Compare commits

...

27 Commits

Author SHA1 Message Date
tristan 06a7ef4f78 fix(front) : repertoires fournisseur et prestataire affichent le code des categories (MAJUSCULE) comme le client (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 2m4s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m37s
formatCategories mappe desormais c.code (slug MAJUSCULE) au lieu de c.name,
pour aligner l'affichage des categories sur le repertoire client.
2026-06-19 16:51:03 +02:00
tristan 3462510c15 fix(front) : pas d'etoile required sur les champs adresse en consultation (4 modules) (ERP-193)
Les *AddressBlock derivent desormais required de l'etat editable
(:required="!readonly && !disabled") : l'asterisque obligatoire reste en
creation/edition mais disparait en consultation (bloc disabled).
2026-06-19 15:45:49 +02:00
tristan ad7d201285 fix(front) : masque la barre d'onglets en consultation fournisseur quand aucun onglet visible + bump @malio/layer-ui 1.7.13 (ERP-193)
- consultation fournisseur : MalioTabList rendu sous v-if="visibleTabKeys.length"
  (aligne sur le client) pour ne plus afficher la bordure quand seul le formulaire
  principal est rempli
- mise a jour @malio/layer-ui ^1.7.12 -> ^1.7.13
2026-06-19 15:39:55 +02:00
tristan 4a2d7cddb0 Merge remote-tracking branch 'origin/fix/erp-193-retours-metier' into fix/erp-193-retours-metier
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 48s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 2m17s
2026-06-19 15:24:59 +02:00
tristan 1e86d9745c fix(front) : toast de succes a la suppression d'un bloc (contact / adresse / RIB / prix) sur les 4 modules (ERP-193)
removeCollectionRow expose un callback onSuccess declenche uniquement apres une
suppression serveur confirmee (pas sur le retrait d'un brouillon local). Cable
sur Client / Fournisseur / Prestataire / Transporteur via notifyRemovalSuccess,
avec un message i18n generique success.deleted.
2026-06-19 15:24:38 +02:00
matthieu 1ef0560604 Merge branch 'develop' into fix/erp-193-retours-metier
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-19 13:08:14 +00:00
tristan 0786e4f461 fix(front) : masque le Valider du premier onglet tant que le formulaire principal n'est pas valide (ERP-193)
Sur les ecrans d'ajout Client / Fournisseur / Prestataire, le Valider du
premier onglet (onglet actif par defaut) etait seulement desactive et
s'affichait a cote de celui du formulaire principal. On le masque desormais
tant que l'entite n'est pas creee (id null).
2026-06-19 15:01:21 +02:00
tristan 833d992ebb fix(transport) : onglet Contact transporteur non obligatoire + navigation onglets (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 46s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m24s
- retrait de la regle « prenom OU nom » sur le bloc Contact : garde
  CarrierContactProcessor::validateName supprimee, CHECK chk_carrier_contact_name
  droppe (migration Version20260619120000), commentaires SQL/catalogue alignes
- front : gating « + Nouveau contact » sur bloc non vide (au lieu de « nomme »),
  onglet Contact vide finalisable sans creer de contact
- Prix accessible des la validation des Adresses (Contacts optionnel ne bloque plus)
- consultation <-> edition : on retombe sur le meme onglet via ?tab=
2026-06-19 14:53:52 +02:00
tristan c11d7822ce refactor(front) : champs anti-parasites via masks maska (filtrage natif, focus/curseur OK) au lieu du sanitizer @update ; email sans masque (ERP-193)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-19 14:25:26 +02:00
tristan e66615d40b fix(transport) : immatriculations LIOT filtrées via mask maska (focus/curseur natifs, plus de hack) (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 12m13s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
2026-06-19 14:10:49 +02:00
tristan 6d3122a0b8 fix(transport) : immatriculations LIOT — re-synchronise le champ après filtrage (:key) pour ne plus afficher les caractères interdits (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 15m5s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 15m7s
2026-06-19 12:02:56 +02:00
tristan fa00a2b6e1 fix(transport) : immatriculations LIOT sur 3 colonnes en consultation aussi (ERP-193)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-19 11:52:24 +02:00
tristan a98f58cb33 feat(transport) : immatriculations LIOT sur 3 colonnes + filtre saisie (lettres/chiffres/tiret/point-virgule) (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
2026-06-19 11:50:05 +02:00
tristan ab33b09bc0 fix(transport) : tableau prix — bord droit Transport noir uniforme (couleur de bordure bas side-specific) (ERP-193)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-19 11:36:45 +02:00
tristan 023f70dd1d fix(transport) : tableau prix — trait fin entre lignes d'une même adresse, épais entre adresses (ERP-193)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-19 11:34:15 +02:00
tristan c243232799 fix(transport) : tableau prix — pas de trait entre les lignes d'une même adresse de livraison (ERP-193)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-19 11:29:29 +02:00
tristan cdd43960cd fix(transport) : tableau prix — regroupement et tri par adresse de livraison, contenant par ligne (ERP-193)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Has been cancelled
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Has been cancelled
2026-06-19 11:17:47 +02:00
tristan 9a42c432f8 fix(transport) : tableau prix — colonnes séparées, ordre Adresse livraisons puis Adresse sites (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 2m19s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m36s
2026-06-19 11:10:17 +02:00
tristan 865e580b6e fix(transport) : tableau prix — colonne Fournisseurs/Clients, fusion adresses sites/livraisons, renomme Transport (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m55s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m19s
2026-06-19 10:50:25 +02:00
tristan 0ad4a739ca style : bouton Archiver en rouge (variant danger) sur les 4 consultations (ERP-193)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 2m16s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m39s
2026-06-19 10:13:52 +02:00
tristan cdcc7d1e75 feat : grise les champs en consultation et onglets validés (readonly → disabled) (ERP-193) 2026-06-19 09:48:49 +02:00
tristan 07f5a95a6b feat : bloque les caractères spéciaux dans les champs texte des 4 répertoires (ERP-193) 2026-06-19 09:46:23 +02:00
tristan 403dc4a870 feat(commercial) : interdit les dates de création futures sur client/fournisseur (ERP-193) 2026-06-18 16:53:07 +02:00
tristan 745b03083c feat(commercial) : plafonne le chiffre d'affaires client/fournisseur à 999 999 999 999,99 (ERP-193) 2026-06-18 16:34:12 +02:00
tristan 868141e324 fix(front) : masque les onglets vides en consultation des 4 repertoires (ERP-193) 2026-06-18 16:15:41 +02:00
tristan 86507486a4 fix(front) : renomme la colonne « Dernière activité » en « Dernière modification » (ERP-193) 2026-06-18 15:58:46 +02:00
tristan 29aa9b352d fix(front) : pagination par defaut a 25 sur les repertoires (ERP-193) 2026-06-18 15:53:25 +02:00
78 changed files with 2117 additions and 559 deletions
+8 -7
View File
@@ -71,7 +71,7 @@
"companyName": "Nom", "companyName": "Nom",
"categories": "Catégories", "categories": "Catégories",
"sites": "Site", "sites": "Site",
"lastActivity": "Dernière activité" "lastActivity": "Dernière modification"
}, },
"filters": { "filters": {
"title": "Filtres", "title": "Filtres",
@@ -215,7 +215,7 @@
"companyName": "Nom", "companyName": "Nom",
"categories": "Catégories", "categories": "Catégories",
"sites": "Site", "sites": "Site",
"lastActivity": "Dernière activité" "lastActivity": "Dernière modification"
}, },
"filters": { "filters": {
"title": "Filtres", "title": "Filtres",
@@ -385,7 +385,7 @@
"companyName": "Nom", "companyName": "Nom",
"categories": "Catégories", "categories": "Catégories",
"sites": "Site", "sites": "Site",
"lastActivity": "Dernière activité" "lastActivity": "Dernière modification"
}, },
"filters": { "filters": {
"title": "Filtres", "title": "Filtres",
@@ -509,7 +509,7 @@
"name": "Nom", "name": "Nom",
"certification": "Certification", "certification": "Certification",
"validityDate": "Date de validité", "validityDate": "Date de validité",
"lastActivity": "Dernière activité" "lastActivity": "Dernière modification"
}, },
"certification": { "certification": {
"QUALIMAT": "QUALIMAT", "QUALIMAT": "QUALIMAT",
@@ -558,8 +558,8 @@
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?" "message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
}, },
"price": { "price": {
"group": "Type de transport", "group": "Transport",
"carrier": "Transporteurs", "carrier": "Fournisseurs / Clients",
"aproOrSite": "Adresse sites", "aproOrSite": "Adresse sites",
"delivery": "Adresse livraisons", "delivery": "Adresse livraisons",
"forfait": "Forfait (€)", "forfait": "Forfait (€)",
@@ -790,7 +790,8 @@
"auth": { "auth": {
"logout": "Deconnexion reussie" "logout": "Deconnexion reussie"
}, },
"title": "Succès" "title": "Succès",
"deleted": "Suppression effectuée"
}, },
"admin": { "admin": {
"roles": { "roles": {
@@ -2,7 +2,7 @@
<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)]"> <div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> <!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly" v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -21,7 +21,8 @@
:options="addressTypeOptions" :options="addressTypeOptions"
:label="t('commercial.clients.form.address.addressType')" :label="t('commercial.clients.form.address.addressType')"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.isProspect" :error="errors?.isProspect"
@update:model-value="onAddressTypeChange" @update:model-value="onAddressTypeChange"
/> />
@@ -33,7 +34,8 @@
:label="t('commercial.clients.form.address.sites')" :label="t('commercial.clients.form.address.sites')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites" :error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/> />
@@ -44,6 +46,7 @@
:label="t('commercial.clients.form.address.contacts')" :label="t('commercial.clients.form.address.contacts')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
@@ -55,8 +58,9 @@
v-if="isBillingEmailRequired(model)" v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail" :model-value="model.billingEmail"
:label="t('commercial.clients.form.address.billingEmail')" :label="t('commercial.clients.form.address.billingEmail')"
:required="true" :required="!readonly && !disabled"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.billingEmail" :error="errors?.billingEmail"
:addable="!model.hasSecondaryBillingEmail && !readonly" :addable="!model.hasSecondaryBillingEmail && !readonly"
@@ -71,6 +75,7 @@
:model-value="model.billingEmailSecondary" :model-value="model.billingEmailSecondary"
:label="t('commercial.clients.form.address.billingEmailSecondary')" :label="t('commercial.clients.form.address.billingEmailSecondary')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.billingEmailSecondary" :error="errors?.billingEmailSecondary"
@update:model-value="(v: string) => update('billingEmailSecondary', v)" @update:model-value="(v: string) => update('billingEmailSecondary', v)"
@@ -82,7 +87,8 @@
:label="t('commercial.clients.form.address.categories')" :label="t('commercial.clients.form.address.categories')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.categories" :error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/> />
@@ -92,7 +98,8 @@
:options="countryOptions" :options="countryOptions"
:label="t('commercial.clients.form.address.country')" :label="t('commercial.clients.form.address.country')"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
@@ -101,7 +108,8 @@
:label="t('commercial.clients.form.address.postalCode')" :label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode" :error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
@@ -115,8 +123,9 @@
:options="cityOptions" :options="cityOptions"
:label="t('commercial.clients.form.address.city')" :label="t('commercial.clients.form.address.city')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="true" :required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
@@ -124,8 +133,10 @@
v-else v-else
:model-value="model.city" :model-value="model.city"
:label="t('commercial.clients.form.address.city')" :label="t('commercial.clients.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
@@ -142,14 +153,15 @@
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. --> pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly" v-if="!readonly && !disabled"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
:allow-create="true" :allow-create="true"
:no-results-text="t('commercial.clients.form.address.streetNotFound')" :no-results-text="t('commercial.clients.form.address.streetNotFound')"
@@ -162,7 +174,8 @@
:model-value="model.street" :model-value="model.street"
:label="t('commercial.clients.form.address.street')" :label="t('commercial.clients.form.address.street')"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
@@ -172,7 +185,9 @@
<MalioInputText <MalioInputText
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')" :label="t('commercial.clients.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement" :error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
@@ -191,6 +206,7 @@ import {
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials' import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm' import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
// Masque code postal FR : 5 chiffres. // Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####' const POSTAL_CODE_MASK = '#####'
@@ -209,6 +225,8 @@ const props = defineProps<{
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -284,6 +302,11 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
// les champs texte editables (complement, ville en mode degrade). La voie en
// autocomplete (BAN) et la ville en select ne sont pas masquees (le back valide
// via Assert\Regex) ; les emails de facturation valident leur format (Assert\Email).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void { function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -4,7 +4,7 @@
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule. non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). --> ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly" v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -15,14 +15,18 @@
<MalioInputText <MalioInputText
:model-value="model.lastName" :model-value="model.lastName"
:label="t('commercial.clients.form.contact.lastName')" :label="t('commercial.clients.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.lastName" :error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
:model-value="model.firstName" :model-value="model.firstName"
:label="t('commercial.clients.form.contact.firstName')" :label="t('commercial.clients.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
@@ -33,7 +37,9 @@
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('commercial.clients.form.contact.jobTitle')" :label="t('commercial.clients.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle" :error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
@@ -42,6 +48,7 @@
:model-value="model.email" :model-value="model.email"
:label="t('commercial.clients.form.contact.email')" :label="t('commercial.clients.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.email" :error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
@@ -51,6 +58,7 @@
:label="t('commercial.clients.form.contact.phonePrimary')" :label="t('commercial.clients.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary" :error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.clients.form.contact.addPhone')" :add-button-label="t('commercial.clients.form.contact.addPhone')"
@@ -63,6 +71,7 @@
:label="t('commercial.clients.form.contact.phoneSecondary')" :label="t('commercial.clients.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary" :error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
@@ -71,6 +80,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm' import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste // Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee). // serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
@@ -85,6 +95,8 @@ const props = defineProps<{
removable?: boolean removable?: 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). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -99,6 +111,10 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template. // Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue) const model = computed(() => props.modelValue)
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void { function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -2,7 +2,7 @@
<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)]"> <div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. --> <!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly" v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -18,8 +18,9 @@
:options="addressTypeOptions" :options="addressTypeOptions"
:label="t('commercial.suppliers.form.address.addressType')" :label="t('commercial.suppliers.form.address.addressType')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="true" :required="!readonly && !disabled"
:error="errors?.addressType" :error="errors?.addressType"
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))" @update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
/> />
@@ -31,7 +32,8 @@
:label="t('commercial.suppliers.form.address.sites')" :label="t('commercial.suppliers.form.address.sites')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites" :error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/> />
@@ -43,6 +45,7 @@
:label="t('commercial.suppliers.form.address.contacts')" :label="t('commercial.suppliers.form.address.contacts')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
@@ -57,7 +60,8 @@
:label="t('commercial.suppliers.form.address.categories')" :label="t('commercial.suppliers.form.address.categories')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.categories" :error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/> />
@@ -67,7 +71,8 @@
:options="countryOptions" :options="countryOptions"
:label="t('commercial.suppliers.form.address.country')" :label="t('commercial.suppliers.form.address.country')"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
@@ -76,7 +81,8 @@
:label="t('commercial.suppliers.form.address.postalCode')" :label="t('commercial.suppliers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode" :error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
@@ -88,8 +94,9 @@
:options="cityOptions" :options="cityOptions"
:label="t('commercial.suppliers.form.address.city')" :label="t('commercial.suppliers.form.address.city')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="true" :required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
@@ -97,8 +104,10 @@
v-else v-else
:model-value="model.city" :model-value="model.city"
:label="t('commercial.suppliers.form.address.city')" :label="t('commercial.suppliers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
@@ -107,14 +116,15 @@
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). --> texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2"> <div class="col-span-2">
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly" v-if="!readonly && !disabled"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('commercial.suppliers.form.address.street')" :label="t('commercial.suppliers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
:allow-create="true" :allow-create="true"
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')" :no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
@@ -126,8 +136,10 @@
v-else v-else
:model-value="model.street" :model-value="model.street"
:label="t('commercial.suppliers.form.address.street')" :label="t('commercial.suppliers.form.address.street')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
@@ -137,7 +149,9 @@
<MalioInputText <MalioInputText
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('commercial.suppliers.form.address.streetComplement')" :label="t('commercial.suppliers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement" :error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
@@ -149,6 +163,7 @@
:label="t('commercial.suppliers.form.address.bennes')" :label="t('commercial.suppliers.form.address.bennes')"
:min="0" :min="0"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.bennes" :error="errors?.bennes"
@update:model-value="(v: string) => update('bennes', v)" @update:model-value="(v: string) => update('bennes', v)"
/> />
@@ -160,6 +175,7 @@
:model-value="model.triageProvider" :model-value="model.triageProvider"
group-class="self-center" group-class="self-center"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
@update:model-value="(v: boolean) => update('triageProvider', v)" @update:model-value="(v: boolean) => update('triageProvider', v)"
/> />
</div> </div>
@@ -169,6 +185,7 @@
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials' import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm' import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
// Masque code postal FR : 5 chiffres. // Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####' const POSTAL_CODE_MASK = '#####'
@@ -187,6 +204,8 @@ const props = defineProps<{
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -238,6 +257,11 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
// les champs texte editables (complement, ville en mode degrade, voie en repli). La
// voie en autocomplete (BAN) et la ville en select ne sont pas masquees (le back
// valide via Assert\Regex).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void { function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -3,7 +3,7 @@
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc, RG-2.13) ou en lecture seule. --> non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly" v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -14,14 +14,18 @@
<MalioInputText <MalioInputText
:model-value="model.lastName" :model-value="model.lastName"
:label="t('commercial.suppliers.form.contact.lastName')" :label="t('commercial.suppliers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.lastName" :error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
:model-value="model.firstName" :model-value="model.firstName"
:label="t('commercial.suppliers.form.contact.firstName')" :label="t('commercial.suppliers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
@@ -32,7 +36,9 @@
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('commercial.suppliers.form.contact.jobTitle')" :label="t('commercial.suppliers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle" :error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
@@ -41,6 +47,7 @@
:model-value="model.email" :model-value="model.email"
:label="t('commercial.suppliers.form.contact.email')" :label="t('commercial.suppliers.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.email" :error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
@@ -50,6 +57,7 @@
:label="t('commercial.suppliers.form.contact.phonePrimary')" :label="t('commercial.suppliers.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary" :error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('commercial.suppliers.form.contact.addPhone')" :add-button-label="t('commercial.suppliers.form.contact.addPhone')"
@@ -62,6 +70,7 @@
:label="t('commercial.suppliers.form.contact.phoneSecondary')" :label="t('commercial.suppliers.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary" :error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
@@ -70,6 +79,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm' import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur). // Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##' const PHONE_MASK = '## ## ## ## ##'
@@ -83,6 +93,8 @@ const props = defineProps<{
removable?: boolean removable?: 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). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -97,6 +109,10 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template. // Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue) const model = computed(() => props.modelValue)
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void { function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Client> {
describe('useClientsRepository', () => { describe('useClientsRepository', () => {
beforeEach(() => { beforeEach(() => {
mockGet.mockReset() mockGet.mockReset()
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2. // 60 items → 3 pages a 25/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(25)) mockGet.mockResolvedValue(makeHydra(60))
}) })
it('cible la ressource /clients en page 1 par defaut', async () => { it('cible la ressource /clients en page 1 par defaut', async () => {
@@ -35,7 +35,7 @@ describe('useClientsRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
'/clients', '/clients',
{ page: 1, itemsPerPage: 10 }, { page: 1, itemsPerPage: 25 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
}) })
@@ -65,7 +65,7 @@ describe('useClientsRepository', () => {
'siteId[]': ['1', '2'], 'siteId[]': ['1', '2'],
archivedOnly: true, archivedOnly: true,
page: 1, page: 1,
itemsPerPage: 10, itemsPerPage: 25,
}, },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
@@ -78,7 +78,7 @@ describe('useClientsRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
'/clients', '/clients',
{ page: 1, itemsPerPage: 10 }, { page: 1, itemsPerPage: 25 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
}) })
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Supplier> {
describe('useSuppliersRepository', () => { describe('useSuppliersRepository', () => {
beforeEach(() => { beforeEach(() => {
mockGet.mockReset() mockGet.mockReset()
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2. // 60 items → 3 pages a 25/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(25)) mockGet.mockResolvedValue(makeHydra(60))
}) })
it('cible la ressource /suppliers en page 1 par defaut', async () => { it('cible la ressource /suppliers en page 1 par defaut', async () => {
@@ -35,7 +35,7 @@ describe('useSuppliersRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers', '/suppliers',
{ page: 1, itemsPerPage: 10 }, { page: 1, itemsPerPage: 25 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
}) })
@@ -65,7 +65,7 @@ describe('useSuppliersRepository', () => {
'siteId[]': ['86', '17'], 'siteId[]': ['86', '17'],
archivedOnly: true, archivedOnly: true,
page: 1, page: 1,
itemsPerPage: 10, itemsPerPage: 25,
}, },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
@@ -78,7 +78,7 @@ describe('useSuppliersRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith( expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers', '/suppliers',
{ page: 1, itemsPerPage: 10 }, { page: 1, itemsPerPage: 25 },
expect.objectContaining({ toast: false }), expect.objectContaining({ toast: false }),
) )
}) })
@@ -49,5 +49,6 @@ export interface Client {
* gerer. * gerer.
*/ */
export function useClientsRepository() { export function useClientsRepository() {
return usePaginatedList<Client>({ url: '/clients' }) // Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
return usePaginatedList<Client>({ url: '/clients', defaultItemsPerPage: 25 })
} }
@@ -51,5 +51,6 @@ export interface Supplier {
* `usePaginatedList`. Aucun reset au logout a gerer. * `usePaginatedList`. Aucun reset au logout a gerer.
*/ */
export function useSuppliersRepository() { export function useSuppliersRepository() {
return usePaginatedList<Supplier>({ url: '/suppliers' }) // Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
return usePaginatedList<Supplier>({ url: '/suppliers', defaultItemsPerPage: 25 })
} }
@@ -25,9 +25,10 @@
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="main.companyName" v-model="main.companyName"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
:required="true" :required="true"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
@@ -35,7 +36,7 @@
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
:readonly="businessReadonly" :disabled="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -46,7 +47,7 @@
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" :empty-option-label="t('commercial.clients.form.main.relationNone')"
:readonly="businessReadonly" :disabled="businessReadonly"
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
@@ -54,7 +55,7 @@
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="brokerOptions" :options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
:readonly="businessReadonly" :disabled="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.broker" :error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
@@ -64,7 +65,7 @@
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="distributorOptions" :options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
:readonly="businessReadonly" :disabled="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.distributor" :error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
@@ -74,7 +75,7 @@
v-model="main.triageService" v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
:readonly="businessReadonly" :disabled="businessReadonly"
/> />
</div> </div>
@@ -101,20 +102,24 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.competitors" :error="informationErrors.errors.competitors"
/> />
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:readonly="businessReadonly" :disabled="businessReadonly"
:editable="true" :editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v" @update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
@@ -122,25 +127,30 @@
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.employeesCount" :error="informationErrors.errors.employeesCount"
/> />
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount <MalioInputAmount
v-model="information.revenueAmount" :key="revenueAmountKey"
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.revenueAmount" :error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:mask="PERSON_NAME_MASK"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.directorName" :error="informationErrors.errors.directorName"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.profitAmount" :error="informationErrors.errors.profitAmount"
/> />
</div> </div>
@@ -167,7 +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)"
:readonly="businessReadonly" :disabled="businessReadonly"
: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)"
@@ -204,7 +214,7 @@
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly" :disabled="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -239,14 +249,15 @@
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
/> />
@@ -254,7 +265,7 @@
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -262,8 +273,9 @@
/> />
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
/> />
@@ -271,7 +283,7 @@
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -281,7 +293,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -292,7 +304,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="bankOptions" :options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -319,21 +331,23 @@
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
/> />
@@ -423,6 +437,9 @@ import {
type InformationFormDraft, type InformationFormDraft,
type MainFormDraft, type MainFormDraft,
} from '~/modules/commercial/utils/forms/clientEdit' } from '~/modules/commercial/utils/forms/clientEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { import {
buildClientFormTabKeys, buildClientFormTabKeys,
isAddressValid, isAddressValid,
@@ -491,6 +508,22 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail)) const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail)) const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail))
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail)) const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail))
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
const contacts = ref<ContactFormDraft[]>([]) const contacts = ref<ContactFormDraft[]>([])
const addresses = ref<AddressFormDraft[]>([]) const addresses = ref<AddressFormDraft[]>([])
const ribs = ref<RibFormDraft[]>([]) const ribs = ref<RibFormDraft[]>([])
@@ -668,6 +701,11 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
}) })
} }
/** Toast de succès après suppression serveur confirmée d'un bloc (contact / adresse / RIB). */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
// ── Erreurs de validation par champ (ERP-101) ─────────────────────────────── // ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) : // Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour // un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
@@ -767,6 +805,7 @@ function askRemoveContact(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact, makeEmpty: emptyContact,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -844,6 +883,7 @@ function askRemoveAddress(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress, makeEmpty: emptyAddress,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -944,6 +984,7 @@ function askRemoveRib(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib, makeEmpty: emptyRib,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -23,7 +23,7 @@
/> />
<MalioButton <MalioButton
v-if="showArchive" v-if="showArchive"
variant="secondary" variant="danger"
icon-name="mdi:archive-arrow-down-outline" icon-name="mdi:archive-arrow-down-outline"
icon-position="left" icon-position="left"
:label="t('commercial.clients.action.archive')" :label="t('commercial.clients.action.archive')"
@@ -50,14 +50,14 @@
<MalioInputText <MalioInputText
:model-value="client.companyName" :model-value="client.companyName"
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
readonly disabled
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="categoryIris" :model-value="categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
readonly disabled
/> />
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. --> <!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
<MalioSelect <MalioSelect
@@ -65,7 +65,7 @@
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" :empty-option-label="t('commercial.clients.form.main.relationNone')"
readonly disabled
/> />
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant, <!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
aucune valeur sans relation meme comportement qu'en edition). --> aucune valeur sans relation meme comportement qu'en edition). -->
@@ -73,18 +73,20 @@
v-if="relation.type" v-if="relation.type"
:model-value="relation.name" :model-value="relation.name"
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')" :label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
readonly disabled
/> />
<MalioCheckbox <MalioCheckbox
:model-value="client.triageService === true" :model-value="client.triageService === true"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
readonly disabled
/> />
</div> </div>
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── --> <!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <!-- ERP-193 : on n'affiche la barre que s'il reste au moins un onglet
non vide (sinon seul le bloc principal est visible). -->
<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 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
@@ -97,37 +99,37 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="information.competitors" :model-value="information.competitors"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
readonly disabled
/> />
<MalioDate <MalioDate
:model-value="information.foundedAt" :model-value="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="information.employeesCount" :model-value="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
readonly disabled
/> />
<MalioInputAmount <MalioInputAmount
:model-value="information.revenueAmount" :model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="information.directorName" :model-value="information.directorName"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
readonly disabled
/> />
<MalioInputAmount <MalioInputAmount
:model-value="information.profitAmount" :model-value="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
readonly disabled
/> />
</div> </div>
</template> </template>
@@ -140,7 +142,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 })"
readonly disabled
/> />
</div> </div>
</template> </template>
@@ -157,7 +159,7 @@
:site-options="allSiteOptions" :site-options="allSiteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
readonly disabled
/> />
</div> </div>
</template> </template>
@@ -171,38 +173,38 @@
:model-value="accounting.siren" :model-value="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="accounting.accountNumber" :model-value="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
readonly disabled
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
empty-option-label="" empty-option-label=""
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="accounting.nTva" :model-value="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
readonly disabled
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
empty-option-label="" empty-option-label=""
readonly disabled
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
empty-option-label="" empty-option-label=""
readonly disabled
/> />
<MalioSelect <MalioSelect
v-if="accounting.bankIri" v-if="accounting.bankIri"
@@ -210,7 +212,7 @@
:options="bankOptions" :options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
empty-option-label="" empty-option-label=""
readonly disabled
/> />
</div> </div>
</div> </div>
@@ -225,28 +227,25 @@
<MalioInputText <MalioInputText
:model-value="rib.label" :model-value="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="rib.bic" :model-value="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="rib.iban" :model-value="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
readonly disabled
/> />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
<!-- Onglets non encore implementes : frame vide (navigation libre). --> Rapports / Echanges) ne sont plus rendus en consultation
<template #transport><ComingSoonPlaceholder /></template> (masquage des onglets vides) — slots supprimes. -->
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -278,13 +277,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient' import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules' import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
import { import {
canEditClient, canEditClient,
categoryOptionsOf, categoryOptionsOf,
clientConsultationVisibleTabs,
contactOptionsOf, contactOptionsOf,
mapAccountingDraft, mapAccountingDraft,
mapAddressView, mapAddressView,
@@ -412,9 +412,11 @@ const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paym
const bankOptions = computed(() => referentialOptionOf(client.value?.bank)) const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ──── // ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
// 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et // ERP-193 (retour metier) : on masque les coquilles non implementees ET tout
// 4 coquilles (Transport, Statistiques, Rapports, Echanges). // onglet de donnees vide. La liste depend donc du payload charge.
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true })) const visibleTabKeys = computed(() => clientConsultationVisibleTabs(client.value, {
canAccountingView: canAccountingView.value,
}))
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline', information: 'mdi:account-outline',
@@ -427,14 +429,26 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:account-group-outline', exchanges: 'mdi:account-group-outline',
} }
const tabs = computed(() => tabKeys.value.map(key => ({ const tabs = computed(() => visibleTabKeys.value.map(key => ({
key, key,
label: t(`commercial.clients.tab.${key}`), label: t(`commercial.clients.tab.${key}`),
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
// Onglet initial : repris de l'edition au retour (history.state), sinon Information. // Onglet initial : vide tant que le client n'est pas charge. Des que la liste
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information') // des onglets visibles est connue, on cale sur l'onglet repris de l'edition
// (history.state) s'il est encore visible, sinon le premier onglet visible.
// Un watcher recale aussi si l'onglet courant disparait (ex: changement de droit).
const activeTab = ref('')
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = readHistoryTab(keys) ?? keys[0]
}
}, { immediate: true })
// ── Navigation ───────────────────────────────────────────────────────────── // ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void { function goBack(): void {
@@ -19,9 +19,10 @@
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="main.companyName" v-model="main.companyName"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.main.companyName')" :label="t('commercial.clients.form.main.companyName')"
:required="true" :required="true"
:readonly="mainLocked" :disabled="mainLocked"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
@@ -29,7 +30,7 @@
:options="referentials.categories.value" :options="referentials.categories.value"
:label="t('commercial.clients.form.main.categories')" :label="t('commercial.clients.form.main.categories')"
:display-tag="true" :display-tag="true"
:readonly="mainLocked" :disabled="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -40,7 +41,7 @@
:options="relationOptions" :options="relationOptions"
:label="t('commercial.clients.form.main.relation')" :label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')" :empty-option-label="t('commercial.clients.form.main.relationNone')"
:readonly="mainLocked" :disabled="mainLocked"
@update:model-value="onRelationChange" @update:model-value="onRelationChange"
/> />
<MalioSelect <MalioSelect
@@ -48,7 +49,7 @@
:model-value="main.brokerIri" :model-value="main.brokerIri"
:options="referentials.brokers.value" :options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')" :label="t('commercial.clients.form.main.brokerName')"
:readonly="mainLocked" :disabled="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.broker" :error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
@@ -58,7 +59,7 @@
:model-value="main.distributorIri" :model-value="main.distributorIri"
:options="referentials.distributors.value" :options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')" :label="t('commercial.clients.form.main.distributorName')"
:readonly="mainLocked" :disabled="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.distributor" :error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
@@ -68,7 +69,7 @@
v-model="main.triageService" v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')" :label="t('commercial.clients.form.main.triageService')"
group-class="self-center" group-class="self-center"
:readonly="mainLocked" :disabled="mainLocked"
/> />
</div> </div>
@@ -96,20 +97,24 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.information.competitors')" :label="t('commercial.clients.form.information.competitors')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.competitors" :error="informationErrors.errors.competitors"
/> />
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')" :label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:editable="true" :editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v" @update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
@@ -117,37 +122,42 @@
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')" :label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.employeesCount" :error="informationErrors.errors.employeesCount"
/> />
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount <MalioInputAmount
v-model="information.revenueAmount" :key="revenueAmountKey"
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')" :label="t('commercial.clients.form.information.revenueAmount')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.revenueAmount" :error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:mask="PERSON_NAME_MASK"
:label="t('commercial.clients.form.information.directorName')" :label="t('commercial.clients.form.information.directorName')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.directorName" :error="informationErrors.errors.directorName"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')" :label="t('commercial.clients.form.information.profitAmount')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.profitAmount" :error="informationErrors.errors.profitAmount"
/> />
</div> </div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center"> <!-- Masque tant que le client n'est pas cree : Information etant
<!-- Desactive tant que le client n'est pas cree (evite un PATCH l'onglet actif par defaut, son Valider ne doit pas apparaitre a
avant le POST si clic trop tot, Information etant l'onglet cote de celui du formulaire principal (ERP-193). Onglet facultatif :
actif par defaut). Onglet facultatif : un enregistrement a un enregistrement a vide reste possible, c'est le back qui valide. -->
vide reste possible, c'est le back qui valide. --> <div v-if="!isValidated('information') && clientId !== null" class="mt-12 flex justify-center">
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting || clientId === null" :disabled="tabSubmitting"
@click="submitInformation" @click="submitInformation"
/> />
</div> </div>
@@ -166,7 +176,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)"
:readonly="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"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -203,7 +213,7 @@
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:readonly="isValidated('address')" :disabled="isValidated('address')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -237,14 +247,15 @@
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')" :label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.accountNumber')" :label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
/> />
@@ -252,7 +263,7 @@
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('commercial.clients.form.accounting.tvaMode')" :label="t('commercial.clients.form.accounting.tvaMode')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -260,8 +271,9 @@
/> />
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.nTva')" :label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
/> />
@@ -269,7 +281,7 @@
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('commercial.clients.form.accounting.paymentDelay')" :label="t('commercial.clients.form.accounting.paymentDelay')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -279,7 +291,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('commercial.clients.form.accounting.paymentType')" :label="t('commercial.clients.form.accounting.paymentType')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -290,7 +302,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('commercial.clients.form.accounting.bank')" :label="t('commercial.clients.form.accounting.bank')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -318,21 +330,23 @@
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')" :label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribBic')" :label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribIban')" :label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
/> />
@@ -407,6 +421,9 @@ import {
lastFillableTabKey, lastFillableTabKey,
showsRelationAndTriageFields, showsRelationAndTriageFields,
} from '~/modules/commercial/utils/forms/clientFormRules' } from '~/modules/commercial/utils/forms/clientFormRules'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { import {
buildAddressPayload, buildAddressPayload,
buildMainPayload, buildMainPayload,
@@ -665,6 +682,22 @@ const information = reactive({
directorName: null as string | null, directorName: null as string | null,
}) })
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */ /** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return if (clientId.value === null || tabSubmitting.value) return
@@ -26,15 +26,16 @@
v-model="main.companyName" v-model="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')" :label="t('commercial.suppliers.form.main.companyName')"
:required="true" :required="true"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
:mask="FREE_TEXT_MASK"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('commercial.suppliers.form.main.categories')" :label="t('commercial.suppliers.form.main.categories')"
:display-tag="true" :display-tag="true"
:readonly="businessReadonly" :disabled="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -62,20 +63,24 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')" :label="t('commercial.suppliers.form.information.competitors')"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.competitors" :error="informationErrors.errors.competitors"
:mask="FREE_TEXT_MASK"
/> />
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')" :label="t('commercial.suppliers.form.information.foundedAt')"
:readonly="businessReadonly" :disabled="businessReadonly"
:editable="true" :editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v" @update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
@@ -83,25 +88,30 @@
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')" :label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.employeesCount" :error="informationErrors.errors.employeesCount"
/> />
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount <MalioInputAmount
v-model="information.revenueAmount" :key="revenueAmountKey"
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')" :label="t('commercial.suppliers.form.information.revenueAmount')"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.revenueAmount" :error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')" :label="t('commercial.suppliers.form.information.directorName')"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.directorName" :error="informationErrors.errors.directorName"
:mask="PERSON_NAME_MASK"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')" :label="t('commercial.suppliers.form.information.profitAmount')"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.profitAmount" :error="informationErrors.errors.profitAmount"
/> />
<!-- Volume previsionnel : specifique fournisseur (entier). --> <!-- Volume previsionnel : specifique fournisseur (entier). -->
@@ -109,7 +119,7 @@
v-model="information.volumeForecast" v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')" :label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK" :mask="VOLUME_FORECAST_MASK"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="informationErrors.errors.volumeForecast" :error="informationErrors.errors.volumeForecast"
/> />
</div> </div>
@@ -136,7 +146,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)"
:readonly="businessReadonly" :disabled="businessReadonly"
: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)"
@@ -173,7 +183,7 @@
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly" :disabled="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -208,22 +218,23 @@
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')" :label="t('commercial.suppliers.form.accounting.accountNumber')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
:mask="CODE_ALNUM_MASK"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')" :label="t('commercial.suppliers.form.accounting.tvaMode')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -232,15 +243,16 @@
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')" :label="t('commercial.suppliers.form.accounting.nTva')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
:mask="CODE_ALNUM_MASK"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')" :label="t('commercial.suppliers.form.accounting.paymentDelay')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -250,7 +262,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')" :label="t('commercial.suppliers.form.accounting.paymentType')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -261,7 +273,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="bankOptions" :options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')" :label="t('commercial.suppliers.form.accounting.bank')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -288,23 +300,25 @@
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')" :label="t('commercial.suppliers.form.accounting.ribBic')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
:mask="CODE_ALNUM_MASK"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')" :label="t('commercial.suppliers.form.accounting.ribIban')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
:mask="CODE_ALNUM_MASK"
/> />
</div> </div>
</div> </div>
@@ -392,6 +406,8 @@ import {
type MainFormDraft, type MainFormDraft,
type SupplierEditAbilities, type SupplierEditAbilities,
} from '~/modules/commercial/utils/forms/supplierEdit' } from '~/modules/commercial/utils/forms/supplierEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { import {
buildSupplierFormTabKeys, buildSupplierFormTabKeys,
isAddressValid, isAddressValid,
@@ -412,6 +428,7 @@ import {
} from '~/modules/commercial/types/supplierForm' } from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow' import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
@@ -457,6 +474,22 @@ const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.
const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail)) const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail)) const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail))
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as SupplierDetail)) const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as SupplierDetail))
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
const contacts = ref<SupplierContactFormDraft[]>([]) const contacts = ref<SupplierContactFormDraft[]>([])
const addresses = ref<SupplierAddressFormDraft[]>([]) const addresses = ref<SupplierAddressFormDraft[]>([])
const ribs = ref<SupplierRibFormDraft[]>([]) const ribs = ref<SupplierRibFormDraft[]>([])
@@ -583,6 +616,11 @@ function showError(e: unknown): void {
toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(e) }) toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(e) })
} }
/** Toast de succès après suppression serveur confirmée d'un bloc (contact / adresse / RIB). */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
// ── Erreurs de validation par champ (ERP-101) ─────────────────────────────── // ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
const { const {
mainErrors, mainErrors,
@@ -666,6 +704,7 @@ function askRemoveContact(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact, makeEmpty: emptyContact,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -734,6 +773,7 @@ function askRemoveAddress(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress, makeEmpty: emptyAddress,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -833,6 +873,7 @@ function askRemoveRib(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib, makeEmpty: emptyRib,
onError: showError, onError: showError,
onSuccess: notifyRemovalSuccess,
})) }))
} }
@@ -23,7 +23,7 @@
/> />
<MalioButton <MalioButton
v-if="showArchive" v-if="showArchive"
variant="secondary" variant="danger"
icon-name="mdi:archive-arrow-down-outline" icon-name="mdi:archive-arrow-down-outline"
icon-position="left" icon-position="left"
:label="t('commercial.suppliers.action.archive')" :label="t('commercial.suppliers.action.archive')"
@@ -50,19 +50,22 @@
<MalioInputText <MalioInputText
:model-value="supplier.companyName" :model-value="supplier.companyName"
:label="t('commercial.suppliers.form.main.companyName')" :label="t('commercial.suppliers.form.main.companyName')"
readonly disabled
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="categoryIris" :model-value="categoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('commercial.suppliers.form.main.categories')" :label="t('commercial.suppliers.form.main.categories')"
:display-tag="true" :display-tag="true"
readonly disabled
/> />
</div> </div>
<!-- Onglets (navigation libre, tout en lecture seule) --> <!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <!-- Masque la barre d'onglets (et sa bordure) quand aucun onglet n'est
visible : seul le formulaire principal est rempli (aligné sur le
client). -->
<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 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
@@ -74,43 +77,43 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="information.competitors" :model-value="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')" :label="t('commercial.suppliers.form.information.competitors')"
readonly disabled
/> />
<MalioDate <MalioDate
:model-value="information.foundedAt" :model-value="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')" :label="t('commercial.suppliers.form.information.foundedAt')"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="information.employeesCount" :model-value="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')" :label="t('commercial.suppliers.form.information.employeesCount')"
readonly disabled
/> />
<MalioInputAmount <MalioInputAmount
:model-value="information.revenueAmount" :model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')" :label="t('commercial.suppliers.form.information.revenueAmount')"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="information.directorName" :model-value="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')" :label="t('commercial.suppliers.form.information.directorName')"
readonly disabled
/> />
<MalioInputAmount <MalioInputAmount
:model-value="information.profitAmount" :model-value="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')" :label="t('commercial.suppliers.form.information.profitAmount')"
readonly disabled
/> />
<!-- Volume previsionnel : specifique fournisseur (entier). --> <!-- Volume previsionnel : specifique fournisseur (entier). -->
<MalioInputText <MalioInputText
:model-value="information.volumeForecast" :model-value="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')" :label="t('commercial.suppliers.form.information.volumeForecast')"
readonly disabled
/> />
</div> </div>
</template> </template>
@@ -123,7 +126,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 })"
readonly disabled
/> />
</div> </div>
</template> </template>
@@ -140,7 +143,7 @@
:site-options="allSiteOptions" :site-options="allSiteOptions"
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
readonly disabled
/> />
</div> </div>
</template> </template>
@@ -154,38 +157,38 @@
:model-value="accounting.siren" :model-value="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="accounting.accountNumber" :model-value="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')" :label="t('commercial.suppliers.form.accounting.accountNumber')"
readonly disabled
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="tvaModeOptions" :options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')" :label="t('commercial.suppliers.form.accounting.tvaMode')"
empty-option-label="" empty-option-label=""
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="accounting.nTva" :model-value="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')" :label="t('commercial.suppliers.form.accounting.nTva')"
readonly disabled
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions" :options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')" :label="t('commercial.suppliers.form.accounting.paymentDelay')"
empty-option-label="" empty-option-label=""
readonly disabled
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions" :options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')" :label="t('commercial.suppliers.form.accounting.paymentType')"
empty-option-label="" empty-option-label=""
readonly disabled
/> />
<MalioSelect <MalioSelect
v-if="accounting.bankIri" v-if="accounting.bankIri"
@@ -193,7 +196,7 @@
:options="bankOptions" :options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')" :label="t('commercial.suppliers.form.accounting.bank')"
empty-option-label="" empty-option-label=""
readonly disabled
/> />
</div> </div>
</div> </div>
@@ -208,28 +211,25 @@
<MalioInputText <MalioInputText
:model-value="rib.label" :model-value="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="rib.bic" :model-value="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')" :label="t('commercial.suppliers.form.accounting.ribBic')"
readonly disabled
/> />
<MalioInputText <MalioInputText
:model-value="rib.iban" :model-value="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')" :label="t('commercial.suppliers.form.accounting.ribIban')"
readonly disabled
/> />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
<!-- Onglets non encore implementes : frame vide (navigation libre). --> Rapports / Echanges) ne sont plus rendus en consultation
<template #transport><ComingSoonPlaceholder /></template> (masquage des onglets vides) slots supprimes. -->
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -261,9 +261,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier' import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules' import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab' import { readHistoryTab } from '~/shared/utils/historyTab'
import { import {
canEditSupplier, canEditSupplier,
@@ -278,6 +278,7 @@ import {
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
supplierConsultationVisibleTabs,
type SelectOption, type SelectOption,
type SupplierDetail, type SupplierDetail,
} from '~/modules/commercial/utils/forms/supplierConsultation' } from '~/modules/commercial/utils/forms/supplierConsultation'
@@ -387,9 +388,11 @@ const paymentTypeOptions = computed(() => referentialOptionOf(supplier.value?.pa
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank)) const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ──── // ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
// 3 onglets actifs (Information, Contacts, Adresses, + Comptabilite si droit) et // ERP-193 (retour metier) : on masque les coquilles non implementees ET tout
// 4 coquilles (Transport, Statistiques, Rapports, Echanges). // onglet de donnees vide. La liste depend donc du payload charge.
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true })) const visibleTabKeys = computed(() => supplierConsultationVisibleTabs(supplier.value, {
canAccountingView: canAccountingView.value,
}))
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline', information: 'mdi:account-outline',
@@ -402,14 +405,25 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:account-group-outline', exchanges: 'mdi:account-group-outline',
} }
const tabs = computed(() => tabKeys.value.map(key => ({ const tabs = computed(() => visibleTabKeys.value.map(key => ({
key, key,
label: t(`commercial.suppliers.tab.${key}`), label: t(`commercial.suppliers.tab.${key}`),
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
// Onglet initial : repris de l'edition au retour (history.state), sinon Information. // Onglet initial : vide tant que le fournisseur n'est pas charge. Des que la
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information') // liste des onglets visibles est connue, on cale sur l'onglet repris de
// l'edition (history.state) s'il est encore visible, sinon le premier visible.
const activeTab = ref('')
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = readHistoryTab(keys) ?? keys[0]
}
}, { immediate: true })
// ── Navigation ───────────────────────────────────────────────────────────── // ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void { function goBack(): void {
@@ -43,7 +43,7 @@
@update:page="goToPage" @update:page="goToPage"
@update:per-page="setItemsPerPage" @update:per-page="setItemsPerPage"
> >
<!-- Categories : libelles (name) separes par une virgule (spec M2). --> <!-- Categories : codes stables (MAJUSCULE) separes par une virgule, aligne sur le client (ERP-193). -->
<template #cell-categories="{ item }"> <template #cell-categories="{ item }">
{{ formatCategories(item) }} {{ formatCategories(item) }}
</template> </template>
@@ -209,10 +209,10 @@ const columns = [
{ key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') }, { key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') },
] ]
/** Libelles des categories du fournisseur, separes par une virgule (spec M2 : name). */ /** Codes (MAJUSCULE) des categories du fournisseur, separes par une virgule (aligne sur le client, ERP-193). */
function formatCategories(item: Record<string, unknown>): string { function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Supplier['categories']) ?? [] const categories = (item.categories as Supplier['categories']) ?? []
return categories.map(c => c.name).join(', ') return categories.map(c => c.code).join(', ')
} }
/** /**
@@ -21,15 +21,16 @@
v-model="main.companyName" v-model="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')" :label="t('commercial.suppliers.form.main.companyName')"
:required="true" :required="true"
:readonly="mainLocked" :disabled="mainLocked"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
:mask="FREE_TEXT_MASK"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="main.categoryIris" :model-value="main.categoryIris"
:options="referentials.categories.value" :options="referentials.categories.value"
:label="t('commercial.suppliers.form.main.categories')" :label="t('commercial.suppliers.form.main.categories')"
:display-tag="true" :display-tag="true"
:readonly="mainLocked" :disabled="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -56,20 +57,24 @@
resize="none" resize="none"
group-class="row-span-2 pt-1 pb-1" group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg" text-input="h-full text-lg"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.description" :error="informationErrors.errors.description"
/> />
<MalioInputText <MalioInputText
v-model="information.competitors" v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')" :label="t('commercial.suppliers.form.information.competitors')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.competitors" :error="informationErrors.errors.competitors"
:mask="FREE_TEXT_MASK"
/> />
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate <MalioDate
v-model="information.foundedAt" v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')" :label="t('commercial.suppliers.form.information.foundedAt')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:editable="true" :editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt" :error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v" @update:raw-value="(v: string) => information.foundedAtRaw = v"
/> />
@@ -77,25 +82,30 @@
v-model="information.employeesCount" v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')" :label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK" :mask="EMPLOYEES_MASK"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.employeesCount" :error="informationErrors.errors.employeesCount"
/> />
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount <MalioInputAmount
v-model="information.revenueAmount" :key="revenueAmountKey"
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')" :label="t('commercial.suppliers.form.information.revenueAmount')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.revenueAmount" :error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/> />
<MalioInputText <MalioInputText
v-model="information.directorName" v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')" :label="t('commercial.suppliers.form.information.directorName')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.directorName" :error="informationErrors.errors.directorName"
:mask="PERSON_NAME_MASK"
/> />
<MalioInputAmount <MalioInputAmount
v-model="information.profitAmount" v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')" :label="t('commercial.suppliers.form.information.profitAmount')"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.profitAmount" :error="informationErrors.errors.profitAmount"
/> />
<!-- Volume previsionnel : specifique fournisseur. Champ texte <!-- Volume previsionnel : specifique fournisseur. Champ texte
@@ -104,15 +114,18 @@
v-model="information.volumeForecast" v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')" :label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK" :mask="VOLUME_FORECAST_MASK"
:readonly="isValidated('information')" :disabled="isValidated('information')"
:error="informationErrors.errors.volumeForecast" :error="informationErrors.errors.volumeForecast"
/> />
</div> </div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center"> <!-- Masque tant que le fournisseur n'est pas cree : Information etant
l'onglet actif par defaut, son Valider ne doit pas apparaitre a cote
de celui du formulaire principal (ERP-193). -->
<div v-if="!isValidated('information') && supplierId !== null" class="mt-12 flex justify-center">
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.suppliers.form.submit')" :label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting || supplierId === null" :disabled="tabSubmitting"
@click="submitInformation" @click="submitInformation"
/> />
</div> </div>
@@ -131,7 +144,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)"
:readonly="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"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -168,7 +181,7 @@
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:readonly="isValidated('addresses')" :disabled="isValidated('addresses')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -202,22 +215,23 @@
v-model="accounting.siren" v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')" :label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')" :label="t('commercial.suppliers.form.accounting.accountNumber')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
:mask="CODE_ALNUM_MASK"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('commercial.suppliers.form.accounting.tvaMode')" :label="t('commercial.suppliers.form.accounting.tvaMode')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -226,15 +240,16 @@
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')" :label="t('commercial.suppliers.form.accounting.nTva')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
:mask="CODE_ALNUM_MASK"
/> />
<MalioSelect <MalioSelect
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('commercial.suppliers.form.accounting.paymentDelay')" :label="t('commercial.suppliers.form.accounting.paymentDelay')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -244,7 +259,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('commercial.suppliers.form.accounting.paymentType')" :label="t('commercial.suppliers.form.accounting.paymentType')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -255,7 +270,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('commercial.suppliers.form.accounting.bank')" :label="t('commercial.suppliers.form.accounting.bank')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -282,23 +297,25 @@
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')" :label="t('commercial.suppliers.form.accounting.ribLabel')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')" :label="t('commercial.suppliers.form.accounting.ribBic')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
:mask="CODE_ALNUM_MASK"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')" :label="t('commercial.suppliers.form.accounting.ribIban')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="isRibRequired" :required="isRibRequired"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
:mask="CODE_ALNUM_MASK"
/> />
</div> </div>
</div> </div>
@@ -375,6 +392,8 @@ import {
buildMainPayload, buildMainPayload,
buildRibPayload, buildRibPayload,
} from '~/modules/commercial/utils/forms/supplierEdit' } from '~/modules/commercial/utils/forms/supplierEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { import {
emptyAddress, emptyAddress,
emptyContact, emptyContact,
@@ -385,6 +404,7 @@ import {
} from '~/modules/commercial/types/supplierForm' } from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow' import { isRowRemovable } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
// Masques de saisie (la normalisation finale reste serveur). // Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -564,6 +584,22 @@ const information = reactive({
volumeForecast: null as string | null, volumeForecast: null as string | null,
}) })
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
/** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */ /** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return if (supplierId.value === null || tabSubmitting.value) return
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { clampRevenueAmount, REVENUE_AMOUNT_MAX } from '../amountInput'
describe('clampRevenueAmount', () => {
it('laisse les valeurs vides / nulles telles quelles', () => {
expect(clampRevenueAmount(null)).toBeNull()
expect(clampRevenueAmount(undefined)).toBeUndefined()
expect(clampRevenueAmount('')).toBe('')
})
it('laisse une valeur sous le plafond inchangee', () => {
expect(clampRevenueAmount('1000.50')).toBe('1000.50')
expect(clampRevenueAmount('999999999999.99')).toBe('999999999999.99')
})
it('plafonne une valeur au-dessus du maximum', () => {
expect(clampRevenueAmount('1000000000000')).toBe('999999999999.99')
expect(clampRevenueAmount('999999999999999.99')).toBe('999999999999.99')
})
it('tolere une saisie a virgule / avec espaces (securite)', () => {
expect(clampRevenueAmount('1 000 000 000 000,00')).toBe('999999999999.99')
expect(clampRevenueAmount('12,5')).toBe('12,5')
})
it('ne touche pas une saisie non numerique', () => {
expect(clampRevenueAmount('abc')).toBe('abc')
})
it('expose le plafond metier', () => {
expect(REVENUE_AMOUNT_MAX).toBe(999_999_999_999.99)
})
})
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
import { import {
canEditClient, canEditClient,
categoryOptionsOf, categoryOptionsOf,
clientConsultationVisibleTabs,
contactOptionsOf, contactOptionsOf,
hasAccountingData,
hasInformationData,
iriOf, iriOf,
mapAccountingDraft, mapAccountingDraft,
mapAddressToDraft, mapAddressToDraft,
@@ -248,3 +251,73 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
expect(paymentTypeCodeOf(undefined)).toBeNull() expect(paymentTypeCodeOf(undefined)).toBeNull()
}) })
}) })
describe('hasInformationData', () => {
it('faux si tous les champs Information sont vides/absents', () => {
expect(hasInformationData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, description: ' ' })).toBe(false)
})
it('vrai des qu\'un champ Information porte une donnee', () => {
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, directorName: 'Dupont' })).toBe(true)
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, employeesCount: 0 })).toBe(true)
})
})
describe('hasAccountingData', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable scalaire', () => {
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1, siren: '123456789' })).toBe(true)
})
it('vrai avec une relation comptable embarquee (paymentType)', () => {
expect(hasAccountingData({
'@id': '/api/clients/1', id: 1,
paymentType: { '@id': '/api/payment_types/1', code: 'LCR' },
})).toBe(true)
})
it('vrai avec au moins un RIB', () => {
expect(hasAccountingData({
'@id': '/api/clients/1', id: 1,
ribs: [{ '@id': '/api/ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('clientConsultationVisibleTabs', () => {
it('retourne [] tant que le client n\'est pas charge', () => {
expect(clientConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
expect(clientConsultationVisibleTabs(undefined, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles et les onglets vides (client minimal)', () => {
const client: ClientDetail = { '@id': '/api/clients/1', id: 1, companyName: 'ACME' }
expect(clientConsultationVisibleTabs(client, { canAccountingView: true })).toEqual([])
})
it('affiche les onglets non vides dans l\'ordre information/contact/address/accounting', () => {
const client: ClientDetail = {
'@id': '/api/clients/1', id: 1,
directorName: 'Dupont',
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/client_addresses/1', id: 1 }],
siren: '123456789',
}
expect(clientConsultationVisibleTabs(client, { canAccountingView: true }))
.toEqual(['information', 'contact', 'address', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view meme si des donnees existent', () => {
const client: ClientDetail = {
'@id': '/api/clients/1', id: 1,
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
siren: '123456789',
}
expect(clientConsultationVisibleTabs(client, { canAccountingView: false }))
.toEqual(['contact'])
})
})
@@ -3,6 +3,8 @@ import {
canEditSupplier, canEditSupplier,
categoryOptionsOf, categoryOptionsOf,
contactOptionsOf, contactOptionsOf,
hasAccountingData,
hasInformationData,
iriOf, iriOf,
mapAccountingDraft, mapAccountingDraft,
mapAddressToDraft, mapAddressToDraft,
@@ -14,6 +16,7 @@ import {
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
siteOptionsOf, siteOptionsOf,
supplierConsultationVisibleTabs,
type SupplierDetail, type SupplierDetail,
} from '../supplierConsultation' } from '../supplierConsultation'
@@ -237,3 +240,60 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
expect(paymentTypeCodeOf(undefined)).toBeNull() expect(paymentTypeCodeOf(undefined)).toBeNull()
}) })
}) })
describe('hasInformationData (fournisseur)', () => {
it('faux si tous les champs Information (volume previsionnel inclus) sont vides', () => {
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
})
it('vrai des qu\'un champ Information porte une donnee', () => {
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, directorName: 'Martin' })).toBe(true)
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, volumeForecast: 1200 })).toBe(true)
})
})
describe('hasAccountingData (fournisseur)', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable ou un RIB', () => {
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1, siren: '123456789' })).toBe(true)
expect(hasAccountingData({
'@id': '/api/suppliers/1', id: 1,
ribs: [{ '@id': '/api/supplier_ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('supplierConsultationVisibleTabs', () => {
it('retourne [] tant que le fournisseur n\'est pas charge', () => {
expect(supplierConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles et les onglets vides (fournisseur minimal)', () => {
expect(supplierConsultationVisibleTabs(
{ '@id': '/api/suppliers/1', id: 1, companyName: 'ACME' },
{ canAccountingView: true },
)).toEqual([])
})
it('affiche information/contacts/addresses/accounting (cles plurielles) dans l\'ordre', () => {
const supplier: SupplierDetail = {
'@id': '/api/suppliers/1', id: 1,
volumeForecast: 1000,
contacts: [{ '@id': '/api/supplier_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/supplier_addresses/1', id: 1 }],
siren: '123456789',
}
expect(supplierConsultationVisibleTabs(supplier, { canAccountingView: true }))
.toEqual(['information', 'contacts', 'addresses', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view', () => {
expect(supplierConsultationVisibleTabs(
{ '@id': '/api/suppliers/1', id: 1, siren: '123456789' },
{ canAccountingView: false },
)).toEqual([])
})
})
@@ -0,0 +1,29 @@
/**
* Helpers de saisie des montants des formulaires Client / Fournisseur (commercial).
* Purs / testables. Pendant FRONT de la contrainte back `LessThanOrEqual` posee sur
* `revenueAmount` (Client/Supplier) — retour metier ERP-193 : le chiffre d'affaires
* est plafonne a 999 999 999 999,99.
*/
/** Plafond metier du chiffre d'affaires (CA) : 999 999 999 999,99. */
export const REVENUE_AMOUNT_MAX = 999_999_999_999.99
/** Valeur « modele » (decimale a point, sans separateur) renvoyee quand on plafonne. */
const REVENUE_AMOUNT_MAX_MODEL = '999999999999.99'
/**
* Plafonne le CA au maximum metier. Recoit le modele emis par `MalioInputAmount`
* (chaine propre a decimale `.`, sans espaces) ; tolere malgre tout une virgule /
* des espaces par securite. Renvoie la valeur telle quelle si elle est vide, non
* numerique ou sous le plafond ; sinon la valeur plafonnee.
*/
export function clampRevenueAmount(value: string | null | undefined): string | null | undefined {
if (value === null || value === undefined || value === '') {
return value
}
const n = Number(String(value).replace(/\s/g, '').replace(',', '.'))
if (Number.isNaN(n)) {
return value
}
return n > REVENUE_AMOUNT_MAX ? REVENUE_AMOUNT_MAX_MODEL : value
}
@@ -317,6 +317,77 @@ export function mapAddressView(address: AddressRead): AddressView {
} }
} }
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Information porte au moins une donnee. ERP-193 : en
* consultation on masque les onglets vides ; Information n'echappe pas a la
* regle malgre son statut d'onglet d'atterrissage par defaut.
*/
export function hasInformationData(client: ClientDetail): boolean {
return [
client.description,
client.competitors,
client.foundedAt,
client.employeesCount,
client.revenueAmount,
client.profitAmount,
client.directorName,
].some(hasValue)
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(client: ClientDetail): boolean {
const draft = mapAccountingDraft(client)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (client.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
* ET tout onglet de donnees vide. L'ordre reproduit `buildClientFormTabKeys`.
* Retourne `[]` tant que le client n'est pas charge.
*/
export function clientConsultationVisibleTabs(
client: ClientDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!client) {
return []
}
const visible: string[] = []
if (hasInformationData(client)) {
visible.push('information')
}
if ((client.contacts ?? []).length > 0) {
visible.push('contact')
}
if ((client.addresses ?? []).length > 0) {
visible.push('address')
}
if (options.canAccountingView && hasAccountingData(client)) {
visible.push('accounting')
}
return visible
}
/** /**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta * — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
@@ -292,6 +292,78 @@ export function mapAddressView(address: AddressRead): AddressView {
} }
} }
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Information porte au moins une donnee (volume previsionnel
* inclus, specifique fournisseur). ERP-193 : en consultation on masque les
* onglets vides, Information comprise.
*/
export function hasInformationData(supplier: SupplierDetail): boolean {
return [
supplier.description,
supplier.competitors,
supplier.foundedAt,
supplier.employeesCount,
supplier.revenueAmount,
supplier.profitAmount,
supplier.directorName,
supplier.volumeForecast,
].some(hasValue)
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(supplier: SupplierDetail): boolean {
const draft = mapAccountingDraft(supplier)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (supplier.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
* ET tout onglet de donnees vide. L'ordre reproduit `buildSupplierFormTabKeys`.
* Retourne `[]` tant que le fournisseur n'est pas charge.
*/
export function supplierConsultationVisibleTabs(
supplier: SupplierDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!supplier) {
return []
}
const visible: string[] = []
if (hasInformationData(supplier)) {
visible.push('information')
}
if ((supplier.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((supplier.addresses ?? []).length > 0) {
visible.push('addresses')
}
if (options.canAccountingView && hasAccountingData(supplier)) {
visible.push('accounting')
}
return visible
}
/** /**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta * — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
@@ -2,7 +2,7 @@
<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)]"> <div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation cote parent. --> <!-- Suppression : modal de confirmation cote parent. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly" v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -17,7 +17,8 @@
:label="t('technique.providers.form.address.sites')" :label="t('technique.providers.form.address.sites')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.sites" :error="errors?.sites"
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
/> />
@@ -29,7 +30,8 @@
:label="t('technique.providers.form.address.categories')" :label="t('technique.providers.form.address.categories')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.categories" :error="errors?.categories"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/> />
@@ -41,6 +43,7 @@
:label="t('technique.providers.form.address.contacts')" :label="t('technique.providers.form.address.contacts')"
:display-tag="true" :display-tag="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))" @update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/> />
@@ -49,7 +52,8 @@
:options="countryOptions" :options="countryOptions"
:label="t('technique.providers.form.address.country')" :label="t('technique.providers.form.address.country')"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
@@ -58,7 +62,8 @@
:label="t('technique.providers.form.address.postalCode')" :label="t('technique.providers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode" :error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
@@ -70,8 +75,9 @@
:options="cityOptions" :options="cityOptions"
:label="t('technique.providers.form.address.city')" :label="t('technique.providers.form.address.city')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="true" :required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
@@ -79,8 +85,10 @@
v-else v-else
:model-value="model.city" :model-value="model.city"
:label="t('technique.providers.form.address.city')" :label="t('technique.providers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
@@ -89,14 +97,15 @@
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). --> texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2"> <div class="col-span-2">
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly" v-if="!readonly && !disabled"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('technique.providers.form.address.street')" :label="t('technique.providers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
:allow-create="true" :allow-create="true"
:no-results-text="t('technique.providers.form.address.streetNotFound')" :no-results-text="t('technique.providers.form.address.streetNotFound')"
@@ -109,7 +118,8 @@
:model-value="model.street" :model-value="model.street"
:label="t('technique.providers.form.address.street')" :label="t('technique.providers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
@@ -119,7 +129,9 @@
<MalioInputText <MalioInputText
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('technique.providers.form.address.streetComplement')" :label="t('technique.providers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement" :error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
@@ -131,6 +143,7 @@
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials' import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm' import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
// Masque code postal FR : 5 chiffres. // Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####' const POSTAL_CODE_MASK = '#####'
@@ -148,6 +161,8 @@ const props = defineProps<{
countryOptions: RefOption[] countryOptions: RefOption[]
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -193,6 +208,11 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
// les champs texte editables (complement, ville en mode degrade). La voie en
// autocomplete (BAN) et la ville en select ne sont pas masquees (le back valide
// via Assert\Regex).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void { function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -3,7 +3,7 @@
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si <!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
non supprimable (1er bloc) ou en lecture seule. --> non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly" v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -14,14 +14,18 @@
<MalioInputText <MalioInputText
:model-value="model.lastName" :model-value="model.lastName"
:label="t('technique.providers.form.contact.lastName')" :label="t('technique.providers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.lastName" :error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
:model-value="model.firstName" :model-value="model.firstName"
:label="t('technique.providers.form.contact.firstName')" :label="t('technique.providers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
@@ -32,7 +36,9 @@
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('technique.providers.form.contact.jobTitle')" :label="t('technique.providers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle" :error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
@@ -41,6 +47,7 @@
:model-value="model.email" :model-value="model.email"
:label="t('technique.providers.form.contact.email')" :label="t('technique.providers.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.email" :error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
@@ -50,6 +57,7 @@
:label="t('technique.providers.form.contact.phonePrimary')" :label="t('technique.providers.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary" :error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('technique.providers.form.contact.addPhone')" :add-button-label="t('technique.providers.form.contact.addPhone')"
@@ -63,6 +71,7 @@
:label="t('technique.providers.form.contact.phoneSecondary')" :label="t('technique.providers.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary" :error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
@@ -71,6 +80,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm' import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur). // Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##' const PHONE_MASK = '## ## ## ## ##'
@@ -82,6 +92,8 @@ const props = defineProps<{
removable?: boolean removable?: 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). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -96,6 +108,10 @@ const { t } = useI18n()
// Alias local pour la lisibilite du template. // Alias local pour la lisibilite du template.
const model = computed(() => props.modelValue) const model = computed(() => props.modelValue)
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void { function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -44,7 +44,7 @@ describe('useProvidersRepository', () => {
expect(mockApiGet).toHaveBeenCalledTimes(1) expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0] const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/providers') expect(url).toBe('/providers')
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 }) expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
expect(opts).toMatchObject({ expect(opts).toMatchObject({
toast: false, toast: false,
headers: { Accept: 'application/ld+json' }, headers: { Accept: 'application/ld+json' },
@@ -84,6 +84,11 @@ export function useProviderForm() {
}) })
} }
/** Toast de succès après suppression serveur confirmée d'une sous-ressource. */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
// ── Etat du prestataire cree ──────────────────────────────────────────── // ── Etat du prestataire cree ────────────────────────────────────────────
const providerId = ref<number | null>(null) const providerId = ref<number | null>(null)
const mainLocked = ref(false) const mainLocked = ref(false)
@@ -339,6 +344,7 @@ export function useProviderForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderContact, makeEmpty: emptyProviderContact,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
@@ -417,6 +423,7 @@ export function useProviderForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderAddress, makeEmpty: emptyProviderAddress,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
@@ -518,6 +525,7 @@ export function useProviderForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyProviderRib, makeEmpty: emptyProviderRib,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
@@ -59,5 +59,6 @@ export interface Provider {
* `usePaginatedList`. Aucun reset au logout a gerer. * `usePaginatedList`. Aucun reset au logout a gerer.
*/ */
export function useProvidersRepository() { export function useProvidersRepository() {
return usePaginatedList<Provider>({ url: '/providers' }) // Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
return usePaginatedList<Provider>({ url: '/providers', defaultItemsPerPage: 25 })
} }
@@ -22,8 +22,9 @@
<MalioInputText <MalioInputText
v-model="main.companyName" v-model="main.companyName"
:label="t('technique.providers.form.main.companyName')" :label="t('technique.providers.form.main.companyName')"
:mask="FREE_TEXT_MASK"
:required="true" :required="true"
:readonly="businessReadonly" :disabled="businessReadonly"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
@@ -31,7 +32,7 @@
:options="referentials.categories.value" :options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')" :label="t('technique.providers.form.main.categories')"
:display-tag="true" :display-tag="true"
:readonly="businessReadonly" :disabled="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -41,7 +42,7 @@
:options="referentials.sites.value" :options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')" :label="t('technique.providers.form.main.sites')"
:display-tag="true" :display-tag="true"
:readonly="businessReadonly" :disabled="businessReadonly"
:required="true" :required="true"
:error="mainErrors.errors.sites" :error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
@@ -71,7 +72,7 @@
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly" :disabled="businessReadonly"
: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)"
@@ -107,7 +108,7 @@
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly" :disabled="businessReadonly"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -141,14 +142,15 @@
v-model="accounting.siren" v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')" :label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')" :label="t('technique.providers.form.accounting.accountNumber')"
:readonly="accountingReadonly" :mask="CODE_ALNUM_MASK"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
/> />
@@ -156,7 +158,7 @@
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')" :label="t('technique.providers.form.accounting.tvaMode')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -165,7 +167,8 @@
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')" :label="t('technique.providers.form.accounting.nTva')"
:readonly="accountingReadonly" :mask="CODE_ALNUM_MASK"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
/> />
@@ -173,7 +176,7 @@
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')" :label="t('technique.providers.form.accounting.paymentDelay')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -183,7 +186,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')" :label="t('technique.providers.form.accounting.paymentType')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -194,7 +197,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')" :label="t('technique.providers.form.accounting.bank')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -221,21 +224,23 @@
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')" :label="t('technique.providers.form.accounting.ribLabel')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')" :label="t('technique.providers.form.accounting.ribBic')"
:readonly="accountingReadonly" :mask="CODE_ALNUM_MASK"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')" :label="t('technique.providers.form.accounting.ribIban')"
:readonly="accountingReadonly" :mask="CODE_ALNUM_MASK"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
/> />
@@ -313,6 +318,7 @@ import {
} from '~/modules/technique/types/providerForm' } from '~/modules/technique/types/providerForm'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow' import { isRowRemovable } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur). // Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -22,7 +22,7 @@
/> />
<MalioButton <MalioButton
v-if="showArchive" v-if="showArchive"
variant="secondary" variant="danger"
icon-name="mdi:archive-arrow-down-outline" icon-name="mdi:archive-arrow-down-outline"
icon-position="left" icon-position="left"
:label="t('technique.providers.action.archive')" :label="t('technique.providers.action.archive')"
@@ -49,26 +49,27 @@
<MalioInputText <MalioInputText
:model-value="provider.companyName" :model-value="provider.companyName"
:label="t('technique.providers.form.main.companyName')" :label="t('technique.providers.form.main.companyName')"
readonly disabled
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="mainCategoryIris" :model-value="mainCategoryIris"
:options="mainCategoryOptions" :options="mainCategoryOptions"
:label="t('technique.providers.form.main.categories')" :label="t('technique.providers.form.main.categories')"
:display-tag="true" :display-tag="true"
readonly disabled
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
:model-value="mainSiteIris" :model-value="mainSiteIris"
:options="mainSiteOptions" :options="mainSiteOptions"
:label="t('technique.providers.form.main.sites')" :label="t('technique.providers.form.main.sites')"
:display-tag="true" :display-tag="true"
readonly disabled
/> />
</div> </div>
<!-- Onglets (navigation libre, tout en lecture seule) --> <!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]"> <!-- ERP-193 : barre masquee s'il ne reste aucun onglet non vide. -->
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Contacts --> <!-- Onglet Contacts -->
<template #contacts> <template #contacts>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
@@ -76,7 +77,7 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
readonly disabled
/> />
</div> </div>
</template> </template>
@@ -92,27 +93,25 @@
: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)"
readonly disabled
/> />
</div> </div>
</template> </template>
<!-- ERP-193 : les onglets « a venir » (Rapports / Echanges) ne sont
<!-- Onglets placeholder « A venir » (comme les autres modules). --> plus rendus en consultation (masquage des onglets vides). -->
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<!-- 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)]"> <div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" readonly /> <MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" disabled />
<MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" readonly /> <MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" disabled />
<MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" readonly empty-option-label="" /> <MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" />
<MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" readonly /> <MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" disabled />
<MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" readonly empty-option-label="" /> <MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" disabled empty-option-label="" />
<MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" readonly empty-option-label="" /> <MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" disabled empty-option-label="" />
<MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" readonly empty-option-label="" /> <MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" disabled empty-option-label="" />
</div> </div>
</div> </div>
@@ -123,9 +122,9 @@
class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]" class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]"
> >
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" readonly /> <MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" disabled />
<MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" readonly /> <MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" disabled />
<MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" readonly /> <MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled />
</div> </div>
</div> </div>
</div> </div>
@@ -158,7 +157,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useProvider } from '~/modules/technique/composables/useProvider' import { useProvider } from '~/modules/technique/composables/useProvider'
import { import {
canEditProvider, canEditProvider,
@@ -170,6 +169,7 @@ import {
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf, paymentTypeCodeOf,
providerConsultationVisibleTabs,
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
@@ -197,7 +197,6 @@ const headerTitle = computed(() => provider.value?.companyName || t('technique.p
useHead({ title: t('technique.providers.consultation.title') }) useHead({ title: t('technique.providers.consultation.title') })
// ── Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité) ── // ── Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité) ──
const activeTab = ref('contacts')
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
contacts: 'mdi:account-box-plus-outline', contacts: 'mdi:account-box-plus-outline',
address: 'mdi:map-marker-outline', address: 'mdi:map-marker-outline',
@@ -205,11 +204,27 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:swap-horizontal', exchanges: 'mdi:swap-horizontal',
accounting: 'mdi:bank-circle-outline', accounting: 'mdi:bank-circle-outline',
} }
const tabs = computed(() => { // ERP-193 (retour metier) : on masque les coquilles (Rapports / Echanges) ET
const keys = ['contacts', 'address', 'reports', 'exchanges'] // tout onglet de donnees vide. La liste depend donc du payload charge.
if (canAccountingView.value) keys.push('accounting') const visibleTabKeys = computed(() => providerConsultationVisibleTabs(provider.value, {
return keys.map(key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] })) canAccountingView: canAccountingView.value,
}) }))
const tabs = computed(() => visibleTabKeys.value.map(
key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }),
))
// Onglet initial : vide tant que le prestataire n'est pas charge, puis premier
// onglet visible. Un watcher recale si l'onglet courant disparait.
const activeTab = ref('')
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = keys[0]
}
}, { immediate: true })
// ── Donnees mappees depuis la SEULE reponse detail ───────────────────────────── // ── Donnees mappees depuis la SEULE reponse detail ─────────────────────────────
const mainCategoryIris = computed(() => irisOf(provider.value?.categories)) const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
@@ -44,7 +44,7 @@
@update:page="goToPage" @update:page="goToPage"
@update:per-page="setItemsPerPage" @update:per-page="setItemsPerPage"
> >
<!-- Categories : libelles (name) separes par une virgule. --> <!-- Categories : codes stables (MAJUSCULE) separes par une virgule, aligne sur le client (ERP-193). -->
<template #cell-categories="{ item }"> <template #cell-categories="{ item }">
{{ formatCategories(item) }} {{ formatCategories(item) }}
</template> </template>
@@ -210,10 +210,10 @@ const columns = [
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') }, { key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
] ]
/** Libelles des categories du prestataire, separes par une virgule (name). */ /** Codes (MAJUSCULE) des categories du prestataire, separes par une virgule (aligne sur le client, ERP-193). */
function formatCategories(item: Record<string, unknown>): string { function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Provider['categories']) ?? [] const categories = (item.categories as Provider['categories']) ?? []
return categories.map(c => c.name).join(', ') return categories.map(c => c.code).join(', ')
} }
/** /**
@@ -21,8 +21,9 @@
<MalioInputText <MalioInputText
v-model="main.companyName" v-model="main.companyName"
:label="t('technique.providers.form.main.companyName')" :label="t('technique.providers.form.main.companyName')"
:mask="FREE_TEXT_MASK"
:required="true" :required="true"
:readonly="mainLocked" :disabled="mainLocked"
:error="mainErrors.errors.companyName" :error="mainErrors.errors.companyName"
/> />
<MalioSelectCheckbox <MalioSelectCheckbox
@@ -30,7 +31,7 @@
:options="referentials.categories.value" :options="referentials.categories.value"
:label="t('technique.providers.form.main.categories')" :label="t('technique.providers.form.main.categories')"
:display-tag="true" :display-tag="true"
:readonly="mainLocked" :disabled="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.categories" :error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -40,7 +41,7 @@
:options="referentials.sites.value" :options="referentials.sites.value"
:label="t('technique.providers.form.main.sites')" :label="t('technique.providers.form.main.sites')"
:display-tag="true" :display-tag="true"
:readonly="mainLocked" :disabled="mainLocked"
:required="true" :required="true"
:error="mainErrors.errors.sites" :error="mainErrors.errors.sites"
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)" @update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
@@ -72,12 +73,16 @@
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:readonly="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"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
/> />
<div v-if="!isValidated('contact')" class="flex justify-center gap-6"> <!-- Masque tant que le prestataire n'est pas cree : Contact etant
l'onglet actif par defaut, ses actions (Ajouter / Valider) ne
doivent pas apparaitre a cote du Valider du formulaire principal
(ERP-193). -->
<div v-if="!isValidated('contact') && providerId !== null" class="flex justify-center gap-6">
<MalioButton <MalioButton
variant="secondary" variant="secondary"
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
@@ -89,7 +94,7 @@
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('technique.providers.form.submit')" :label="t('technique.providers.form.submit')"
:disabled="tabSubmitting || providerId === null" :disabled="tabSubmitting"
@click="onSubmitContacts" @click="onSubmitContacts"
/> />
</div> </div>
@@ -107,7 +112,7 @@
:contact-options="contactOptions" :contact-options="contactOptions"
:country-options="countryOptions" :country-options="countryOptions"
:removable="isRowRemovable(addresses, index)" :removable="isRowRemovable(addresses, index)"
:readonly="isValidated('address')" :disabled="isValidated('address')"
:errors="addressErrors[index]" :errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v" @update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)" @remove="askRemoveAddress(index)"
@@ -140,14 +145,15 @@
v-model="accounting.siren" v-model="accounting.siren"
:label="t('technique.providers.form.accounting.siren')" :label="t('technique.providers.form.accounting.siren')"
:mask="SIREN_MASK" :mask="SIREN_MASK"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.siren" :error="accountingErrors.errors.siren"
/> />
<MalioInputText <MalioInputText
v-model="accounting.accountNumber" v-model="accounting.accountNumber"
:label="t('technique.providers.form.accounting.accountNumber')" :label="t('technique.providers.form.accounting.accountNumber')"
:readonly="accountingReadonly" :mask="CODE_ALNUM_MASK"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.accountNumber" :error="accountingErrors.errors.accountNumber"
/> />
@@ -155,7 +161,7 @@
:model-value="accounting.tvaModeIri" :model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value" :options="referentials.tvaModes.value"
:label="t('technique.providers.form.accounting.tvaMode')" :label="t('technique.providers.form.accounting.tvaMode')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.tvaMode" :error="accountingErrors.errors.tvaMode"
@@ -164,7 +170,8 @@
<MalioInputText <MalioInputText
v-model="accounting.nTva" v-model="accounting.nTva"
:label="t('technique.providers.form.accounting.nTva')" :label="t('technique.providers.form.accounting.nTva')"
:readonly="accountingReadonly" :mask="CODE_ALNUM_MASK"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="accountingErrors.errors.nTva" :error="accountingErrors.errors.nTva"
/> />
@@ -172,7 +179,7 @@
:model-value="accounting.paymentDelayIri" :model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value" :options="referentials.paymentDelays.value"
:label="t('technique.providers.form.accounting.paymentDelay')" :label="t('technique.providers.form.accounting.paymentDelay')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentDelay" :error="accountingErrors.errors.paymentDelay"
@@ -182,7 +189,7 @@
:model-value="accounting.paymentTypeIri" :model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value" :options="referentials.paymentTypes.value"
:label="t('technique.providers.form.accounting.paymentType')" :label="t('technique.providers.form.accounting.paymentType')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.paymentType" :error="accountingErrors.errors.paymentType"
@@ -194,7 +201,7 @@
:model-value="accounting.bankIri" :model-value="accounting.bankIri"
:options="referentials.banks.value" :options="referentials.banks.value"
:label="t('technique.providers.form.accounting.bank')" :label="t('technique.providers.form.accounting.bank')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:error="accountingErrors.errors.bank" :error="accountingErrors.errors.bank"
@@ -221,21 +228,23 @@
<MalioInputText <MalioInputText
v-model="rib.label" v-model="rib.label"
:label="t('technique.providers.form.accounting.ribLabel')" :label="t('technique.providers.form.accounting.ribLabel')"
:readonly="accountingReadonly" :disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.label" :error="ribErrors[index]?.label"
/> />
<MalioInputText <MalioInputText
v-model="rib.bic" v-model="rib.bic"
:label="t('technique.providers.form.accounting.ribBic')" :label="t('technique.providers.form.accounting.ribBic')"
:readonly="accountingReadonly" :mask="CODE_ALNUM_MASK"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.bic" :error="ribErrors[index]?.bic"
/> />
<MalioInputText <MalioInputText
v-model="rib.iban" v-model="rib.iban"
:label="t('technique.providers.form.accounting.ribIban')" :label="t('technique.providers.form.accounting.ribIban')"
:readonly="accountingReadonly" :mask="CODE_ALNUM_MASK"
:disabled="accountingReadonly"
:required="true" :required="true"
:error="ribErrors[index]?.iban" :error="ribErrors[index]?.iban"
/> />
@@ -297,6 +306,7 @@ import {
} from '~/modules/technique/utils/forms/providerAccounting' } from '~/modules/technique/utils/forms/providerAccounting'
import { extractApiErrorMessage } from '~/shared/utils/api' import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow' import { isRowRemovable } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
// Masque SIREN : 9 chiffres (la normalisation finale reste serveur). // Masque SIREN : 9 chiffres (la normalisation finale reste serveur).
const SIREN_MASK = '#########' const SIREN_MASK = '#########'
@@ -10,6 +10,7 @@ const {
canEditProvider, canEditProvider,
categoryOptionsOf, categoryOptionsOf,
contactOptionsOf, contactOptionsOf,
hasAccountingData,
iriOf, iriOf,
irisOf, irisOf,
mapAccountingDraft, mapAccountingDraft,
@@ -17,6 +18,7 @@ const {
mapContactToDraft, mapContactToDraft,
mapRibToDraft, mapRibToDraft,
paymentTypeCodeOf, paymentTypeCodeOf,
providerConsultationVisibleTabs,
referentialOptionOf, referentialOptionOf,
showArchiveAction, showArchiveAction,
showRestoreAction, showRestoreAction,
@@ -165,3 +167,48 @@ describe('providerDetail helpers', () => {
}) })
}) })
}) })
describe('hasAccountingData (prestataire)', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/providers/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable ou un RIB', () => {
expect(hasAccountingData({ '@id': '/api/providers/1', id: 1, siren: '123456789' })).toBe(true)
expect(hasAccountingData({
'@id': '/api/providers/1', id: 1,
ribs: [{ '@id': '/api/provider_ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('providerConsultationVisibleTabs', () => {
it('retourne [] tant que le prestataire n\'est pas charge', () => {
expect(providerConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles (reports/exchanges) et les onglets vides', () => {
expect(providerConsultationVisibleTabs(
{ '@id': '/api/providers/1', id: 1, companyName: 'ACME' },
{ canAccountingView: true },
)).toEqual([])
})
it('affiche contacts/address/accounting dans l\'ordre (pas d\'onglet information)', () => {
const provider = {
'@id': '/api/providers/1', id: 1,
contacts: [{ '@id': '/api/provider_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/provider_addresses/1', id: 1 }],
siren: '123456789',
}
expect(providerConsultationVisibleTabs(provider, { canAccountingView: true }))
.toEqual(['contacts', 'address', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view', () => {
expect(providerConsultationVisibleTabs(
{ '@id': '/api/providers/1', id: 1, siren: '123456789' },
{ canAccountingView: false },
)).toEqual([])
})
})
@@ -224,6 +224,58 @@ export function paymentTypeCodeOf(relation: Relation): string | null {
return (relation.code as string | undefined) ?? null return (relation.code as string | undefined) ?? null
} }
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert au predicat « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(provider: ProviderDetail): boolean {
const draft = mapAccountingDraft(provider)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (provider.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Rapports / Echanges) ET tout onglet de donnees
* vide. Le prestataire n'a pas d'onglet Information (bloc principal au-dessus
* des onglets). Ordre : Contacts · Adresse · Comptabilite. Retourne `[]` tant
* que le prestataire n'est pas charge.
*/
export function providerConsultationVisibleTabs(
provider: ProviderDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!provider) {
return []
}
const visible: string[] = []
if ((provider.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((provider.addresses ?? []).length > 0) {
visible.push('address')
}
if (options.canAccountingView && hasAccountingData(provider)) {
visible.push('accounting')
}
return visible
}
/** /**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet — * Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet —
* `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir * `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir
@@ -7,7 +7,8 @@
:options="countryOptions" :options="countryOptions"
:label="t('transport.carriers.form.address.country')" :label="t('transport.carriers.form.address.country')"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.country" :error="errors?.country"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))" @update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/> />
@@ -18,7 +19,8 @@
:label="t('transport.carriers.form.address.postalCode')" :label="t('transport.carriers.form.address.postalCode')"
:mask="POSTAL_CODE_MASK" :mask="POSTAL_CODE_MASK"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.postalCode" :error="errors?.postalCode"
@update:model-value="onPostalCodeChange" @update:model-value="onPostalCodeChange"
/> />
@@ -30,8 +32,9 @@
:options="cityOptions" :options="cityOptions"
:label="t('transport.carriers.form.address.city')" :label="t('transport.carriers.form.address.city')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
empty-option-label="" empty-option-label=""
:required="true" :required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/> />
@@ -39,8 +42,10 @@
v-else v-else
:model-value="model.city" :model-value="model.city"
:label="t('transport.carriers.form.address.city')" :label="t('transport.carriers.form.address.city')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.city" :error="errors?.city"
@update:model-value="(v: string) => update('city', v)" @update:model-value="(v: string) => update('city', v)"
/> />
@@ -52,14 +57,15 @@
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). --> texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
<div class="col-span-2"> <div class="col-span-2">
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly" v-if="!readonly && !disabled"
:model-value="model.street" :model-value="model.street"
:options="addressOptions" :options="addressOptions"
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:label="t('transport.carriers.form.address.street')" :label="t('transport.carriers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
:allow-create="true" :allow-create="true"
:no-results-text="t('transport.carriers.form.address.streetNotFound')" :no-results-text="t('transport.carriers.form.address.streetNotFound')"
@@ -72,7 +78,8 @@
:model-value="model.street" :model-value="model.street"
:label="t('transport.carriers.form.address.street')" :label="t('transport.carriers.form.address.street')"
:readonly="readonly" :readonly="readonly"
:required="true" :disabled="disabled"
:required="!readonly && !disabled"
:error="errors?.street" :error="errors?.street"
@update:model-value="(v: string) => update('street', v)" @update:model-value="(v: string) => update('street', v)"
/> />
@@ -81,7 +88,9 @@
<MalioInputText <MalioInputText
:model-value="model.streetComplement" :model-value="model.streetComplement"
:label="t('transport.carriers.form.address.streetComplement')" :label="t('transport.carriers.form.address.streetComplement')"
:mask="ADDRESS_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.streetComplement" :error="errors?.streetComplement"
@update:model-value="(v: string) => update('streetComplement', v)" @update:model-value="(v: string) => update('streetComplement', v)"
/> />
@@ -91,6 +100,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete' import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm' import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
interface RefOption { interface RefOption {
value: string value: string
@@ -106,6 +116,8 @@ const props = defineProps<{
/** Pays disponibles (France par defaut). */ /** Pays disponibles (France par defaut). */
countryOptions: RefOption[] countryOptions: RefOption[]
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -150,6 +162,10 @@ const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = [] let lastAddressSuggestions: AddressSuggestion[] = []
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur les
// champs texte editables (complement, ville en mode degrade). La voie en autocomplete
// (BAN) et la ville en select ne sont pas masquees (le back valide via Assert\Regex).
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */ /** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void { function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -3,7 +3,7 @@
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si <!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
non supprimable (1er bloc) ou en lecture seule. --> non supprimable (1er bloc) ou en lecture seule. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly" v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -14,14 +14,18 @@
<MalioInputText <MalioInputText
:model-value="model.lastName" :model-value="model.lastName"
:label="t('transport.carriers.form.contact.lastName')" :label="t('transport.carriers.form.contact.lastName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.lastName" :error="errors?.lastName"
@update:model-value="(v: string) => update('lastName', v)" @update:model-value="(v: string) => update('lastName', v)"
/> />
<MalioInputText <MalioInputText
:model-value="model.firstName" :model-value="model.firstName"
:label="t('transport.carriers.form.contact.firstName')" :label="t('transport.carriers.form.contact.firstName')"
:mask="PERSON_NAME_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.firstName" :error="errors?.firstName"
@update:model-value="(v: string) => update('firstName', v)" @update:model-value="(v: string) => update('firstName', v)"
/> />
@@ -31,7 +35,9 @@
<MalioInputText <MalioInputText
:model-value="model.jobTitle" :model-value="model.jobTitle"
:label="t('transport.carriers.form.contact.jobTitle')" :label="t('transport.carriers.form.contact.jobTitle')"
:mask="FREE_TEXT_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.jobTitle" :error="errors?.jobTitle"
@update:model-value="(v: string) => update('jobTitle', v)" @update:model-value="(v: string) => update('jobTitle', v)"
/> />
@@ -40,6 +46,7 @@
:model-value="model.email" :model-value="model.email"
:label="t('transport.carriers.form.contact.email')" :label="t('transport.carriers.form.contact.email')"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:lowercase="true" :lowercase="true"
:error="errors?.email" :error="errors?.email"
@update:model-value="(v: string) => update('email', v)" @update:model-value="(v: string) => update('email', v)"
@@ -50,6 +57,7 @@
:label="t('transport.carriers.form.contact.phonePrimary')" :label="t('transport.carriers.form.contact.phonePrimary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phonePrimary" :error="errors?.phonePrimary"
:addable="!model.hasSecondaryPhone && !readonly" :addable="!model.hasSecondaryPhone && !readonly"
:add-button-label="t('transport.carriers.form.contact.addPhone')" :add-button-label="t('transport.carriers.form.contact.addPhone')"
@@ -63,6 +71,7 @@
:label="t('transport.carriers.form.contact.phoneSecondary')" :label="t('transport.carriers.form.contact.phoneSecondary')"
:mask="PHONE_MASK" :mask="PHONE_MASK"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.phoneSecondary" :error="errors?.phoneSecondary"
@update:model-value="(v: string) => update('phoneSecondary', v)" @update:model-value="(v: string) => update('phoneSecondary', v)"
/> />
@@ -71,6 +80,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm' import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
// Masque téléphone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur). // Masque téléphone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
const PHONE_MASK = '## ## ## ## ##' const PHONE_MASK = '## ## ## ## ##'
@@ -82,6 +92,8 @@ const props = defineProps<{
removable?: boolean removable?: 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). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -96,6 +108,10 @@ const { t } = useI18n()
// Alias local pour la lisibilité du template. // Alias local pour la lisibilité du template.
const model = computed(() => props.modelValue) const model = computed(() => props.modelValue)
// Filtrage des caractères parasites : porté par les masks maska sur les champs
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
/** Émet un nouveau brouillon avec le champ modifié (immutabilité). */ /** Émet un nouveau brouillon avec le champ modifié (immutabilité). */
function update<K extends keyof CarrierContactFormDraft>(field: K, value: CarrierContactFormDraft[K]): void { function update<K extends keyof CarrierContactFormDraft>(field: K, value: CarrierContactFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value }) emit('update:modelValue', { ...props.modelValue, [field]: value })
@@ -2,7 +2,7 @@
<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)]"> <div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- Suppression : modal de confirmation côté parent. --> <!-- Suppression : modal de confirmation côté parent. -->
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly" v-if="removable && !readonly && !disabled"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
button-class="absolute top-3 right-3" button-class="absolute top-3 right-3"
@@ -20,7 +20,7 @@
:name="`price-direction-${uid}`" :name="`price-direction-${uid}`"
value="CLIENT" value="CLIENT"
:label="t('transport.carriers.form.price.directionClient')" :label="t('transport.carriers.form.price.directionClient')"
:disabled="readonly" :disabled="readonly || disabled"
group-class="mt-0" group-class="mt-0"
@update:model-value="onDirectionChange" @update:model-value="onDirectionChange"
/> />
@@ -29,7 +29,7 @@
:name="`price-direction-${uid}`" :name="`price-direction-${uid}`"
value="FOURNISSEUR" value="FOURNISSEUR"
:label="t('transport.carriers.form.price.directionSupplier')" :label="t('transport.carriers.form.price.directionSupplier')"
:disabled="readonly" :disabled="readonly || disabled"
group-class="mt-0" group-class="mt-0"
@update:model-value="onDirectionChange" @update:model-value="onDirectionChange"
/> />
@@ -46,6 +46,7 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.client" :error="errors?.client"
@update:model-value="onClientChange" @update:model-value="onClientChange"
/> />
@@ -56,6 +57,7 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.clientDeliveryAddress" :error="errors?.clientDeliveryAddress"
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
/> />
@@ -66,6 +68,7 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.departureSite" :error="errors?.departureSite"
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
/> />
@@ -80,6 +83,7 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.supplier" :error="errors?.supplier"
@update:model-value="onSupplierChange" @update:model-value="onSupplierChange"
/> />
@@ -90,6 +94,7 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.supplierSupplyAddress" :error="errors?.supplierSupplyAddress"
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
/> />
@@ -100,6 +105,7 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.deliverySite" :error="errors?.deliverySite"
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
/> />
@@ -115,7 +121,7 @@
:name="`price-container-${uid}`" :name="`price-container-${uid}`"
value="BENNE" value="BENNE"
:label="t('transport.carriers.containerType.BENNE')" :label="t('transport.carriers.containerType.BENNE')"
:disabled="readonly" :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="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
/> />
@@ -124,7 +130,7 @@
:name="`price-container-${uid}`" :name="`price-container-${uid}`"
value="FOND_MOUVANT" value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')" :label="t('transport.carriers.containerType.FOND_MOUVANT')"
:disabled="readonly" :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="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
/> />
@@ -140,7 +146,7 @@
:name="`price-unit-${uid}`" :name="`price-unit-${uid}`"
value="FORFAIT" value="FORFAIT"
:label="t('transport.carriers.form.price.pricingForfait')" :label="t('transport.carriers.form.price.pricingForfait')"
:disabled="readonly" :disabled="readonly || disabled"
group-class="mt-0" group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))" @update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
/> />
@@ -149,7 +155,7 @@
:name="`price-unit-${uid}`" :name="`price-unit-${uid}`"
value="TONNE" value="TONNE"
:label="t('transport.carriers.form.price.pricingTonne')" :label="t('transport.carriers.form.price.pricingTonne')"
:disabled="readonly" :disabled="readonly || disabled"
group-class="mt-0" group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))" @update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
/> />
@@ -162,6 +168,7 @@
:label="t('transport.carriers.form.price.price')" :label="t('transport.carriers.form.price.price')"
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.price" :error="errors?.price"
@update:model-value="(v: string) => update('price', v)" @update:model-value="(v: string) => update('price', v)"
/> />
@@ -173,6 +180,7 @@
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="readonly" :readonly="readonly"
:disabled="disabled"
:error="errors?.priceState" :error="errors?.priceState"
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))" @update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
/> />
@@ -200,6 +208,8 @@ const props = defineProps<{
siteOptions: SelectOption[] siteOptions: SelectOption[]
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
disabled?: boolean
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */ /** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
errors?: Record<string, string> errors?: Record<string, string>
}>() }>()
@@ -37,7 +37,7 @@ vi.stubGlobal('useToast', () => ({
})) }))
const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm') const { useCarrierForm, CARRIER_TAB_KEYS } = await import('../useCarrierForm')
const { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } = await import('../../utils/forms/carrierContact') const { buildCarrierContactPayload, isCarrierContactBlank } = await import('../../utils/forms/carrierContact')
const { buildCarrierPricePayload, isCarrierPriceValid } = await import('../../utils/forms/carrierPrice') const { buildCarrierPricePayload, isCarrierPriceValid } = await import('../../utils/forms/carrierPrice')
const { emptyCarrierContact, emptyCarrierPrice } = await import('../../types/carrierForm') const { emptyCarrierContact, emptyCarrierPrice } = await import('../../types/carrierForm')
@@ -545,6 +545,9 @@ describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
expect(form.address.value.id).toBe(88) expect(form.address.value.id).toBe(88)
expect(form.isValidated('addresses')).toBe(true) expect(form.isValidated('addresses')).toBe(true)
// ERP-193 : Contact optionnel → valider Adresses déverrouille jusqu'à Prix
// (dernier onglet), sans étape bloquante par Contacts.
expect(form.unlockedIndex.value).toBe(CARRIER_TAB_KEYS.length - 1)
}) })
it('submitAddress : PATCH de l\'adresse existante sur /carrier_addresses/{id}', async () => { it('submitAddress : PATCH de l\'adresse existante sur /carrier_addresses/{id}', async () => {
@@ -577,7 +580,7 @@ describe('useCarrierForm — onglet Adresse (ERP-167 / ERP-172 : adresse unique)
}) })
}) })
describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléphones', () => { describe('carrierContact (util) — bloc optionnel (ERP-193) + max 2 téléphones', () => {
it('isCarrierContactBlank : vrai si vide, faux dès un champ comptant rempli (phoneSecondary exclu)', () => { it('isCarrierContactBlank : vrai si vide, faux dès un champ comptant rempli (phoneSecondary exclu)', () => {
expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true) expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true)
expect(isCarrierContactBlank({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false) expect(isCarrierContactBlank({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
@@ -586,15 +589,6 @@ describe('carrierContact (util) — validité alignée M1/M2/M3 + max 2 téléph
expect(isCarrierContactBlank({ ...emptyCarrierContact(), phoneSecondary: '0605040302', hasSecondaryPhone: true })).toBe(true) expect(isCarrierContactBlank({ ...emptyCarrierContact(), phoneSecondary: '0605040302', hasSecondaryPhone: true })).toBe(true)
}) })
it('isCarrierContactNamed : nommé seulement avec un prénom OU un nom', () => {
expect(isCarrierContactNamed(emptyCarrierContact())).toBe(false)
expect(isCarrierContactNamed({ ...emptyCarrierContact(), firstName: 'Jean' })).toBe(true)
expect(isCarrierContactNamed({ ...emptyCarrierContact(), lastName: 'Doe' })).toBe(true)
// Fonction / téléphone / email seuls ne « nomment » pas (≠ RG-4.08 large).
expect(isCarrierContactNamed({ ...emptyCarrierContact(), jobTitle: 'Acheteur' })).toBe(false)
expect(isCarrierContactNamed({ ...emptyCarrierContact(), email: 'a@b.fr' })).toBe(false)
})
it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => { it('buildCarrierContactPayload : phones = 1 numéro sans secondaire', () => {
const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' }) const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' })
expect(body.phones).toEqual(['0102030405']) expect(body.phones).toEqual(['0102030405'])
@@ -635,23 +629,18 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
return form return form
} }
it('« + Nouveau contact » désactivé tant que le bloc n\'est pas nommé (prénom OU nom, aligné M1/M2/M3)', () => { it('ERP-193 : « + Nouveau contact » désactivé tant que le bloc est VIDE (plus de règle prénom/nom)', () => {
const form = createdForm() const form = createdForm()
expect(form.canAddContact.value).toBe(false) expect(form.canAddContact.value).toBe(false)
// addContact est un no-op tant que le bloc n'est pas nommé. // addContact est un no-op tant que le bloc est totalement vide.
form.addContact() form.addContact()
expect(form.contacts.value).toHaveLength(1) expect(form.contacts.value).toHaveLength(1)
// Fonction seule ne suffit PAS à ajouter un nouveau bloc (≠ RG-4.08 large). // ERP-193 : un seul champ rempli (ici la fonction, sans prénom ni nom) suffit
// désormais à débloquer l'ajout — la règle « prénom OU nom » est retirée.
const first = form.contacts.value[0] const first = form.contacts.value[0]
if (first) first.jobTitle = 'Acheteur' if (first) first.jobTitle = 'Acheteur'
expect(form.canAddContact.value).toBe(false)
form.addContact()
expect(form.contacts.value).toHaveLength(1)
// Un nom (ou prénom) débloque l'ajout.
if (first) first.lastName = 'Doe'
expect(form.canAddContact.value).toBe(true) expect(form.canAddContact.value).toBe(true)
form.addContact() form.addContact()
expect(form.contacts.value).toHaveLength(2) expect(form.contacts.value).toHaveLength(2)
@@ -686,21 +675,15 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
expect(mockPatch).toHaveBeenCalledWith('/carrier_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false }) expect(mockPatch).toHaveBeenCalledWith('/carrier_contacts/55', expect.objectContaining({ lastName: 'Doe' }), { toast: false })
}) })
it('RG-4.08 : onglet vide → soumet l\'amorce pour déclencher la 422 inline', async () => { it('ERP-193 : onglet Contact vide → aucun POST, onglet finalisé (bloc optionnel)', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
},
})
const form = createdForm() const form = createdForm()
// Bloc vide → rien n'est soumis, l'onglet se finalise et déverrouille Prix.
const ok = await form.submitContacts(vi.fn()) const ok = await form.submitContacts(vi.fn())
expect(ok).toBe(false) expect(ok).toBe(true)
expect(mockPost).toHaveBeenCalledTimes(1) expect(mockPost).not.toHaveBeenCalled()
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.') expect(form.isValidated('contacts')).toBe(true)
expect(form.isValidated('contacts')).toBe(false)
}) })
it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => { it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => {
@@ -50,7 +50,7 @@ describe('useCarriersRepository', () => {
expect(mockApiGet).toHaveBeenCalledTimes(1) expect(mockApiGet).toHaveBeenCalledTimes(1)
const [url, query, opts] = mockApiGet.mock.calls[0] const [url, query, opts] = mockApiGet.mock.calls[0]
expect(url).toBe('/carriers') expect(url).toBe('/carriers')
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 }) expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
expect(opts).toMatchObject({ expect(opts).toMatchObject({
toast: false, toast: false,
headers: { Accept: 'application/ld+json' }, headers: { Accept: 'application/ld+json' },
@@ -17,7 +17,7 @@ import {
type CarrierPriceFormDraft, type CarrierPriceFormDraft,
} from '~/modules/transport/types/carrierForm' } from '~/modules/transport/types/carrierForm'
import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress' import { buildCarrierAddressPayload } from '~/modules/transport/utils/forms/carrierAddress'
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact' import { buildCarrierContactPayload, isCarrierContactBlank } from '~/modules/transport/utils/forms/carrierContact'
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice' import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
import { import {
mapAddressToDraft, mapAddressToDraft,
@@ -416,6 +416,11 @@ export function useCarrierForm() {
}) })
} }
/** Toast de succès après suppression serveur confirmée d'une sous-ressource. */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
/** /**
* Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX : * Soumet TOUS les blocs d'une collection en collectant les erreurs PAR INDEX :
* on n'arrête pas au premier bloc en échec (décision ERP-101). Réinitialise la * on n'arrête pas au premier bloc en échec (décision ERP-101). Réinitialise la
@@ -488,6 +493,10 @@ export function useCarrierForm() {
await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false }) await api.patch(`/carrier_addresses/${address.value.id}`, body, { toast: false })
} }
completeTab('addresses') completeTab('addresses')
// ERP-193 : l'onglet Contact est OPTIONNEL — il ne doit pas verrouiller
// l'accès à Prix. Dès les Adresses validées, on déverrouille jusqu'à Prix
// (Contacts reste accessible mais n'est plus une étape bloquante).
unlockedIndex.value = tabKeys.value.length - 1
return true return true
} }
catch (error) { catch (error) {
@@ -511,12 +520,13 @@ export function useCarrierForm() {
// Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows. // Erreurs 422 par ligne (alignées sur l'index du v-for), peuplées par submitRows.
const contactErrors = ref<Record<string, string>[]>([]) const contactErrors = ref<Record<string, string>[]>([])
// « + Nouveau contact » désactivé tant que le DERNIER bloc n'est pas « nommé » // « + Nouveau contact » désactivé tant que le DERNIER bloc est VIDE. ERP-193 :
// (prénom OU nom) — aligné sur M1/M2/M3 (fonction / téléphone / email seuls ne // l'onglet Contact n'est plus obligatoire — on ne réclame plus prénom OU nom,
// suffisent pas à ajouter un nouveau bloc). // un seul champ rempli (fonction / téléphone / email) suffit pour empiler un
// bloc suivant (et évite d'accumuler des blocs totalement vides).
const canAddContact = computed(() => { const canAddContact = computed(() => {
const last = contacts.value[contacts.value.length - 1] const last = contacts.value[contacts.value.length - 1]
return last !== undefined && isCarrierContactNamed(last) return last !== undefined && !isCarrierContactBlank(last)
}) })
function addContact(): void { function addContact(): void {
@@ -535,16 +545,18 @@ export function useCarrierForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyCarrierContact, makeEmpty: emptyCarrierContact,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
/** /**
* Valide l'onglet Contacts : POST des nouveaux contacts sur * Valide l'onglet Contacts : POST des nouveaux contacts sur
* /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id} * /carriers/{id}/contacts, PATCH des existants sur /carrier_contacts/{id}
* (groupe carrier:write:contacts). RG-4.08 (≥ 1 champ rempli, max 2 téléphones) * (groupe carrier:write:contacts). Max 2 téléphones re-validé back → 422 par
* re-validée back → 422 par ligne. Si l'onglet ne contient QUE des amorces * ligne. ERP-193 : l'onglet Contact est OPTIONNEL — les amorces vides neuves
* vides, on soumet la 1re pour déclencher la 422 RG-4.08 inline plutôt que de * sont systématiquement ignorées (pas de contact vide créé) et un onglet sans
* finaliser un onglet vide. Retourne true si l'onglet a été validé. * aucun bloc rempli est simplement finalisé, déverrouillant l'onglet Prix.
* Retourne true si l'onglet a été validé.
*/ */
async function submitContacts(onError: (error: unknown) => void): Promise<boolean> { async function submitContacts(onError: (error: unknown) => void): Promise<boolean> {
if (carrierId.value === null || tabSubmitting.value) { if (carrierId.value === null || tabSubmitting.value) {
@@ -552,7 +564,6 @@ export function useCarrierForm() {
} }
tabSubmitting.value = true tabSubmitting.value = true
try { try {
const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c))
const hasError = await submitRows( const hasError = await submitRows(
contacts.value, contacts.value,
contactErrors, contactErrors,
@@ -571,9 +582,9 @@ export function useCarrierForm() {
} }
}, },
onError, onError,
// Amorce vide neuve ignorée s'il reste un autre bloc soumettable ; // Amorce vide neuve toujours ignorée (bloc Contact optionnel, ERP-193) :
// sinon on la soumet pour déclencher la 422 RG-4.08 (sur firstName). // un onglet sans aucun bloc rempli se finalise sans rien créer.
contact => hasSubmittable && contact.id === null && isCarrierContactBlank(contact), contact => contact.id === null && isCarrierContactBlank(contact),
) )
if (hasError) { if (hasError) {
return false return false
@@ -648,6 +659,7 @@ export function useCarrierForm() {
deleteRow: url => api.delete(url, {}, { toast: false }), deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyCarrierPrice, makeEmpty: emptyCarrierPrice,
onError: notifyRemovalError, onError: notifyRemovalError,
onSuccess: notifyRemovalSuccess,
}) })
} }
@@ -66,5 +66,6 @@ export interface CarrierFilters {
* `usePaginatedList`. Aucun reset au logout a gerer. * `usePaginatedList`. Aucun reset au logout a gerer.
*/ */
export function useCarriersRepository() { export function useCarriersRepository() {
return usePaginatedList<Carrier, CarrierFilters>({ url: '/carriers' }) // Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
return usePaginatedList<Carrier, CarrierFilters>({ url: '/carriers', defaultItemsPerPage: 25 })
} }
@@ -20,18 +20,24 @@
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="main.name" v-model="main.name"
:mask="FREE_TEXT_MASK"
:label="t('transport.carriers.form.main.name')" :label="t('transport.carriers.form.main.name')"
:required="true" :required="true"
:error="mainErrors.errors.name" :error="mainErrors.errors.name"
/> />
<MalioInputText <!-- Cas LIOT : le champ immatriculations occupe les colonnes restantes
v-if="isLiot" de la ligne (3 en xl, 2 sinon). Wrapper pour le col-span car
v-model="main.liotPlates" MalioInputText (inheritAttrs:false) renvoie `class` sur l'input. -->
:label="t('transport.carriers.form.main.liotPlates')" <div v-if="isLiot" class="col-span-2 xl:col-span-3">
:hint="t('transport.carriers.form.main.liotPlatesHint')" <MalioInputText
:required="true" v-model="main.liotPlates"
:error="mainErrors.errors.liotPlates" :mask="LIOT_PLATES_MASK"
/> :label="t('transport.carriers.form.main.liotPlates')"
:hint="t('transport.carriers.form.main.liotPlatesHint')"
:required="true"
:error="mainErrors.errors.liotPlates"
/>
</div>
<template v-if="!isLiot"> <template v-if="!isLiot">
<MalioSelect <MalioSelect
:model-value="main.certificationType" :model-value="main.certificationType"
@@ -39,7 +45,7 @@
:label="t('transport.carriers.form.main.certificationType')" :label="t('transport.carriers.form.main.certificationType')"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="certificationReadonly" :disabled="certificationReadonly"
:error="mainErrors.errors.certificationType" :error="mainErrors.errors.certificationType"
@update:model-value="(v: string | number | null) => setCertification(v === null ? null : String(v))" @update:model-value="(v: string | number | null) => setCertification(v === null ? null : String(v))"
/> />
@@ -49,7 +55,7 @@
:label="t('transport.carriers.form.main.discharge')" :label="t('transport.carriers.form.main.discharge')"
accept="application/pdf,image/*" accept="application/pdf,image/*"
:required="true" :required="true"
:readonly="dischargeUploading" :disabled="dischargeUploading"
:clearable="true" :clearable="true"
:error="mainErrors.errors.dischargeDocument" :error="mainErrors.errors.dischargeDocument"
@update:model-value="(v: string) => dischargeFileName = v" @update:model-value="(v: string) => dischargeFileName = v"
@@ -213,7 +219,8 @@ import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTa
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm' import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
import { useCarrier } from '~/modules/transport/composables/useCarrier' import { useCarrier } from '~/modules/transport/composables/useCarrier'
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch' import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput' import { clampPercent, LIOT_PLATES_MASK, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
interface SelectOption { interface SelectOption {
value: string value: string
@@ -284,9 +291,13 @@ const TAB_ICONS: Record<string, string> = {
contacts: 'mdi:account-box-plus-outline', contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:payment', prices: 'mdi:payment',
} }
const activeTab = ref('addresses')
// Onglet Qualimat disponible en modif (ERP-172) : « actualiser » nom + certification. // Onglet Qualimat disponible en modif (ERP-172) : « actualiser » nom + certification.
const tabs = computed(() => ['qualimat', 'addresses', 'contacts', 'prices'].map(key => ({ const TAB_KEYS = ['qualimat', 'addresses', 'contacts', 'prices']
// ERP-193 : on honore l'onglet demande via `?tab=` (navigation depuis la
// consultation) pour retomber sur le meme onglet ; defaut « addresses ».
const requestedTab = typeof route.query.tab === 'string' ? route.query.tab : ''
const activeTab = ref(TAB_KEYS.includes(requestedTab) ? requestedTab : 'addresses')
const tabs = computed(() => TAB_KEYS.map(key => ({
key, key,
label: t(`transport.carriers.tab.${key}`), label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
@@ -364,7 +375,8 @@ function onIndexationInput(value: string): void {
} }
function goBack(): void { function goBack(): void {
router.push(`/carriers/${carrierId}`) // ERP-193 : on transmet l'onglet courant pour retomber dessus en consultation.
router.push({ path: `/carriers/${carrierId}`, query: { tab: activeTab.value } })
} }
/** PATCH du formulaire principal (pas de re-POST). */ /** PATCH du formulaire principal (pas de re-POST). */
@@ -22,7 +22,7 @@
/> />
<MalioButton <MalioButton
v-if="showArchive" v-if="showArchive"
variant="secondary" variant="danger"
icon-name="mdi:archive-arrow-down-outline" icon-name="mdi:archive-arrow-down-outline"
icon-position="left" icon-position="left"
:label="t('transport.carriers.action.archive')" :label="t('transport.carriers.action.archive')"
@@ -45,22 +45,24 @@
<template v-else-if="carrier"> <template v-else-if="carrier">
<!-- Bloc principal (lecture seule) même disposition que l'ajout --> <!-- Bloc principal (lecture seule) même disposition que l'ajout -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText :model-value="main.name" :label="t('transport.carriers.form.main.name')" readonly /> <MalioInputText :model-value="main.name" :label="t('transport.carriers.form.main.name')" disabled />
<!-- Cas LIOT : seul le champ immatriculations. --> <!-- Cas LIOT : le champ immatriculations occupe les colonnes restantes
<MalioInputText de la ligne (3 en xl, 2 sinon), comme à l'ajout / la modification. -->
v-if="isLiot" <div v-if="isLiot" class="col-span-2 xl:col-span-3">
:model-value="main.liotPlates" <MalioInputText
:label="t('transport.carriers.form.main.liotPlates')" :model-value="main.liotPlates"
readonly :label="t('transport.carriers.form.main.liotPlates')"
/> disabled
/>
</div>
<!-- Cas standard : certification + décharge (col 3 réservée) + affrètement (col 4). --> <!-- Cas standard : certification + décharge (col 3 réservée) + affrètement (col 4). -->
<template v-if="!isLiot"> <template v-if="!isLiot">
<MalioInputText <MalioInputText
:model-value="certificationLabel" :model-value="certificationLabel"
:label="t('transport.carriers.form.main.certificationType')" :label="t('transport.carriers.form.main.certificationType')"
readonly disabled
/> />
<!-- Colonne 3 réservée à la décharge (si AUTRE), sinon vide (xl). --> <!-- Colonne 3 réservée à la décharge (si AUTRE), sinon vide (xl). -->
@@ -68,7 +70,7 @@
v-if="main.certificationType === 'AUTRE'" v-if="main.certificationType === 'AUTRE'"
:model-value="dischargeLabel" :model-value="dischargeLabel"
:label="t('transport.carriers.form.main.discharge')" :label="t('transport.carriers.form.main.discharge')"
readonly disabled
/> />
<div v-else class="hidden xl:block"></div> <div v-else class="hidden xl:block"></div>
@@ -78,14 +80,14 @@
id="carrier-view-chartered" id="carrier-view-chartered"
:label="t('transport.carriers.form.main.isChartered')" :label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered" :model-value="main.isChartered"
readonly disabled
:reserve-message-space="false" :reserve-message-space="false"
/> />
</div> </div>
<!-- Champs d'affrètement (ligne 2) si affrété. --> <!-- Champs d'affrètement (ligne 2) si affrété. -->
<template v-if="main.isChartered"> <template v-if="main.isChartered">
<MalioInputText :model-value="indexationDisplay" :label="t('transport.carriers.form.main.indexationRate')" readonly /> <MalioInputText :model-value="indexationDisplay" :label="t('transport.carriers.form.main.indexationRate')" disabled />
<!-- Contenant : radios désactivés (lecture seule), aligné sur l'ajout / la modif. --> <!-- Contenant : radios désactivés (lecture seule), aligné sur l'ajout / la modif. -->
<div> <div>
<div class="flex h-12 items-center gap-4"> <div class="flex h-12 items-center gap-4">
@@ -94,7 +96,7 @@
name="carrier-view-container" name="carrier-view-container"
value="BENNE" value="BENNE"
:label="t('transport.carriers.containerType.BENNE')" :label="t('transport.carriers.containerType.BENNE')"
readonly disabled
group-class="mt-0" group-class="mt-0"
/> />
<MalioRadioButton <MalioRadioButton
@@ -102,25 +104,26 @@
name="carrier-view-container" name="carrier-view-container"
value="FOND_MOUVANT" value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')" :label="t('transport.carriers.containerType.FOND_MOUVANT')"
readonly disabled
group-class="mt-0" group-class="mt-0"
/> />
</div> </div>
</div> </div>
<MalioInputText :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" readonly /> <MalioInputText :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" disabled />
</template> </template>
</template> </template>
</div> </div>
<!-- Onglets (Adresses · Contacts · Prix) ouvre sur Adresses --> <!-- Onglets (Adresses · Contacts · Prix) ouvre sur Adresses -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]"> <!-- ERP-193 : barre masquee s'il ne reste aucun onglet non vide. -->
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #addresses> <template #addresses>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<!-- Adresse UNIQUE (ERP-172). --> <!-- Adresse UNIQUE (ERP-172). -->
<CarrierAddressBlock <CarrierAddressBlock
:model-value="address" :model-value="address"
:country-options="countryOptionsFor(address.country)" :country-options="countryOptionsFor(address.country)"
readonly disabled
/> />
</div> </div>
</template> </template>
@@ -131,7 +134,7 @@
v-for="(contact, index) in contacts" v-for="(contact, index) in contacts"
:key="index" :key="index"
:model-value="contact" :model-value="contact"
readonly disabled
/> />
</div> </div>
</template> </template>
@@ -144,14 +147,15 @@
groupe (Fond Mouvant / Benne) fusionné en rowspan ; séparateur groupe (Fond Mouvant / Benne) fusionné en rowspan ; séparateur
épais entre les deux groupes. --> épais entre les deux groupes. -->
<table class="w-full table-fixed border-separate border-spacing-0 overflow-hidden rounded-malio border border-black text-left text-black"> <table class="w-full table-fixed border-separate border-spacing-0 overflow-hidden rounded-malio border border-black text-left text-black">
<!-- Répartition (table-fixed) : « Type de transport » un peu plus <!-- Répartition (table-fixed) : « Transport » étroit (libellé
large ; Transporteurs et Adresse livraisons larges ; Forfait / court Benne / Fond mouvant) ; Fournisseurs/Clients et
Tonne / Indexation / État réduits. --> Adresse livraisons larges ; Forfait / Tonne / Indexation
/ État réduits. -->
<colgroup> <colgroup>
<col class="w-[170px]" /> <col class="w-[120px]" />
<col class="w-[20%]" /> <col class="w-[20%]" />
<col class="w-[11%]" />
<col class="w-[24%]" /> <col class="w-[24%]" />
<col class="w-[11%]" />
<col class="w-[9%]" /> <col class="w-[9%]" />
<col class="w-[9%]" /> <col class="w-[9%]" />
<col class="w-[9%]" /> <col class="w-[9%]" />
@@ -162,8 +166,8 @@
<!-- En-tête centré pour matcher les cellules fusionnées Benne / Fond mouvant. --> <!-- En-tête centré pour matcher les cellules fusionnées Benne / Fond mouvant. -->
<th class="border-b border-r border-black bg-m-surface px-3 py-3 text-center align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.group') }}</th> <th class="border-b border-r border-black bg-m-surface px-3 py-3 text-center align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.group') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.carrier') }}</th> <th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.carrier') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.aproOrSite') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.delivery') }}</th> <th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.delivery') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.aproOrSite') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.forfait') }}</th> <th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.forfait') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.tonne') }}</th> <th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.tonne') }}</th>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.indexation') }}</th> <th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.indexation') }}</th>
@@ -171,28 +175,21 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<template v-for="(group, gi) in priceGroups" :key="group.label"> <template v-for="(group, gi) in priceGroups" :key="gi">
<tr <tr
v-for="(row, i) in group.rows" v-for="(row, i) in group.rows"
:key="`${gi}-${i}`" :key="`${gi}-${i}`"
> >
<!-- Cellule de groupe fusionnée (rowspan), centrée verticalement ; <!-- Contenant par ligne (plus de cellule fusionnée) ; séparateur
séparateur épais en bas entre les groupes (sauf dernier). --> à droite, comme l'ancienne colonne de groupe. -->
<td <td class="border-r border-black px-3 py-4 text-center align-middle text-[14px] font-medium" :class="dataBorder(gi, i)">{{ row.transport }}</td>
v-if="i === 0" <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.party }}</td>
:rowspan="group.rows.length" <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.delivery }}</td>
class="border-r border-black px-3 text-center align-middle text-[14px] font-medium" <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.apro }}</td>
:class="groupBorder(gi)" <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.forfait }}</td>
> <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.tonne }}</td>
{{ group.label }} <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.indexation }}</td>
</td> <td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.state }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ headerTitle }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.apro }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.delivery }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.forfait }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.tonne }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.indexation }}</td>
<td class="px-3 py-4 text-[14px]" :class="dataBorder(group, i, gi)">{{ row.state }}</td>
</tr> </tr>
</template> </template>
<tr v-if="!hasPrices"> <tr v-if="!hasPrices">
@@ -241,12 +238,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue' import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue' import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
import { useCarrier } from '~/modules/transport/composables/useCarrier' import { useCarrier } from '~/modules/transport/composables/useCarrier'
import { import {
canEditCarrier, canEditCarrier,
carrierConsultationVisibleTabs,
labelOfRelation, labelOfRelation,
mapAddressToDraft, mapAddressToDraft,
mapContactToDraft, mapContactToDraft,
@@ -300,18 +298,36 @@ const dischargeLabel = computed(() => {
}) })
// ── Onglets : Adresses · Contacts · Prix (ouvre sur Adresses, pas de Qualimat) ── // ── Onglets : Adresses · Contacts · Prix (ouvre sur Adresses, pas de Qualimat) ──
const activeTab = ref('addresses') // ERP-193 (retour metier) : on masque tout onglet de donnees vide.
const TAB_ICONS: Record<string, string> = { const TAB_ICONS: Record<string, string> = {
addresses: 'mdi:map-marker-outline', addresses: 'mdi:map-marker-outline',
contacts: 'mdi:account-box-plus-outline', contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:payment', prices: 'mdi:payment',
} }
const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({ const visibleTabKeys = computed(() => carrierConsultationVisibleTabs(carrier.value))
const tabs = computed(() => visibleTabKeys.value.map(key => ({
key, key,
label: t(`transport.carriers.tab.${key}`), label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key], icon: TAB_ICONS[key],
}))) })))
// Onglet initial : vide tant que le transporteur n'est pas charge, puis premier
// onglet visible. Un watcher recale si l'onglet courant disparait. ERP-193 : on
// honore l'onglet demande via `?tab=` (navigation depuis l'edition) s'il est
// visible, pour retomber sur le meme onglet en passant edition <-> consultation.
const activeTab = ref('')
let requestedTab = typeof route.query.tab === 'string' ? route.query.tab : ''
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = requestedTab && keys.includes(requestedTab) ? requestedTab : keys[0]
requestedTab = ''
}
}, { immediate: true })
// Adresse UNIQUE (ERP-172) : un seul bloc en lecture seule (vide si pas d'adresse). // Adresse UNIQUE (ERP-172) : un seul bloc en lecture seule (vide si pas d'adresse).
const address = computed(() => carrier.value?.address const address = computed(() => carrier.value?.address
? mapAddressToDraft(carrier.value.address) ? mapAddressToDraft(carrier.value.address)
@@ -326,10 +342,17 @@ function countryOptionsFor(country: string): SelectOption[] {
return country ? [{ value: country, label: country }] : [] return country ? [{ value: country, label: country }] : []
} }
// ── Tableau Prix consultation (regroupé par contenant Fond Mouvant / Benne) ─── // ── Tableau Prix consultation (regroupé par ADRESSE DE LIVRAISON) ─────────────
const PRICE_GROUP_ORDER = ['FOND_MOUVANT', 'BENNE'] as const // Rang d'affichage des contenants au sein d'une même adresse (Fond mouvant puis Benne).
const CONTAINER_RANK: Record<string, number> = { FOND_MOUVANT: 0, BENNE: 1 }
interface PriceRowView { interface PriceRowView {
/** Contenant (libellé affiché : Fond mouvant / Benne). */
transport: string
/** Contenant brut (FOND_MOUVANT / BENNE) — tri interne du groupe. */
transportType: string
/** Fournisseur ou client lié au prix (raison sociale). */
party: string
apro: string apro: string
delivery: string delivery: string
forfait: string forfait: string
@@ -338,9 +361,8 @@ interface PriceRowView {
state: string state: string
} }
/** Groupe de prix d'un même contenant (Fond Mouvant / Benne) — cellule fusionnée. */ /** Groupe de prix d'une même adresse de livraison (lignes consécutives). */
interface PriceGroupView { interface PriceGroupView {
label: string
rows: PriceRowView[] rows: PriceRowView[]
} }
@@ -367,13 +389,19 @@ function siteCode(relation: Relation): string {
/** /**
* Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) : * Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) :
* - « Transport » = le contenant (Fond mouvant / Benne) ;
* - « Fournisseurs / Clients » = le fournisseur OU le client lié (raison sociale) ;
* - « Adresse sites » = le CODE du site (département, ex: 86 / 17 / 82) ; * - « Adresse sites » = le CODE du site (département, ex: 86 / 17 / 82) ;
* - « Adresse livraisons » = l'adresse (voie) du client/fournisseur ; * - « Adresse livraisons » = l'adresse (voie) du client/fournisseur ;
* - le prix tombe dans Forfait € OU Tonne € selon `pricingUnit`. * - le prix tombe dans Forfait € OU Tonne € selon `pricingUnit`.
*/ */
function toPriceRow(price: CarrierPriceRead): PriceRowView { function toPriceRow(price: CarrierPriceRead): PriceRowView {
const isClient = price.direction === 'CLIENT' const isClient = price.direction === 'CLIENT'
const containerType = price.containerType ?? ''
return { return {
transportType: containerType,
transport: containerType ? t(`transport.carriers.containerType.${containerType}`) : '',
party: labelOfRelation(isClient ? price.client : price.supplier),
apro: isClient ? siteCode(price.departureSite) : siteCode(price.deliverySite), apro: isClient ? siteCode(price.departureSite) : siteCode(price.deliverySite),
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress), delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '', forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '',
@@ -392,39 +420,48 @@ function stateSuffix(state: string): string {
return map[state] ?? '' return map[state] ?? ''
} }
// Prix regroupés par contenant (Fond Mouvant puis Benne) — une cellule fusionnée // Prix regroupés par ADRESSE DE LIVRAISON : les lignes d'une même adresse sont
// par groupe (rowspan) à gauche, conformément à la maquette. // consécutives (triées par contenant Fond mouvant → Benne), les groupes triés
// alphabétiquement par adresse. Un séparateur épais sépare deux adresses.
const priceGroups = computed<PriceGroupView[]>(() => { const priceGroups = computed<PriceGroupView[]>(() => {
const list = carrier.value?.prices ?? [] const rows = (carrier.value?.prices ?? []).map(toPriceRow)
return PRICE_GROUP_ORDER const byDelivery = new Map<string, PriceRowView[]>()
.map(container => ({ for (const row of rows) {
label: t(`transport.carriers.containerType.${container}`), const list = byDelivery.get(row.delivery)
rows: list.filter(p => p.containerType === container).map(toPriceRow), if (list) {
list.push(row)
} else {
byDelivery.set(row.delivery, [row])
}
}
return [...byDelivery.entries()]
.sort(([a], [b]) => a.localeCompare(b, 'fr'))
.map(([, groupRows]) => ({
rows: groupRows
.slice()
.sort((x, y) => (CONTAINER_RANK[x.transportType] ?? 99) - (CONTAINER_RANK[y.transportType] ?? 99)),
})) }))
.filter(group => group.rows.length > 0)
}) })
const hasPrices = computed(() => priceGroups.value.length > 0) const hasPrices = computed(() => priceGroups.value.length > 0)
/** /**
* Bordure basse d'une cellule de données : * Bordure basse d'une cellule de données :
* - ligne interne d'un groupe → fine grise ; * - ligne interne d'un groupe d'adresse (même adresse de livraison) → fine grise ;
* - dernière ligne d'un groupe NON final → épaisse noire (séparateur de groupe) ; * - dernière ligne d'un groupe NON final → épaisse noire (sépare deux adresses) ;
* - dernière ligne du DERNIER groupe → aucune (le cadre du tableau s'en charge, * - dernière ligne du DERNIER groupe → aucune (le cadre du tableau s'en charge,
* évite la double bordure tout en bas). * évite la double bordure tout en bas).
*/ */
function dataBorder(group: PriceGroupView, i: number, gi: number): string { function dataBorder(gi: number, i: number): string {
const group = priceGroups.value[gi]
const isLastRow = i === group.rows.length - 1 const isLastRow = i === group.rows.length - 1
const isLastGroup = gi === priceGroups.value.length - 1 const isLastGroup = gi === priceGroups.value.length - 1
// Couleur de bordure SIDE-SPECIFIC (border-b-*) : un `border-{color}` global
// ecraserait la couleur du bord droit noir de la colonne Transport.
if (!isLastRow) { if (!isLastRow) {
return 'border-b border-m-muted/30' return 'border-b border-b-m-muted/30'
} }
return isLastGroup ? '' : 'border-b-2 border-black' return isLastGroup ? '' : 'border-b-2 border-b-black'
}
/** Bordure basse de la cellule de groupe fusionnée (séparateur épais sauf dernier groupe). */
function groupBorder(gi: number): string {
return gi === priceGroups.value.length - 1 ? '' : 'border-b-2 border-black'
} }
// ── Export XLSX des prix ───────────────────────────────────────────────────── // ── Export XLSX des prix ─────────────────────────────────────────────────────
@@ -465,7 +502,8 @@ function goBack(): void {
} }
function goEdit(): void { function goEdit(): void {
router.push(`/carriers/${carrierId}/edit`) // ERP-193 : on transmet l'onglet courant pour retomber dessus en edition.
router.push({ path: `/carriers/${carrierId}/edit`, query: activeTab.value ? { tab: activeTab.value } : {} })
} }
const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' }) const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' })
@@ -20,22 +20,28 @@
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4"> <div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
v-model="main.name" v-model="main.name"
:mask="FREE_TEXT_MASK"
:label="t('transport.carriers.form.main.name')" :label="t('transport.carriers.form.main.name')"
:required="true" :required="true"
:readonly="mainLocked" :disabled="mainLocked"
:error="mainErrors.errors.name" :error="mainErrors.errors.name"
/> />
<!-- Cas LIOT : seul le champ immatriculations est pertinent. --> <!-- Cas LIOT : seul le champ immatriculations est pertinent. Il occupe
<MalioInputText les colonnes restantes de la ligne (3 en xl, 2 sinon) le wrapper
v-if="isLiot" porte le col-span car MalioInputText (inheritAttrs:false) renvoie
v-model="main.liotPlates" `class` sur l'input interne, pas sur la cellule de grille. -->
:label="t('transport.carriers.form.main.liotPlates')" <div v-if="isLiot" class="col-span-2 xl:col-span-3">
:hint="t('transport.carriers.form.main.liotPlatesHint')" <MalioInputText
:required="true" v-model="main.liotPlates"
:readonly="mainLocked" :mask="LIOT_PLATES_MASK"
:error="mainErrors.errors.liotPlates" :label="t('transport.carriers.form.main.liotPlates')"
/> :hint="t('transport.carriers.form.main.liotPlatesHint')"
:required="true"
:disabled="mainLocked"
:error="mainErrors.errors.liotPlates"
/>
</div>
<!-- Cas standard : certification + affretement + champs conditionnels. --> <!-- Cas standard : certification + affretement + champs conditionnels. -->
<template v-if="!isLiot"> <template v-if="!isLiot">
@@ -45,7 +51,7 @@
:label="t('transport.carriers.form.main.certificationType')" :label="t('transport.carriers.form.main.certificationType')"
empty-option-label="" empty-option-label=""
:required="true" :required="true"
:readonly="certificationReadonly" :disabled="certificationReadonly"
:error="mainErrors.errors.certificationType" :error="mainErrors.errors.certificationType"
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)" @update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
/> />
@@ -61,7 +67,7 @@
:label="t('transport.carriers.form.main.discharge')" :label="t('transport.carriers.form.main.discharge')"
accept="application/pdf,image/*" accept="application/pdf,image/*"
:required="true" :required="true"
:readonly="mainLocked || dischargeUploading" :disabled="mainLocked || dischargeUploading"
:clearable="true" :clearable="true"
:error="mainErrors.errors.dischargeDocument" :error="mainErrors.errors.dischargeDocument"
@update:model-value="(v: string) => dischargeFileName = v" @update:model-value="(v: string) => dischargeFileName = v"
@@ -78,7 +84,7 @@
id="carrier-is-chartered" id="carrier-is-chartered"
:label="t('transport.carriers.form.main.isChartered')" :label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered" :model-value="main.isChartered"
:readonly="mainLocked" :disabled="mainLocked"
:reserve-message-space="false" :reserve-message-space="false"
@update:model-value="(val: boolean) => main.isChartered = val" @update:model-value="(val: boolean) => main.isChartered = val"
/> />
@@ -98,7 +104,7 @@
icon-name="mdi:percent" icon-name="mdi:percent"
icon-position="right" icon-position="right"
:required="true" :required="true"
:readonly="mainLocked" :disabled="mainLocked"
:error="mainErrors.errors.indexationRate" :error="mainErrors.errors.indexationRate"
@update:model-value="onIndexationInput" @update:model-value="onIndexationInput"
/> />
@@ -134,7 +140,7 @@
:model-value="main.volumeM3" :model-value="main.volumeM3"
:label="t('transport.carriers.form.main.volumeM3')" :label="t('transport.carriers.form.main.volumeM3')"
:required="true" :required="true"
:readonly="mainLocked" :disabled="mainLocked"
:error="mainErrors.errors.volumeM3" :error="mainErrors.errors.volumeM3"
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)" @update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
/> />
@@ -174,7 +180,7 @@
<CarrierAddressBlock <CarrierAddressBlock
:model-value="address" :model-value="address"
:country-options="countryOptions" :country-options="countryOptions"
:readonly="isQualimat || isValidated('addresses')" :disabled="isQualimat || isValidated('addresses')"
:errors="addressErrors" :errors="addressErrors"
@update:model-value="(v) => address = v" @update:model-value="(v) => address = v"
@degraded="onAddressDegraded" @degraded="onAddressDegraded"
@@ -192,8 +198,8 @@
</div> </div>
</template> </template>
<!-- Onglet Contacts (ERP-168) : un bloc par contact (RG-4.08 ≥ 1 champ, <!-- Onglet Contacts (ERP-168) : un bloc par contact (bloc optionnel,
max 2 téléphones). Erreurs 422 par ligne. --> ERP-193 ; max 2 téléphones). Erreurs 422 par ligne. -->
<template #contacts> <template #contacts>
<div class="mt-12 flex flex-col gap-6"> <div class="mt-12 flex flex-col gap-6">
<CarrierContactBlock <CarrierContactBlock
@@ -201,7 +207,7 @@
:key="index" :key="index"
:model-value="contact" :model-value="contact"
:removable="isRowRemovable(contacts, index)" :removable="isRowRemovable(contacts, index)"
:readonly="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"
@remove="askRemoveContact(index)" @remove="askRemoveContact(index)"
@@ -237,7 +243,7 @@
:supplier-options="supplierOptions" :supplier-options="supplierOptions"
:site-options="siteOptions" :site-options="siteOptions"
:removable="!isValidated('prices')" :removable="!isValidated('prices')"
:readonly="isValidated('prices')" :disabled="isValidated('prices')"
: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)"
@@ -307,7 +313,8 @@ import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.
import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue' import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue'
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm' import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch' import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput' import { clampPercent, LIOT_PLATES_MASK, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
interface SelectOption { interface SelectOption {
value: string value: string
@@ -1,6 +1,8 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { import {
canEditCarrier, canEditCarrier,
carrierConsultationVisibleTabs,
hasAddressData,
iriOf, iriOf,
labelOfRelation, labelOfRelation,
mapAddressToDraft, mapAddressToDraft,
@@ -25,6 +27,10 @@ describe('carrierMappers', () => {
expect(iriOf(undefined)).toBeNull() expect(iriOf(undefined)).toBeNull()
}) })
it('labelOfRelation : companyName (client/fournisseur) prioritaire sur name/adresse', () => {
expect(labelOfRelation({ '@id': '/api/suppliers/8', companyName: 'AAAAAAA', name: 'X' })).toBe('AAAAAAA')
})
it('labelOfRelation : name (site) à défaut adresse condensée', () => { it('labelOfRelation : name (site) à défaut adresse condensée', () => {
expect(labelOfRelation({ '@id': '/api/sites/1', name: 'Châtellerault' })).toBe('Châtellerault') expect(labelOfRelation({ '@id': '/api/sites/1', name: 'Châtellerault' })).toBe('Châtellerault')
expect(labelOfRelation({ '@id': '/api/client_addresses/8', street: '1 rue X', postalCode: '86000', city: 'Poitiers' })).toBe('1 rue X · 86000 · Poitiers') expect(labelOfRelation({ '@id': '/api/client_addresses/8', street: '1 rue X', postalCode: '86000', city: 'Poitiers' })).toBe('1 rue X · 86000 · Poitiers')
@@ -118,3 +124,47 @@ describe('carrierMappers', () => {
expect(showRestoreAction(noArchive, true)).toBe(false) expect(showRestoreAction(noArchive, true)).toBe(false)
}) })
}) })
describe('hasAddressData', () => {
it('faux pour une adresse absente ou entièrement vide', () => {
expect(hasAddressData(null)).toBe(false)
expect(hasAddressData(undefined)).toBe(false)
expect(hasAddressData({ '@id': '/api/carrier_addresses/1', id: 1 })).toBe(false)
})
it('vrai dès qu\'un champ adresse est rempli', () => {
expect(hasAddressData({ '@id': '/api/carrier_addresses/1', id: 1, city: 'Poitiers' })).toBe(true)
expect(hasAddressData({ '@id': '/api/carrier_addresses/1', id: 1, country: 'France' })).toBe(true)
})
})
describe('carrierConsultationVisibleTabs', () => {
it('retourne [] tant que le transporteur n\'est pas chargé', () => {
expect(carrierConsultationVisibleTabs(null)).toEqual([])
expect(carrierConsultationVisibleTabs(undefined)).toEqual([])
})
it('masque les onglets vides (transporteur minimal)', () => {
expect(carrierConsultationVisibleTabs({ '@id': '/api/carriers/1', id: 1, name: 'LIOT' })).toEqual([])
})
it('affiche addresses/contacts/prices dans l\'ordre quand renseignés', () => {
const carrier: CarrierDetail = {
'@id': '/api/carriers/1', id: 1,
address: { '@id': '/api/carrier_addresses/1', id: 1, city: 'Poitiers' },
contacts: [{ '@id': '/api/carrier_contacts/1', id: 1 }],
prices: [{ '@id': '/api/carrier_prices/1', id: 1 }],
}
expect(carrierConsultationVisibleTabs(carrier)).toEqual(['addresses', 'contacts', 'prices'])
})
it('ne garde que les onglets non vides (contacts seulement)', () => {
const carrier: CarrierDetail = {
'@id': '/api/carriers/1', id: 1,
address: { '@id': '/api/carrier_addresses/1', id: 1 },
contacts: [{ '@id': '/api/carrier_contacts/1', id: 1 }],
prices: [],
}
expect(carrierConsultationVisibleTabs(carrier)).toEqual(['contacts'])
})
})
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { clampPercent, sanitizeDecimal } from '../numberInput' import { Mask } from 'maska'
import { clampPercent, LIOT_PLATES_MASK, sanitizeDecimal } from '../numberInput'
describe('numberInput — saisie volume / indexation (ERP-170)', () => { describe('numberInput — saisie volume / indexation (ERP-170)', () => {
it('sanitizeDecimal : ne garde que chiffres + un seul point', () => { it('sanitizeDecimal : ne garde que chiffres + un seul point', () => {
@@ -19,4 +20,14 @@ describe('numberInput — saisie volume / indexation (ERP-170)', () => {
expect(clampPercent('12,5')).toBe('12,5') // ≤ 100 → inchangé expect(clampPercent('12,5')).toBe('12,5') // ≤ 100 → inchangé
expect(clampPercent('')).toBe('') expect(clampPercent('')).toBe('')
}) })
it('LIOT_PLATES_MASK : garde lettres/chiffres/tiret/point-virgule, bloque espaces et reste', () => {
// Reproduit ce que fait maska au runtime (MaskInput) : preProcess puis masked.
const masked = (v: string) => new Mask(LIOT_PLATES_MASK).masked(LIOT_PLATES_MASK.preProcess!(v))
expect(masked('AB-123-CD;EF-456-GH')).toBe('AB-123-CD;EF-456-GH')
expect(masked('ab-123-cd ; ef-456-gh')).toBe('ab-123-cd;ef-456-gh') // espaces retirés
expect(masked('AB 123 CD')).toBe('AB123CD') // espaces retirés
expect(masked('AB.123/CD#42&²²')).toBe('AB123CD42') // . / # & ² retirés
expect(masked('')).toBe('')
})
}) })
@@ -1,10 +1,10 @@
/** /**
* Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168) ALIGNÉ * Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168). ERP-193
* sur `providerContact.ts` (M3) / les autres modules : mêmes règles de validité et * (retour métier) : l'onglet Contact n'est plus obligatoire la règle « prénom OU
* de gating « + Nouveau contact » (un contact est « nommé » dès qu'il porte un * nom » est retirée. Le gating « + Nouveau contact » repose désormais sur « le
* prénom OU un nom). Seule spécificité M4 conservée : les téléphones partent au back * dernier bloc n'est pas vide » (et non plus « nommé »). Spécificité M4 conservée :
* dans le tableau virtuel `phones` (max 2), mappés par le CarrierContactProcessor. * les téléphones partent au back dans le tableau virtuel `phones` (max 2), mappés
* Testables sans Vue ni API. * par le CarrierContactProcessor. Testables sans Vue ni API.
*/ */
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm' import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
@@ -30,15 +30,6 @@ export function isCarrierContactBlank(contact: CarrierContactFormDraft): boolean
].some(isFilled) ].some(isFilled)
} }
/**
* Un contact est « nommé » (valide) dès qu'il porte un prénom OU un nom aligné
* sur M1/M2/M3. Pilote le gating « + Nouveau contact » : la fonction / le téléphone
* / l'email seuls ne suffisent pas pour ajouter un nouveau bloc.
*/
export function isCarrierContactNamed(contact: CarrierContactFormDraft): boolean {
return isFilled(contact.firstName) || isFilled(contact.lastName)
}
/** /**
* Payload de la sous-ressource contacts (groupe `carrier:write:contacts`). Les * Payload de la sous-ressource contacts (groupe `carrier:write:contacts`). Les
* chaînes vides partent à null (le serveur normalise/trim). Les téléphones sont * chaînes vides partent à null (le serveur normalise/trim). Les téléphones sont
@@ -97,13 +97,18 @@ export function iriOf(relation: Relation): string | null {
} }
/** /**
* Libellé d'affichage d'une relation embarquée : `name` (site) à défaut une adresse * Libellé d'affichage d'une relation embarquée : `companyName` (client/fournisseur)
* condensée (voie · CP · ville). Chaîne vide si la relation est un IRI nu / absente. * à défaut `name` (site), à défaut une adresse condensée (voie · CP · ville). Chaîne
* vide si la relation est un IRI nu / absente.
*/ */
export function labelOfRelation(relation: Relation): string { export function labelOfRelation(relation: Relation): string {
if (!relation || typeof relation === 'string') { if (!relation || typeof relation === 'string') {
return '' return ''
} }
const companyName = relation.companyName as string | undefined
if (companyName) {
return companyName
}
const name = relation.name as string | undefined const name = relation.name as string | undefined
if (name) { if (name) {
return name return name
@@ -175,6 +180,62 @@ export function mapPriceToDraft(price: CarrierPriceRead): CarrierPriceFormDraft
} }
} }
/**
* Vrai si une valeur scalaire porte une donnée affichable (non null/undefined,
* et non chaîne vide après trim). Sert aux prédicats « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'adresse unique (OneToOne, ERP-172) porte au moins un champ rempli.
* Un objet adresse vide (toutes clés nulles) est considéré comme « pas d'adresse ».
*/
export function hasAddressData(address: CarrierAddressRead | null | undefined): boolean {
if (!address) {
return false
}
return [
address.postalCode,
address.city,
address.street,
address.streetComplement,
address.country,
].some(hasValue)
}
/**
* Onglets visibles en consultation (ERP-193, retour métier) : on masque tout
* onglet de données vide. Le transporteur n'a pas de coquille « à venir ».
* Ordre : Adresses · Contacts · Prix. Retourne `[]` tant que le transporteur
* n'est pas chargé.
*/
export function carrierConsultationVisibleTabs(
carrier: CarrierDetail | null | undefined,
): string[] {
if (!carrier) {
return []
}
const visible: string[] = []
if (hasAddressData(carrier.address)) {
visible.push('addresses')
}
if ((carrier.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((carrier.prices ?? []).length > 0) {
visible.push('prices')
}
return visible
}
/** Bouton « Modifier » : visible avec la permission `manage` (Admin / Bureau). */ /** Bouton « Modifier » : visible avec la permission `manage` (Admin / Bureau). */
export function canEditCarrier(can: (code: string) => boolean): boolean { export function canEditCarrier(can: (code: string) => boolean): boolean {
return can('transport.carriers.manage') return can('transport.carriers.manage')
@@ -1,7 +1,9 @@
/** /**
* Helpers de saisie numérique du formulaire principal transporteur (ERP-170). * Helpers de saisie du formulaire principal transporteur (ERP-170).
* Champs texte restreints (volume m³ décimal, indexation plafonnée). Purs / testables. * Champs texte restreints (volume m³ décimal, indexation plafonnée, immatriculations
* LIOT via mask maska). Purs / testables.
*/ */
import type { MaskInputOptions } from 'maska'
/** /**
* Restreint une saisie à un nombre décimal : chiffres + UN seul point (RG volume m³, * Restreint une saisie à un nombre décimal : chiffres + UN seul point (RG volume m³,
@@ -26,3 +28,20 @@ export function clampPercent(value: string): string {
const n = Number(String(value ?? '').replace(',', '.').replace(/\s/g, '')) const n = Number(String(value ?? '').replace(',', '.').replace(/\s/g, ''))
return (!Number.isNaN(n) && n > 100) ? '100' : value return (!Number.isNaN(n) && n > 100) ? '100' : value
} }
/**
* Mask maska des immatriculations LIOT : n'autorise que lettres, chiffres, tiret et
* point-virgule (séparateur de plaques), longueur libre. Filtrage NATIF (maska gère
* le focus et le curseur, contrairement à un nettoyage manuel). Espaces et tout autre
* caractère sont bloqués à la frappe / au collage. La normalisation finale (majuscules
* + « ; » espacé) reste au back (RG-4.13).
*
* `preProcess` retire d'abord tout caractère interdit (espaces, &, ², …) OÙ QU'IL
* SOIT (le masque positionnel seul s'arrêterait au 1er caractère invalide) ; le
* token `P` en `multiple: true` laisse ensuite passer le reste (longueur libre).
*/
export const LIOT_PLATES_MASK: MaskInputOptions = {
mask: 'P',
tokens: { P: { pattern: /[A-Za-z0-9;-]/, multiple: true } },
preProcess: (value: string) => value.replace(/[^A-Za-z0-9;-]/g, ''),
}
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "starseed-frontend", "name": "starseed-frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.12", "@malio/layer-ui": "^1.7.13",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -1866,9 +1866,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.7.12", "version": "1.7.13",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.12/layer-ui-1.7.12.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.13/layer-ui-1.7.13.tgz",
"integrity": "sha512-ezQLqi19K2ogI3XwSMsUyluU9x5C4W0tu1muxFbL3foKjibRYRg/FdvySivEhEsalAAt1E88V6Sv/06xPqyYTw==", "integrity": "sha512-/l3wAYKSeJSZslaom81/ugXrY/vVElrg6Mc6U16v7Pm2RruzLJQlX/90aIe+nYzl0xShRZPmdaD+eozFWmes/A==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
+1 -1
View File
@@ -17,7 +17,7 @@
"test:e2e:ui": "playwright test --ui" "test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.7.12", "@malio/layer-ui": "^1.7.13",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3", "@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",
@@ -22,11 +22,12 @@ describe('removeCollectionRow', () => {
const errors: Record<string, string>[] = [{}, {}] const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined) const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn() const onError = vi.fn()
const onSuccess = vi.fn()
const removed = await removeCollectionRow({ const removed = await removeCollectionRow({
rows, errors, index: 0, rows, errors, index: 0,
endpoint: '/client_contacts', endpoint: '/client_contacts',
deleteRow, makeEmpty, onError, deleteRow, makeEmpty, onError, onSuccess,
}) })
expect(deleteRow).toHaveBeenCalledOnce() expect(deleteRow).toHaveBeenCalledOnce()
@@ -35,6 +36,8 @@ describe('removeCollectionRow', () => {
expect(rows).toEqual([{ id: 11, label: 'B' }]) expect(rows).toEqual([{ id: 11, label: 'B' }])
expect(errors).toHaveLength(1) expect(errors).toHaveLength(1)
expect(onError).not.toHaveBeenCalled() expect(onError).not.toHaveBeenCalled()
// Toast de succes uniquement sur suppression serveur confirmee.
expect(onSuccess).toHaveBeenCalledOnce()
}) })
it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => { it('ne fait AUCUN appel reseau pour un bloc jamais persiste (id null) — retrait local', async () => {
@@ -42,16 +45,19 @@ describe('removeCollectionRow', () => {
const errors: Record<string, string>[] = [{}, {}] const errors: Record<string, string>[] = [{}, {}]
const deleteRow = vi.fn().mockResolvedValue(undefined) const deleteRow = vi.fn().mockResolvedValue(undefined)
const onError = vi.fn() const onError = vi.fn()
const onSuccess = vi.fn()
const removed = await removeCollectionRow({ const removed = await removeCollectionRow({
rows, errors, index: 1, rows, errors, index: 1,
endpoint: '/client_contacts', endpoint: '/client_contacts',
deleteRow, makeEmpty, onError, deleteRow, makeEmpty, onError, onSuccess,
}) })
expect(deleteRow).not.toHaveBeenCalled() expect(deleteRow).not.toHaveBeenCalled()
expect(removed).toBe(true) expect(removed).toBe(true)
expect(rows).toEqual([{ id: 10, label: 'A' }]) expect(rows).toEqual([{ id: 10, label: 'A' }])
// Retrait d'un simple brouillon local : pas de toast « supprime ».
expect(onSuccess).not.toHaveBeenCalled()
}) })
it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => { it('conserve le bloc et remonte l\'erreur si le DELETE serveur echoue (ex. 409 dernier RIB LCR)', async () => {
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { todayIso } from '../date'
describe('todayIso', () => {
it('formate la date locale en YYYY-MM-DD (zero-pad mois/jour)', () => {
// 7 mars 2026 (heure locale) -> '2026-03-07'.
expect(todayIso(new Date(2026, 2, 7, 10, 30))).toBe('2026-03-07')
})
it('utilise les composantes LOCALES, pas UTC (pas de decalage de minuit)', () => {
// 18 juin 2026 23:30 heure locale : la date locale reste le 18 meme si
// toISOString() (UTC) basculerait au 19 selon le fuseau.
expect(todayIso(new Date(2026, 5, 18, 23, 30))).toBe('2026-06-18')
})
it('gere le dernier jour de l\'annee', () => {
expect(todayIso(new Date(2026, 11, 31, 12, 0))).toBe('2026-12-31')
})
})
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { Mask, type MaskInputOptions } from 'maska'
import {
ADDRESS_MASK,
CODE_ALNUM_MASK,
FREE_TEXT_MASK,
PERSON_NAME_MASK,
} from '../textSanitize'
/** Reproduit le traitement maska au runtime (MaskInput) : preProcess puis masked. */
function apply(mask: MaskInputOptions, value: string): string {
const pre = mask.preProcess ? mask.preProcess(value) : value
return new Mask(mask).masked(pre)
}
describe('PERSON_NAME_MASK', () => {
it('garde lettres accentuees, espace, apostrophe, tiret, point', () => {
expect(apply(PERSON_NAME_MASK, 'Jean-Pierre')).toBe('Jean-Pierre')
expect(apply(PERSON_NAME_MASK, 'OBrien')).toBe('OBrien')
expect(apply(PERSON_NAME_MASK, "D'Angelo")).toBe("D'Angelo")
expect(apply(PERSON_NAME_MASK, 'Saint-Étienne J.')).toBe('Saint-Étienne J.')
})
it('retire chiffres et caracteres parasites (ou qu\'ils soient)', () => {
expect(apply(PERSON_NAME_MASK, 'Dupont²³')).toBe('Dupont')
expect(apply(PERSON_NAME_MASK, 'Jean§&#~|')).toBe('Jean')
expect(apply(PERSON_NAME_MASK, 'Ma§rie123')).toBe('Marie') // parasite AU MILIEU
})
})
describe('FREE_TEXT_MASK', () => {
it('garde &, /, parentheses, degre, chiffres', () => {
expect(apply(FREE_TEXT_MASK, 'Dupont & Fils')).toBe('Dupont & Fils')
expect(apply(FREE_TEXT_MASK, 'Resp. Achats/Ventes')).toBe('Resp. Achats/Ventes')
expect(apply(FREE_TEXT_MASK, 'SARL Léon (Pôle n°2)')).toBe('SARL Léon (Pôle n°2)')
})
it('retire les parasites ²³§~#|', () => {
expect(apply(FREE_TEXT_MASK, 'ACME²³§')).toBe('ACME')
expect(apply(FREE_TEXT_MASK, 'Te~#|st<>{}')).toBe('Test')
})
})
describe('ADDRESS_MASK', () => {
it('garde chiffres, virgule, point, apostrophe, slash, degre, tiret', () => {
expect(apply(ADDRESS_MASK, '12 bis, rue de l’Église')).toBe('12 bis, rue de l’Église')
expect(apply(ADDRESS_MASK, 'Bât. n°3 - Zone A/B')).toBe('Bât. n°3 - Zone A/B')
})
it('retire les parasites', () => {
expect(apply(ADDRESS_MASK, '5 rue X²³§&')).toBe('5 rue X')
})
})
describe('CODE_ALNUM_MASK', () => {
it('force la majuscule et ne garde que A-Z 0-9', () => {
expect(apply(CODE_ALNUM_MASK, '411dupont')).toBe('411DUPONT')
expect(apply(CODE_ALNUM_MASK, 'FR 12 345')).toBe('FR12345')
expect(apply(CODE_ALNUM_MASK, '4-11.000§')).toBe('411000')
})
it('chaine vide reste vide', () => {
expect(apply(CODE_ALNUM_MASK, '')).toBe('')
})
})
+13 -1
View File
@@ -33,6 +33,12 @@ export interface RemoveCollectionRowOptions<T extends DeletableRow> {
makeEmpty: () => T makeEmpty: () => T
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */ /** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
onError: (error: unknown) => void onError: (error: unknown) => void
/**
* Callback de succes (toast) appele UNIQUEMENT apres une suppression serveur
* confirmee d'un bloc persiste (`id` non null). Pas appele sur le simple retrait
* d'un brouillon local non enregistre (aucune suppression reelle).
*/
onSuccess?: () => void
} }
/** /**
@@ -55,8 +61,9 @@ export interface RemoveCollectionRowOptions<T extends DeletableRow> {
export async function removeCollectionRow<T extends DeletableRow>( export async function removeCollectionRow<T extends DeletableRow>(
options: RemoveCollectionRowOptions<T>, options: RemoveCollectionRowOptions<T>,
): Promise<boolean> { ): Promise<boolean> {
const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError } = options const { rows, errors, index, endpoint, deleteRow, makeEmpty, onError, onSuccess } = options
const removed = rows[index] const removed = rows[index]
let serverDeleted = false
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK. // Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
if (removed?.id != null) { if (removed?.id != null) {
@@ -67,6 +74,7 @@ export async function removeCollectionRow<T extends DeletableRow>(
onError(error) onError(error)
return false return false
} }
serverDeleted = true
} }
rows.splice(index, 1) rows.splice(index, 1)
@@ -75,5 +83,9 @@ export async function removeCollectionRow<T extends DeletableRow>(
if (rows.length === 0) { if (rows.length === 0) {
rows.push(makeEmpty()) rows.push(makeEmpty())
} }
// Toast de succes uniquement quand le serveur a confirme une vraie suppression.
if (serverDeleted) {
onSuccess?.()
}
return true return true
} }
+17
View File
@@ -0,0 +1,17 @@
/**
* Helpers de date purs / testables (partages inter-modules).
*/
/**
* Date du jour au format ISO `YYYY-MM-DD` en heure LOCALE.
*
* On NE passe PAS par `toISOString()` (UTC) : pres de minuit, le decalage de
* fuseau (FR = UTC+1/+2) renverrait la veille ou le lendemain. On lit donc les
* composantes locales. Parametre `now` injectable pour les tests.
*/
export function todayIso(now: Date = new Date()): string {
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Masks de saisie texte (retour metier ERP-193) : filtrage NATIF (maska) des
* caracteres parasites (« ²³§~#| ») dans les champs texte libres. maska gere le
* focus et le curseur (contrairement a un nettoyage manuel sur @update qui laissait
* le caractere affiche jusqu'a la frappe suivante).
*
* Miroir FRONT des patterns back `App\Shared\Domain\Validation\TextInputPattern`
* (allow-list par famille de champ). Le back reste l'autorite (Assert\Regex
* 422 inline via useFormErrors) ; ces masks ne font que le confort de saisie.
*
* IMPORTANT : garder les classes de caracteres STRICTEMENT alignees sur le back.
*
* L'EMAIL n'a PAS de mask (decision ERP-101 : un email n'a pas de structure fixe,
* on valide le FORMAT via Assert\Email + erreur inline, jamais via un masque).
*/
import type { MaskInputOptions } from 'maska'
/**
* Construit un mask maska « jeu de caracteres autorise, longueur libre » :
* - `preProcess` retire d'abord TOUT caractere hors charset, OU QU'IL SOIT (un
* masque positionnel seul s'arreterait au 1er caractere invalide car le token
* `multiple` est glouton) ;
* - le token `P` (`multiple`) laisse ensuite passer le reste, sans limite de longueur.
*
* @param pattern classe des caracteres AUTORISES (1 caractere, sans flag global)
* @param strip negation de `pattern`, flag global (retire les interdits)
* @param upper force la majuscule (codes : n° compte / TVA / IBAN / BIC)
*/
function charsetMask(pattern: RegExp, strip: RegExp, upper = false): MaskInputOptions {
return {
mask: 'P',
tokens: { P: { pattern, multiple: true } },
preProcess: (v: string) => (upper ? v.toUpperCase() : v).replace(strip, ''),
}
}
/** Noms de personnes (Nom, Prenom, Dirigeant) : lettres (accents), espace, apostrophe, tiret, point. */
export const PERSON_NAME_MASK = charsetMask(/[\p{L}\p{M} '.-]/u, /[^\p{L}\p{M} '.-]/gu)
/** Texte societe / libre (Raison sociale, Concurrents, Fonction) : + chiffres, virgule, &, /, parentheses, degre. */
export const FREE_TEXT_MASK = charsetMask(/[\p{L}\p{M}0-9 '.,&/()°-]/u, /[^\p{L}\p{M}0-9 '.,&/()°-]/gu)
/** Adresse (voie, complement, ville) : lettres, chiffres, espace, apostrophe, point, virgule, slash, degre, tiret. */
export const ADDRESS_MASK = charsetMask(/[\p{L}\p{M}0-9 '.,/°-]/u, /[^\p{L}\p{M}0-9 '.,/°-]/gu)
/** Codes alphanumeriques majuscules (N° de compte, N° de TVA, IBAN, BIC) : A-Z et 0-9, majuscule forcee. */
export const CODE_ALNUM_MASK = charsetMask(/[A-Z0-9]/, /[^A-Z0-9]/g, true)
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ERP-193 (retour metier) l'onglet Contact d'un transporteur n'est plus
* obligatoire : suppression de la garde « prenom OU nom » (ex RG-4.08). Drop du
* CHECK chk_carrier_contact_name et mise a jour des commentaires de colonnes. La
* garde applicative (CarrierContactProcessor::validateName) est retiree dans le
* meme commit ; le catalogue ColumnCommentsCatalog aussi.
*
* Placee au namespace racine DoctrineMigrations (et non en modulaire Transport) :
* elle ALTERE une table creee par une migration racine (Version20260615150000),
* comme la migration qui avait introduit le CHECK (Version20260617120000) ; le tri
* par version au sein du meme namespace garantit qu'elle joue APRES (cf. CLAUDE.md
* regle 11 le tri cross-namespace casserait l'ordre sur base vide).
*/
final class Version20260619120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'ERP-193 : onglet Contact transporteur non obligatoire — drop du CHECK chk_carrier_contact_name.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE carrier_contact DROP CONSTRAINT chk_carrier_contact_name');
$this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Bloc optionnel (ERP-193) ; max 2 telephones.$_$');
$this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Optionnel (ERP-193).$_$');
$this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Optionnel (ERP-193).$_$');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE carrier_contact ADD CONSTRAINT chk_carrier_contact_name CHECK (first_name IS NOT NULL OR last_name IS NOT NULL)');
$this->addSql('COMMENT ON TABLE carrier_contact IS $_$Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), max 2 telephones.$_$');
$this->addSql('COMMENT ON COLUMN carrier_contact.first_name IS $_$Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).$_$');
$this->addSql('COMMENT ON COLUMN carrier_contact.last_name IS $_$Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).$_$');
}
}
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -162,6 +163,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 180)] #[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom de l\'entreprise doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom de l\'entreprise ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 180, minMessage: 'Le nom de l\'entreprise doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom de l\'entreprise ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['client:read', 'client:write:main'])] #[Groups(['client:read', 'client:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
@@ -214,11 +216,14 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?string $competitors = null; private ?string $competitors = null;
#[ORM\Column(type: 'date_immutable', nullable: true)] #[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
// Date de creation jamais dans le futur (retour metier ERP-193) : <= aujourd'hui.
#[Assert\LessThanOrEqual(value: 'today', message: 'La date de création ne peut pas être dans le futur.')]
// Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans // Format d'ENTREE strict ISO `Y-m-d` (le `!` remet l'heure a 00:00:00). Sans
// ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que // ce format, PHP DateTime accepte des formes ambigues : « 12/25/2026 » (que
// le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en // le front MalioDate juge invalide en JJ/MM/AAAA) serait sinon interprete en
@@ -233,12 +238,16 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?int $employeesCount = null; private ?int $employeesCount = null;
// Chiffre d'affaires plafonne a 999 999 999 999,99 (12 chiffres, retour metier
// ERP-193). La colonne (decimal 15,2) tolere plus, mais le metier borne la saisie.
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
#[Assert\LessThanOrEqual(value: 999_999_999_999.99, message: 'Le chiffre d\'affaires ne peut pas dépasser 999 999 999 999,99.')]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?string $revenueAmount = null; private ?string $revenueAmount = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['client:read', 'client:write:information'])] #[Groups(['client:read', 'client:write:information'])]
private ?string $directorName = null; private ?string $directorName = null;
@@ -257,6 +266,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['client:read:accounting', 'client:write:accounting'])] #[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $accountNumber = null; private ?string $accountNumber = null;
@@ -267,6 +277,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['client:read:accounting', 'client:write:accounting'])] #[Groups(['client:read:accounting', 'client:write:accounting'])]
private ?string $nTva = null; private ?string $nTva = null;
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\ClientAddressInterface;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -158,17 +159,20 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $city = null; private ?string $city = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $street = null; private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['client_address:read', 'client_address:write'])] #[Groups(['client_address:read', 'client_address:write'])]
private ?string $streetComplement = null; private ?string $streetComplement = null;
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@@ -94,16 +95,19 @@ class ClientContact implements TimestampableInterface, BlamableInterface
// deux restent nullable au niveau ORM. // deux restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['client_contact:read', 'client_contact:write'])] #[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $firstName = null; private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['client_contact:read', 'client_contact:write'])] #[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $lastName = null; private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['client_contact:read', 'client_contact:write'])] #[Groups(['client_contact:read', 'client_contact:write'])]
private ?string $jobTitle = null; private ?string $jobTitle = null;
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierInterface; use App\Shared\Domain\Contract\SupplierInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -171,6 +172,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 180)] #[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le nom du fournisseur est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du fournisseur doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du fournisseur ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du fournisseur doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du fournisseur ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['supplier:read', 'supplier:write:main'])] #[Groups(['supplier:read', 'supplier:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
@@ -195,11 +197,14 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'La liste des concurrents ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $competitors = null; private ?string $competitors = null;
#[ORM\Column(type: 'date_immutable', nullable: true)] #[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
// Date de creation jamais dans le futur (retour metier ERP-193) : <= aujourd'hui.
#[Assert\LessThanOrEqual(value: 'today', message: 'La date de création ne peut pas être dans le futur.')]
// Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des // Format d'ENTREE strict ISO `Y-m-d` : sans lui, PHP DateTime accepte des
// formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA, // formes ambigues (« 12/25/2026 », jugee invalide par MalioDate en JJ/MM/AAAA,
// serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute // serait lue en M/J -> 25 decembre et acceptee a tort). Avec le format, toute
@@ -212,12 +217,16 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
private ?int $employeesCount = null; private ?int $employeesCount = null;
// Chiffre d'affaires plafonne a 999 999 999 999,99 (12 chiffres, retour metier
// ERP-193). La colonne (decimal 15,2) tolere plus, mais le metier borne la saisie.
#[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)] #[ORM\Column(type: 'decimal', precision: 15, scale: 2, nullable: true)]
#[Assert\LessThanOrEqual(value: 999_999_999_999.99, message: 'Le chiffre d\'affaires ne peut pas dépasser 999 999 999 999,99.')]
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $revenueAmount = null; private ?string $revenueAmount = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['supplier:read', 'supplier:write:information'])] #[Groups(['supplier:read', 'supplier:write:information'])]
private ?string $directorName = null; private ?string $directorName = null;
@@ -243,6 +252,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
private ?string $accountNumber = null; private ?string $accountNumber = null;
@@ -253,6 +263,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])] #[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
private ?string $nTva = null; private ?string $nTva = null;
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\SupplierAddressInterface; use App\Shared\Domain\Contract\SupplierAddressInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -154,17 +155,20 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])] #[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $city = null; private ?string $city = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])] #[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $street = null; private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])] #[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
private ?string $streetComplement = null; private ?string $streetComplement = null;
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@@ -99,16 +100,19 @@ class SupplierContact implements TimestampableInterface, BlamableInterface
// deux restent nullable au niveau ORM. // deux restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:contacts'])] #[Groups(['supplier:item:read', 'supplier:write:contacts'])]
private ?string $firstName = null; private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:contacts'])] #[Groups(['supplier:item:read', 'supplier:write:contacts'])]
private ?string $lastName = null; private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['supplier:item:read', 'supplier:write:contacts'])] #[Groups(['supplier:item:read', 'supplier:write:contacts'])]
private ?string $jobTitle = null; private ?string $jobTitle = null;
@@ -22,6 +22,7 @@ use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -156,6 +157,7 @@ class Provider implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 180)] #[ORM\Column(length: 180)]
#[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'Le nom du prestataire est obligatoire.', normalizer: 'trim')]
#[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du prestataire doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du prestataire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(min: 2, max: 180, minMessage: 'Le nom du prestataire doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom du prestataire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['provider:read', 'provider:write:main'])] #[Groups(['provider:read', 'provider:write:main'])]
private ?string $companyName = null; private ?string $companyName = null;
@@ -200,6 +202,7 @@ class Provider implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])] #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $accountNumber = null; private ?string $accountNumber = null;
@@ -210,6 +213,7 @@ class Provider implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 40, nullable: true)] #[ORM\Column(length: 40, nullable: true)]
#[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
#[Groups(['provider:read:accounting', 'provider:write:accounting'])] #[Groups(['provider:read:accounting', 'provider:write:accounting'])]
private ?string $nTva = null; private ?string $nTva = null;
@@ -18,6 +18,7 @@ use App\Shared\Domain\Contract\CategoryInterface;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -135,17 +136,20 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:addresses'])] #[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $city = null; private ?string $city = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')] #[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:addresses'])] #[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $street = null; private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:addresses'])] #[Groups(['provider:item:read', 'provider:write:addresses'])]
private ?string $streetComplement = null; private ?string $streetComplement = null;
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@@ -102,16 +103,19 @@ class ProviderContact implements TimestampableInterface, BlamableInterface, Prov
// champs restent nullable au niveau ORM. // champs restent nullable au niveau ORM.
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:contacts'])] #[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $firstName = null; private ?string $firstName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:contacts'])] #[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $lastName = null; private ?string $lastName = null;
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['provider:item:read', 'provider:write:contacts'])] #[Groups(['provider:item:read', 'provider:write:contacts'])]
private ?string $jobTitle = null; private ?string $jobTitle = null;
@@ -17,6 +17,7 @@ use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Entity\UploadedDocument; use App\Shared\Domain\Entity\UploadedDocument;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -145,6 +146,7 @@ class Carrier implements TimestampableInterface, BlamableInterface
maxMessage: 'Le nom du transporteur ne peut dépasser {{ limit }} caractères.', maxMessage: 'Le nom du transporteur ne peut dépasser {{ limit }} caractères.',
normalizer: 'trim', normalizer: 'trim',
)] )]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['carrier:read', 'carrier:write:main'])] #[Groups(['carrier:read', 'carrier:write:main'])]
private ?string $name = null; private ?string $name = null;
@@ -15,6 +15,7 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@@ -123,16 +124,19 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 120, nullable: true)] #[ORM\Column(length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])] #[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $city = null; private ?string $city = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'L\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])] #[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $street = null; private ?string $street = null;
#[ORM\Column(name: 'street_complement', length: 255, nullable: true)] #[ORM\Column(name: 'street_complement', length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:addresses'])] #[Groups(['carrier:item:read', 'carrier:write:addresses'])]
private ?string $streetComplement = null; private ?string $streetComplement = null;
@@ -15,14 +15,15 @@ use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait; use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Domain\Validation\TextInputPattern;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/** /**
* Contact d'un transporteur (1:n) onglet Contact (M4). Jumeau de * Contact d'un transporteur (1:n) onglet Contact (M4). ERP-193 (retour metier) :
* SupplierContact (M2) : au moins le prenom OU le nom (RG-4.08, garanti par le * le bloc Contact est OPTIONNEL la garde « prenom OU nom » (ex RG-4.08) est
* CHECK chk_carrier_contact_name + le Processor), max 2 telephones. * retiree. Reste applicable : max 2 telephones.
* *
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du * Lecture : proprietes en `carrier:item:read` (embarquees au detail du
* transporteur). Ecriture : groupe `carrier:write:contacts`. * transporteur). Ecriture : groupe `carrier:write:contacts`.
@@ -102,16 +103,19 @@ class CarrierContact implements TimestampableInterface, BlamableInterface
// Processor + CHECK BDD). Les colonnes restent nullable au niveau ORM. // Processor + CHECK BDD). Les colonnes restent nullable au niveau ORM.
#[ORM\Column(name: 'first_name', length: 120, nullable: true)] #[ORM\Column(name: 'first_name', length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])] #[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $firstName = null; private ?string $firstName = null;
#[ORM\Column(name: 'last_name', length: 120, nullable: true)] #[ORM\Column(name: 'last_name', length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])] #[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $lastName = null; private ?string $lastName = null;
#[ORM\Column(name: 'job_title', length: 120, nullable: true)] #[ORM\Column(name: 'job_title', length: 120, nullable: true)]
#[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')]
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
#[Groups(['carrier:item:read', 'carrier:write:contacts'])] #[Groups(['carrier:item:read', 'carrier:write:contacts'])]
private ?string $jobTitle = null; private ?string $jobTitle = null;
@@ -23,23 +23,19 @@ use function is_string;
/** /**
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4, * Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
* spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le * spec-back § 4.5). Jumeau du SupplierContactProcessor (M2), recentre sur le
* perimetre ERP-160. RG-4.08 (correctif, alignement M1/M2/M3) : un contact exige * perimetre ERP-160. ERP-193 (retour metier) : l'onglet Contact n'est plus
* au moins le PRENOM OU le NOM (la fonction / le telephone / l'email seuls ne * obligatoire la garde « prenom OU nom » (ex RG-4.08) est retiree, un contact
* suffisent pas), porte a la fois par le CHECK BDD chk_carrier_contact_name et par * peut donc etre cree sans nom. Le « max 2 telephones » reste une specificite M4.
* ce Processor ; le « max 2 telephones » reste une specificite M4.
* *
* Sequence : * Sequence :
* - POST / PATCH : rattachement au transporteur parent (linkParent), * - POST / PATCH : rattachement au transporteur parent (linkParent),
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase), * normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
* mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary * mapping du tableau d'ecriture `phones` -> phonePrimary/phoneSecondary
* (max 2, chiffres uniquement), puis garde « prenom OU nom » avant persistance. * (max 2, chiffres uniquement) avant persistance.
* - DELETE : aucune regle metier specifique (suppression physique directe). * - DELETE : aucune regle metier specifique (suppression physique directe).
* *
* La garde « prenom OU nom » vit ICI (double du CHECK BDD) pour transformer une * Le « max 2 telephones » est rattache au champ `phones` : seul point de saisie
* violation SQL (500 generique) en 422 propre rattachee au champ `firstName` * des numeros (les colonnes phonePrimary/phoneSecondary sont en lecture seule).
* (mapping inline ERP-101). Le « max 2 telephones » est rattache au champ `phones` : seul
* point de saisie des numeros (les colonnes phonePrimary/phoneSecondary sont en
* lecture seule).
* *
* La security d'operation (transport.carriers.manage) est appliquee par API * La security d'operation (transport.carriers.manage) est appliquee par API
* Platform en amont, de meme que la validation Symfony des contraintes d'attribut * Platform en amont, de meme que la validation Symfony des contraintes d'attribut
@@ -77,7 +73,6 @@ final class CarrierContactProcessor implements ProcessorInterface
$this->linkParent($data, $uriVariables); $this->linkParent($data, $uriVariables);
$this->normalize($data); $this->normalize($data);
$this->applyPhones($data); $this->applyPhones($data);
$this->validateName($data);
return $this->persistProcessor->process($data, $operation, $uriVariables, $context); return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
} }
@@ -115,9 +110,8 @@ final class CarrierContactProcessor implements ProcessorInterface
/** /**
* Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du * Normalisation serveur RG-4.13 des champs texte. Toutes les methodes du
* normalizer sont null-safe : une chaine vide apres trim devient null (donc la * normalizer sont null-safe : une chaine vide apres trim devient null. Les
* garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont * telephones sont traites a part (applyPhones).
* traites a part (applyPhones).
*/ */
private function normalize(CarrierContact $contact): void private function normalize(CarrierContact $contact): void
{ {
@@ -186,30 +180,6 @@ final class CarrierContactProcessor implements ProcessorInterface
$contact->setPhones(null); $contact->setPhones(null);
} }
/**
* RG-4.08 (alignement M1/M2/M3) : un bloc Contact exige au moins le PRENOM OU le
* NOM un contact se materialise par son nom ; fonction / telephone / email
* seuls ne suffisent pas. Double garde avec le CHECK BDD chk_carrier_contact_name
* leve une 422 propre rattachee a `firstName` plutot qu'une 500 SQL. Joue apres
* normalisation + mapping telephones, donc les chaines vides sont deja null.
*/
private function validateName(CarrierContact $contact): void
{
if (null === $contact->getFirstName() && null === $contact->getLastName()) {
$violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation(
'Le prénom ou le nom du contact est obligatoire.',
null,
[],
$contact,
'firstName',
null,
));
throw new ValidationException($violations);
}
}
/** /**
* Trim + chaine vide -> null (la fonction n'est pas normalisee en casse, * Trim + chaine vide -> null (la fonction n'est pas normalisee en casse,
* contrairement aux noms de personne). Evite de persister une chaine vide * contrairement aux noms de personne). Evite de persister une chaine vide
@@ -195,7 +195,8 @@ class CarrierFixtures extends Fixture implements DependentFixtureInterface
/** /**
* Ajoute un contact normalise au transporteur (cascade persist via * Ajoute un contact normalise au transporteur (cascade persist via
* Carrier.contacts). Prenom OU nom toujours fourni (RG-4.08, chk_carrier_contact_name). * Carrier.contacts). Le bloc Contact est optionnel (ERP-193) ; les fixtures
* fournissent neanmoins un nom pour des donnees de demonstration realistes.
*/ */
private function addContact( private function addContact(
Carrier $carrier, Carrier $carrier,
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Validation;
/**
* Profils de caracteres autorises pour les champs texte libres (retour metier
* ERP-193 : bloquer les caracteres parasites « ²³§~#| … » sans casser les saisies
* legitimes accents, apostrophe, tiret, &, etc.).
*
* Approche allow-list (pas blacklist) : on definit ce qui est AUTORISE par famille
* de champ, le reste est rejete. Couche AUTORITAIRE (back) : `#[Assert\Regex]` avec
* ces patterns et messages FR ; le front (shared/utils/textSanitize.ts) miroite ces
* memes ensembles en filtrant la saisie a la frappe.
*
* Note : `Assert\Regex` laisse passer null et la chaine vide (champs nullable OK) ;
* seules les valeurs non vides sont controlees.
*/
final class TextInputPattern
{
/**
* Noms de personnes (Nom, Prenom, Dirigeant) : lettres (accents inclus),
* espace, apostrophe droite/courbe, tiret, point. Ni chiffres ni symboles.
*/
public const string PERSON_NAME = '/^[\p{L}\p{M} \'.\-]+$/u';
public const string PERSON_NAME_MESSAGE = 'Ce champ ne peut contenir que des lettres, espaces, apostrophes, tirets et points.';
/**
* Texte societe / libre (Raison sociale, Concurrents, Fonction) : comme un nom
* + chiffres, virgule, esperluette, slash, parentheses, degre (). Couvre
* « Dupont & Fils », « Achats/Ventes », « Pole 2 ».
*/
// 0-9 (et pas \p{N}) : \p{N} engloberait les exposants ² ³ — justement parasites.
public const string FREE_TEXT = '/^[\p{L}\p{M}0-9 \'.,&\/()°\-]+$/u';
public const string FREE_TEXT_MESSAGE = 'Ce champ contient des caractères non autorisés.';
/**
* Adresse (voie, complement, ville) : lettres, chiffres, espace, apostrophe,
* point, virgule, slash, degre, tiret. Couvre « 12 bis, rue de l’Église ».
*/
public const string ADDRESS = '/^[\p{L}\p{M}0-9 \'.,\/°\-]+$/u';
public const string ADDRESS_MESSAGE = 'Cette adresse contient des caractères non autorisés.';
/**
* Codes alphanumeriques majuscules ( de compte comptable, de TVA) :
* uniquement A-Z et 0-9. Le front force la majuscule a la frappe.
*/
public const string CODE_ALNUM = '/^[A-Z0-9]+$/';
public const string CODE_ALNUM_MESSAGE = 'Ce champ ne doit contenir que des lettres majuscules et des chiffres.';
}
@@ -509,11 +509,11 @@ final class ColumnCommentsCatalog
] + self::timestampableBlamableComments(), ] + self::timestampableBlamableComments(),
'carrier_contact' => [ 'carrier_contact' => [
'_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Au moins le prenom OU le nom rempli (RG-4.08, chk_carrier_contact_name), max 2 telephones.', '_table' => 'Contacts d un transporteur (1:n) — onglet Contact (M4). Bloc optionnel (ERP-193) ; max 2 telephones.',
'id' => 'Identifiant interne auto-incremente.', 'id' => 'Identifiant interne auto-incremente.',
'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.', 'carrier_id' => 'FK -> carrier.id, ON DELETE CASCADE — transporteur proprietaire du contact.',
'first_name' => 'Prenom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).', 'first_name' => 'Prenom du contact (capitalise serveur). Optionnel (ERP-193).',
'last_name' => 'Nom du contact (capitalise serveur). Prenom OU nom obligatoire (RG-4.08, chk_carrier_contact_name).', 'last_name' => 'Nom du contact (capitalise serveur). Optionnel (ERP-193).',
'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).', 'job_title' => 'Fonction / intitule de poste du contact (≤ 120 caracteres).',
'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).', 'phone_primary' => 'Telephone principal — chiffres uniquement (normalisation serveur).',
'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).', 'phone_secondary' => 'Telephone secondaire — chiffres uniquement (max 2 telephones, RG-4.08).',
@@ -99,6 +99,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
Assert\Positive::class, Assert\Positive::class,
Assert\NegativeOrZero::class, Assert\NegativeOrZero::class,
Assert\Negative::class, Assert\Negative::class,
Assert\LessThanOrEqual::class,
]; ];
public function testEveryConstraintHasAnExplicitFrenchMessage(): void public function testEveryConstraintHasAnExplicitFrenchMessage(): void
@@ -306,7 +307,9 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
Assert\Length::class => new Assert\Length(max: 1), Assert\Length::class => new Assert\Length(max: 1),
Assert\Count::class => new Assert\Count(min: 1), Assert\Count::class => new Assert\Count(min: 1),
Assert\Regex::class => new Assert\Regex(pattern: '/^x$/'), Assert\Regex::class => new Assert\Regex(pattern: '/^x$/'),
default => new $class(), // AbstractComparison exige value|propertyPath des l'instanciation.
Assert\LessThanOrEqual::class => new Assert\LessThanOrEqual(value: 0),
default => new $class(),
}; };
$value = $bare->{$prop} ?? null; $value = $bare->{$prop} ?? null;
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use DateTimeImmutable;
/**
* Validation back-autoritative de la date de creation (foundedAt) sur Client ET
* Fournisseur retour metier ERP-193 : une date dans le futur est refusee.
*
* Le front (MalioDate `:max`) plafonne deja le calendrier a aujourd'hui, mais le
* back reste la couche autoritaire : `Assert\LessThanOrEqual('today')` rejette une
* date future (ISO valide) avec une 422 portee sur `foundedAt` (mappable inline par
* useFormErrors). Une date passee ou egale a aujourd'hui reste acceptee.
*
* @internal
*/
final class FoundedAtFutureTest extends AbstractSupplierApiTestCase
{
/** Client : date de creation future -> 422 portee sur foundedAt. */
public function testClientFoundedAtFuturEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Future SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => $this->futureDate()],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/** Client : date de creation passee -> acceptee (200). */
public function testClientFoundedAtPasseEst200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Founded Past SARL');
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '2000-06-15'],
]);
self::assertResponseStatusCodeSame(200);
}
/** Fournisseur : date de creation future -> 422 portee sur foundedAt. */
public function testSupplierFoundedAtFuturEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Founded Future Fournisseur SARL');
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => $this->futureDate()],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('foundedAt', $this->violationsByPath($body));
}
/** Fournisseur : date de creation passee -> acceptee (200). */
public function testSupplierFoundedAtPasseEst200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Founded Past Fournisseur SARL');
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['foundedAt' => '2000-06-15'],
]);
self::assertResponseStatusCodeSame(200);
}
/** Date ISO clairement dans le futur. */
private function futureDate(): string
{
return new DateTimeImmutable('+1 year')->format('Y-m-d');
}
}
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Validation back-autoritative du plafond du chiffre d'affaires (revenueAmount,
* onglet Information) sur Client ET Fournisseur retour metier ERP-193.
*
* Le CA est plafonne a 999 999 999 999,99 (12 chiffres). La colonne decimal(15,2)
* tolererait plus, mais le metier borne la saisie : au-dela, 422 porte sur
* `revenueAmount` (mappable inline par useFormErrors). La valeur exactement egale
* au plafond reste acceptee. Le front clampe deja la saisie (amountInput.ts), mais
* le back reste la couche autoritaire.
*
* @internal
*/
final class RevenueAmountCapTest extends AbstractSupplierApiTestCase
{
/** Plafond metier : 12 chiffres + 2 decimales. */
private const string MAX = '999999999999.99';
/** Client : CA au-dela du plafond -> 422 porte sur revenueAmount. */
public function testClientRevenueAmountAuDelaDuPlafondEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('CA Cap Client SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['revenueAmount' => '1000000000000.00'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('revenueAmount', $this->violationsByPath($body));
}
/** Client : CA exactement au plafond -> accepte (200). */
public function testClientRevenueAmountAuPlafondEst200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('CA Max Client SARL');
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['revenueAmount' => self::MAX],
]);
self::assertResponseStatusCodeSame(200);
}
/** Fournisseur : CA au-dela du plafond -> 422 porte sur revenueAmount. */
public function testSupplierRevenueAmountAuDelaDuPlafondEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('CA Cap Fournisseur SARL');
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['revenueAmount' => '1000000000000.00'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('revenueAmount', $this->violationsByPath($body));
}
/** Fournisseur : CA exactement au plafond -> accepte (200). */
public function testSupplierRevenueAmountAuPlafondEst200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('CA Max Fournisseur SARL');
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['revenueAmount' => self::MAX],
]);
self::assertResponseStatusCodeSame(200);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
/**
* Validation back-autoritative des caracteres autorises dans les champs texte
* (retour metier ERP-193) : on rejette les caracteres parasites « ²³§~#| … » via
* une allow-list par profil (App\Shared\Domain\Validation\TextInputPattern). Le
* front filtre deja a la frappe, mais le back reste l'autorite : une 422 portee
* sur le champ fautif (mappable inline par useFormErrors).
*
* On couvre les clients (M1) et les fournisseurs (M2) meme socle de profils.
*
* @internal
*/
final class TextInputSanitizationTest extends AbstractSupplierApiTestCase
{
/** Raison sociale avec exposants ²³ et § -> 422 sur companyName. */
public function testClientCompanyNameAvecParasitesEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Parasite Client SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'ACME²³§'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('companyName', $this->violationsByPath($body));
}
/** Raison sociale legitime « Dupont & Fils » (esperluette) -> acceptee (200). */
public function testClientCompanyNameLegitimeEst200(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Legit Client SARL');
$client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'Dupont & Fils (Pôle n°2)'],
]);
self::assertResponseStatusCodeSame(200);
}
/** Dirigeant avec chiffres -> 422 (profil nom de personne, pas de chiffres). */
public function testClientDirectorNameAvecChiffresEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Director Parasite SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['directorName' => 'Jean123'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('directorName', $this->violationsByPath($body));
}
/** N° de compte avec caractere special -> 422 (profil code alphanumerique). */
public function testClientAccountNumberAvecParasiteEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Account Parasite SARL');
$body = $client->request('PATCH', '/api/clients/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['accountNumber' => '411#DUP'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('accountNumber', $this->violationsByPath($body));
}
/** Fournisseur : raison sociale avec parasites -> 422 sur companyName. */
public function testSupplierCompanyNameAvecParasitesEst422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedSupplier('Parasite Fournisseur SARL');
$body = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['companyName' => 'NEGOCE~#|²'],
])->toArray(false);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('companyName', $this->violationsByPath($body));
}
}
@@ -15,8 +15,8 @@ use Symfony\Component\Console\Output\NullOutput;
* POST /api/carriers/{id}/contacts, PATCH/DELETE /api/carrier_contacts/{id}. * POST /api/carriers/{id}/contacts, PATCH/DELETE /api/carrier_contacts/{id}.
* *
* Contrat verifie : * Contrat verifie :
* - RG-4.08 : contact sans prenom ni nom -> 422 (alignement M1/M2/M3) ; * - ERP-193 : contact sans prenom ni nom -> 201 (bloc Contact optionnel) ;
* - RG-4.08 : un nom (ou prenom) suffit -> 201 ; * - un nom (ou prenom) seul suffit -> 201 ;
* - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ; * - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ;
* - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ; * - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ;
* - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul). * - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul).
@@ -49,25 +49,25 @@ final class CarrierContactApiTest extends AbstractCarrierApiTestCase
self::ensureKernelShutdown(); self::ensureKernelShutdown();
} }
public function testEmptyContactReturns422(): void public function testContactWithoutNameIsCreated(): void
{ {
// RG-4.08 (alignement M1/M2/M3) : sans prenom ni nom -> 422 (garde Processor, // ERP-193 (retour metier) : l'onglet Contact n'est plus obligatoire et la
// double du CHECK BDD chk_carrier_contact_name). // garde « prenom OU nom » (ex RG-4.08) est retiree -> un contact sans nom
$carrier = $this->seedCarrier('Contact Vide'); // (ici un simple telephone) est desormais accepte (201).
$carrier = $this->seedCarrier('Contact Sans Nom');
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD], 'headers' => ['Content-Type' => self::LD],
'json' => [], 'json' => ['phones' => ['0611111111']],
]); ]);
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(201);
// La violation est rattachee a `firstName` (mapping inline ERP-101). self::assertJsonContains(['phonePrimary' => '0611111111']);
self::assertViolationOnPath($response, 'firstName');
} }
public function testSingleFieldContactIsCreated(): void public function testSingleFieldContactIsCreated(): void
{ {
// RG-4.08 : un nom (ou prenom) suffit a valider le bloc. // Un nom (ou prenom) seul suffit a creer un contact.
$carrier = $this->seedCarrier('Contact Mono'); $carrier = $this->seedCarrier('Contact Mono');
$client = $this->createAdminClient(); $client = $this->createAdminClient();