Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dcc247436 | |||
| b438838465 | |||
| 9f3fe4da4e | |||
| ef7bf69980 | |||
| 117dcdbdcc | |||
| a4158d4e37 | |||
| 5e15c1f69f |
+21
-20
@@ -38,7 +38,27 @@ declare(strict_types=1);
|
||||
*/
|
||||
|
||||
return [
|
||||
// Section "Commerciale" : pole metier principal, remontee en tete de sidebar (ERP-71).
|
||||
// Section "Logistique" (M5, ERP-181) : nouveau pole "operations physiques sur
|
||||
// site", distinct du repertoire Transport (M4, desormais rattache a la section
|
||||
// Administration cote develop). Porte le ticket de pesee au pont bascule.
|
||||
// Placee en tete de sidebar (avant Commerciale). L'item est gate par
|
||||
// `logistique.weighing_tickets.view` ; la section disparait automatiquement
|
||||
// (SidebarProvider) si le module `logistique` est desactive ou si l'user n'a
|
||||
// pas la permission (Compta / Commerciale).
|
||||
[
|
||||
'label' => 'sidebar.logistique.section',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.logistique.weighing_tickets',
|
||||
'to' => '/weighing-tickets',
|
||||
'icon' => 'mdi:truck-outline',
|
||||
'module' => 'logistique',
|
||||
'permission' => 'logistique.weighing_tickets.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Commerciale" : pole metier principal (ERP-71).
|
||||
// L'ordre interne des onglets et les permissions restent inchanges (simple deplacement
|
||||
// du bloc, aucun gate touche).
|
||||
[
|
||||
@@ -78,25 +98,6 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Logistique" (M5, ERP-181) : nouveau pole "operations physiques sur
|
||||
// site", distinct du repertoire Transport (M4, desormais rattache a la section
|
||||
// Administration cote develop). Porte le ticket de pesee au pont bascule.
|
||||
// L'item est gate par `logistique.weighing_tickets.view` ; la section disparait
|
||||
// automatiquement (SidebarProvider) si le module `logistique` est desactive ou
|
||||
// si l'user n'a pas la permission (Compta / Commerciale).
|
||||
[
|
||||
'label' => 'sidebar.logistique.section',
|
||||
'icon' => 'mdi:scale',
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'sidebar.logistique.weighing_tickets',
|
||||
'to' => '/weighing-tickets',
|
||||
'icon' => 'mdi:scale',
|
||||
'module' => 'logistique',
|
||||
'permission' => 'logistique.weighing_tickets.view',
|
||||
],
|
||||
],
|
||||
],
|
||||
// Section "Administration" : regroupe toutes les pages de configuration
|
||||
// applicative (RBAC, users, sites, audit log).
|
||||
//
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.146'
|
||||
app.version: '0.1.147'
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière activité"
|
||||
"lastActivity": "Dernière modification"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
@@ -125,7 +125,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le fournisseur",
|
||||
"back": "Retour au répertoire",
|
||||
"back": "Retour à la consultation",
|
||||
"loading": "Chargement du fournisseur…",
|
||||
"notFound": "Fournisseur introuvable.",
|
||||
"save": "Enregistrer"
|
||||
@@ -215,7 +215,7 @@
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière activité"
|
||||
"lastActivity": "Dernière modification"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
@@ -268,7 +268,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le client",
|
||||
"back": "Retour au répertoire",
|
||||
"back": "Retour à la consultation",
|
||||
"loading": "Chargement du client…",
|
||||
"notFound": "Client introuvable.",
|
||||
"save": "Enregistrer"
|
||||
@@ -385,7 +385,7 @@
|
||||
"companyName": "Nom",
|
||||
"categories": "Catégories",
|
||||
"sites": "Site",
|
||||
"lastActivity": "Dernière activité"
|
||||
"lastActivity": "Dernière modification"
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filtres",
|
||||
@@ -420,7 +420,7 @@
|
||||
},
|
||||
"edit": {
|
||||
"title": "Modifier le prestataire",
|
||||
"back": "Retour à la fiche",
|
||||
"back": "Retour à la consultation",
|
||||
"loading": "Chargement…",
|
||||
"notFound": "Prestataire introuvable.",
|
||||
"save": "Enregistrer"
|
||||
@@ -453,7 +453,6 @@
|
||||
},
|
||||
"address": {
|
||||
"sites": "Sites",
|
||||
"categories": "Catégorie",
|
||||
"contacts": "Contact(s) rattaché(s)",
|
||||
"country": "Pays",
|
||||
"postalCode": "Code postal",
|
||||
@@ -509,7 +508,7 @@
|
||||
"name": "Nom",
|
||||
"certification": "Certification",
|
||||
"validityDate": "Date de validité",
|
||||
"lastActivity": "Dernière activité"
|
||||
"lastActivity": "Dernière modification"
|
||||
},
|
||||
"certification": {
|
||||
"QUALIMAT": "QUALIMAT",
|
||||
@@ -558,8 +557,8 @@
|
||||
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
|
||||
},
|
||||
"price": {
|
||||
"group": "Type de transport",
|
||||
"carrier": "Transporteurs",
|
||||
"group": "Transport",
|
||||
"carrier": "Fournisseurs / Clients",
|
||||
"aproOrSite": "Adresse sites",
|
||||
"delivery": "Adresse livraisons",
|
||||
"forfait": "Forfait (€)",
|
||||
@@ -692,6 +691,72 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"logistique": {
|
||||
"weighingTickets": {
|
||||
"title": "Tickets de pesée",
|
||||
"add": "Ajouter",
|
||||
"export": "Exporter",
|
||||
"empty": "Aucun ticket de pesée pour l'instant.",
|
||||
"column": {
|
||||
"number": "Numéro",
|
||||
"client": "Client",
|
||||
"supplier": "Fournisseur",
|
||||
"other": "Autre",
|
||||
"date": "Date",
|
||||
"weight": "Poids"
|
||||
},
|
||||
"form": {
|
||||
"back": "Retour à la liste",
|
||||
"addTitle": "Ajouter un ticket de pesée",
|
||||
"emptyBlock": "Poids à vide",
|
||||
"fullBlock": "Poids à plein",
|
||||
"number": "Numéro",
|
||||
"site": "Site",
|
||||
"date": "Date",
|
||||
"weight": "Poids (Kg)",
|
||||
"dsd": "DSD",
|
||||
"immatriculation": "Immatriculation",
|
||||
"plateFreeFormat": "Tout format",
|
||||
"save": "Enregistrer",
|
||||
"validate": "Valider",
|
||||
"print": "Imprimer",
|
||||
"counterparty": {
|
||||
"type": "Fournisseur / Client / Autre",
|
||||
"supplier": "Fournisseur",
|
||||
"client": "Client",
|
||||
"other": "Autre"
|
||||
},
|
||||
"weighbridge": {
|
||||
"auto": "Pesée bascule",
|
||||
"manual": "Pesée manuelle",
|
||||
"confirmTitle": "Pesée bascule",
|
||||
"confirmMessage": "Êtes-vous sûr de vouloir déclencher une pesée ?",
|
||||
"cancel": "Annuler",
|
||||
"validate": "Valider",
|
||||
"unavailable": "Pont bascule indisponible — passez en pesée manuelle."
|
||||
},
|
||||
"manual": {
|
||||
"title": "Pesée manuelle",
|
||||
"weight": "Poids (Kg)",
|
||||
"number": "Numéro de pesée",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"weightRequired": "Le poids est obligatoire.",
|
||||
"numberRequired": "Le numéro de pesée est obligatoire."
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"title": "Ticket de pesée {number}",
|
||||
"titleFallback": "Modifier un ticket de pesée",
|
||||
"loading": "Chargement du ticket…",
|
||||
"notFound": "Ticket de pesée introuvable."
|
||||
},
|
||||
"toast": {
|
||||
"error": "Une erreur est survenue. Réessayez.",
|
||||
"exportError": "L'export des tickets de pesée a échoué. Réessayez."
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
"logout": "Deconnexion",
|
||||
@@ -790,7 +855,8 @@
|
||||
"auth": {
|
||||
"logout": "Deconnexion reussie"
|
||||
},
|
||||
"title": "Succès"
|
||||
"title": "Succès",
|
||||
"deleted": "Suppression effectuée"
|
||||
},
|
||||
"admin": {
|
||||
"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)]">
|
||||
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -21,7 +21,8 @@
|
||||
:options="addressTypeOptions"
|
||||
:label="t('commercial.clients.form.address.addressType')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.isProspect"
|
||||
@update:model-value="onAddressTypeChange"
|
||||
/>
|
||||
@@ -33,17 +34,21 @@
|
||||
:label="t('commercial.clients.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif). Consultation : masque si aucun (ERP-193). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.clients.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
@@ -55,8 +60,9 @@
|
||||
v-if="isBillingEmailRequired(model)"
|
||||
:model-value="model.billingEmail"
|
||||
:label="t('commercial.clients.form.address.billingEmail')"
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmail"
|
||||
:addable="!model.hasSecondaryBillingEmail && !readonly"
|
||||
@@ -64,13 +70,17 @@
|
||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||
@add="revealSecondaryBillingEmail"
|
||||
/>
|
||||
<div v-else aria-hidden="true" />
|
||||
<!-- Filler : aligne la suite de la grille (Categorie au debut de ligne).
|
||||
Inutile en consultation masquee (la grille se recompose sans les
|
||||
champs vides, ERP-193). -->
|
||||
<div v-else-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<MalioInputEmail
|
||||
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
|
||||
:model-value="model.billingEmailSecondary"
|
||||
:label="t('commercial.clients.form.address.billingEmailSecondary')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmailSecondary"
|
||||
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
|
||||
@@ -82,7 +92,8 @@
|
||||
:label="t('commercial.clients.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
@@ -92,7 +103,8 @@
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.clients.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
@@ -101,7 +113,8 @@
|
||||
:label="t('commercial.clients.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
@@ -115,17 +128,20 @@
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
@@ -142,14 +158,15 @@
|
||||
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
|
||||
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
|
||||
@@ -162,17 +179,20 @@
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.clients.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
@@ -191,6 +211,8 @@ import {
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
|
||||
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
@@ -209,6 +231,10 @@ const props = defineProps<{
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -284,11 +310,37 @@ const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
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). */
|
||||
function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||
* a chaque frappe).
|
||||
*/
|
||||
function onCityChange(value: string | number | null): void {
|
||||
const next = value === null ? null : String(value)
|
||||
if (next === (props.modelValue.city ?? null)) {
|
||||
return
|
||||
}
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
city: next,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Revele le 2e champ email de facturation (clic sur le « + »). */
|
||||
function revealSecondaryBillingEmail(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
|
||||
@@ -304,9 +356,27 @@ function notifyUnavailable(): void {
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||
|
||||
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||
if (digits.length === 5 && digits !== previousDigits) {
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
postalCode: value,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
else {
|
||||
update('postalCode', value)
|
||||
}
|
||||
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
|
||||
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -13,44 +13,56 @@
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.clients.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.clients.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.clients.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.clients.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('commercial.clients.form.contact.addPhone')"
|
||||
@@ -58,11 +70,12 @@
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.clients.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
@@ -71,6 +84,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
|
||||
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
|
||||
@@ -85,6 +100,10 @@ const props = defineProps<{
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -99,6 +118,10 @@ const { t } = useI18n()
|
||||
// Alias local pour la lisibilite du template.
|
||||
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). */
|
||||
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
|
||||
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)]">
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -18,8 +18,9 @@
|
||||
:options="addressTypeOptions"
|
||||
:label="t('commercial.suppliers.form.address.addressType')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.addressType"
|
||||
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
|
||||
/>
|
||||
@@ -31,24 +32,28 @@
|
||||
:label="t('commercial.suppliers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.suppliers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
|
||||
porte ici l'email de facturation, absent cote fournisseur). -->
|
||||
<div aria-hidden="true" />
|
||||
porte ici l'email de facturation, absent cote fournisseur). Inutile en
|
||||
consultation masquee (la grille se recompose sans les champs vides). -->
|
||||
<div v-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
|
||||
<MalioSelectCheckbox
|
||||
@@ -57,7 +62,8 @@
|
||||
:label="t('commercial.suppliers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
@@ -67,7 +73,8 @@
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.suppliers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
@@ -76,7 +83,8 @@
|
||||
:label="t('commercial.suppliers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
@@ -88,17 +96,20 @@
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
@@ -107,14 +118,15 @@
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.suppliers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
|
||||
@@ -126,40 +138,50 @@
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.suppliers.form.address.street')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.suppliers.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bennes : stepper (specifique fournisseur, defaut 0). -->
|
||||
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0
|
||||
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
|
||||
<MalioInputNumber
|
||||
v-if="!hideEmpty || isFilled(model.bennes)"
|
||||
:model-value="model.bennes"
|
||||
:label="t('commercial.suppliers.form.address.bennes')"
|
||||
:min="0"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.bennes"
|
||||
@update:model-value="(v: string) => update('bennes', v)"
|
||||
/>
|
||||
|
||||
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur). -->
|
||||
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur).
|
||||
Consultation : masquee si non cochee (ERP-193). -->
|
||||
<MalioCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.triageProvider)"
|
||||
id="address-triage-provider"
|
||||
:label="t('commercial.suppliers.form.address.triageProvider')"
|
||||
:model-value="model.triageProvider"
|
||||
group-class="self-center"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: boolean) => update('triageProvider', v)"
|
||||
/>
|
||||
</div>
|
||||
@@ -169,6 +191,8 @@
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
|
||||
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
|
||||
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
@@ -187,6 +211,10 @@ const props = defineProps<{
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -238,11 +266,37 @@ const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
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). */
|
||||
function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||
* a chaque frappe).
|
||||
*/
|
||||
function onCityChange(value: string | number | null): void {
|
||||
const next = value === null ? null : String(value)
|
||||
if (next === (props.modelValue.city ?? null)) {
|
||||
return
|
||||
}
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
city: next,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
@@ -253,9 +307,27 @@ function notifyUnavailable(): void {
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||
|
||||
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||
if (digits.length === 5 && digits !== previousDigits) {
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
postalCode: value,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
else {
|
||||
update('postalCode', value)
|
||||
}
|
||||
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -12,44 +12,56 @@
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.suppliers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.suppliers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.suppliers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.suppliers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
|
||||
@@ -57,11 +69,12 @@
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
@@ -70,6 +83,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
|
||||
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
@@ -83,6 +98,10 @@ const props = defineProps<{
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -97,6 +116,10 @@ const { t } = useI18n()
|
||||
// Alias local pour la lisibilite du template.
|
||||
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). */
|
||||
function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
|
||||
@@ -171,6 +171,182 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Stub MalioInputText emetteur : re-expose `label` et relaie `update:model-value`,
|
||||
* pour piloter le champ Code postal et observer le brouillon emis.
|
||||
*/
|
||||
const MalioInputTextEmitter = defineComponent({
|
||||
name: 'MalioInputTextEmitter',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
label: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': 'addr-input', 'data-label': props.label })
|
||||
},
|
||||
})
|
||||
|
||||
describe('ClientAddressBlock — changement de code postal vide les champs dependants (ERP-193)', () => {
|
||||
beforeEach(() => {
|
||||
searchCityMock.mockReset()
|
||||
searchCityMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
function mountFilled() {
|
||||
return mount(ClientAddressBlock, {
|
||||
props: {
|
||||
modelValue: {
|
||||
...emptyAddress(),
|
||||
postalCode: '75001',
|
||||
city: 'Paris',
|
||||
street: '8 Boulevard du Port',
|
||||
streetComplement: 'Bat A',
|
||||
},
|
||||
title: 'Adresse',
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioCheckbox: true,
|
||||
MalioSelect: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
MalioInputText: MalioInputTextEmitter,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function postalCodeField(wrapper: ReturnType<typeof mountFilled>) {
|
||||
return wrapper.findAllComponents(MalioInputTextEmitter).find(
|
||||
c => c.props('label') === 'commercial.clients.form.address.postalCode',
|
||||
)
|
||||
}
|
||||
|
||||
it('vide ville, adresse et complement quand le CP complet change', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '33000')
|
||||
await flushPromises()
|
||||
|
||||
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||
expect(last.postalCode).toBe('33000')
|
||||
expect(last.city).toBeNull()
|
||||
expect(last.street).toBeNull()
|
||||
expect(last.streetComplement).toBeNull()
|
||||
})
|
||||
|
||||
it('ne vide pas les champs si le CP reste incomplet (< 5 chiffres)', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '7500')
|
||||
await flushPromises()
|
||||
|
||||
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||
expect(last.postalCode).toBe('7500')
|
||||
expect(last.city).toBe('Paris')
|
||||
expect(last.street).toBe('8 Boulevard du Port')
|
||||
expect(last.streetComplement).toBe('Bat A')
|
||||
})
|
||||
|
||||
it('ne vide pas les champs si le CP complet est identique', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '75001')
|
||||
await flushPromises()
|
||||
|
||||
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||
expect(last.city).toBe('Paris')
|
||||
expect(last.street).toBe('8 Boulevard du Port')
|
||||
expect(last.streetComplement).toBe('Bat A')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Stub MalioSelect emetteur : re-expose `label` et relaie `update:model-value`,
|
||||
* pour piloter le select Ville et observer le brouillon emis.
|
||||
*/
|
||||
const MalioSelectEmitter = defineComponent({
|
||||
name: 'MalioSelectEmitter',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
label: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': 'addr-select', 'data-label': props.label })
|
||||
},
|
||||
})
|
||||
|
||||
describe('ClientAddressBlock — changement de ville vide adresse + complement (ERP-193)', () => {
|
||||
beforeEach(() => {
|
||||
searchCityMock.mockReset()
|
||||
searchCityMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
function mountFilled() {
|
||||
return mount(ClientAddressBlock, {
|
||||
props: {
|
||||
modelValue: {
|
||||
...emptyAddress(),
|
||||
postalCode: '75001',
|
||||
city: 'Paris',
|
||||
street: '8 Boulevard du Port',
|
||||
streetComplement: 'Bat A',
|
||||
},
|
||||
title: 'Adresse',
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioCheckbox: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputText: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
MalioSelect: MalioSelectEmitter,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function cityField(wrapper: ReturnType<typeof mountFilled>) {
|
||||
return wrapper.findAllComponents(MalioSelectEmitter).find(
|
||||
c => c.props('label') === 'commercial.clients.form.address.city',
|
||||
)
|
||||
}
|
||||
|
||||
it('vide adresse et complement quand la ville change', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
cityField(wrapper)!.vm.$emit('update:modelValue', 'Lyon')
|
||||
await flushPromises()
|
||||
|
||||
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||
expect(last.city).toBe('Lyon')
|
||||
expect(last.street).toBeNull()
|
||||
expect(last.streetComplement).toBeNull()
|
||||
})
|
||||
|
||||
it('ne vide pas si la ville selectionnee est identique', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
cityField(wrapper)!.vm.$emit('update:modelValue', 'Paris')
|
||||
await flushPromises()
|
||||
|
||||
// Aucun nouvel emit (valeur inchangee) → l'adresse reste intacte.
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
|
||||
beforeEach(() => {
|
||||
searchAddressMock.mockReset()
|
||||
|
||||
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Client> {
|
||||
describe('useClientsRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
|
||||
mockGet.mockResolvedValue(makeHydra(25))
|
||||
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2.
|
||||
mockGet.mockResolvedValue(makeHydra(60))
|
||||
})
|
||||
|
||||
it('cible la ressource /clients en page 1 par defaut', async () => {
|
||||
@@ -35,7 +35,7 @@ describe('useClientsRepository', () => {
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/clients',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
{ page: 1, itemsPerPage: 25 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
@@ -65,7 +65,7 @@ describe('useClientsRepository', () => {
|
||||
'siteId[]': ['1', '2'],
|
||||
archivedOnly: true,
|
||||
page: 1,
|
||||
itemsPerPage: 10,
|
||||
itemsPerPage: 25,
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
@@ -78,7 +78,7 @@ describe('useClientsRepository', () => {
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/clients',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
{ page: 1, itemsPerPage: 25 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Supplier> {
|
||||
describe('useSuppliersRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset()
|
||||
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
|
||||
mockGet.mockResolvedValue(makeHydra(25))
|
||||
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2.
|
||||
mockGet.mockResolvedValue(makeHydra(60))
|
||||
})
|
||||
|
||||
it('cible la ressource /suppliers en page 1 par defaut', async () => {
|
||||
@@ -35,7 +35,7 @@ describe('useSuppliersRepository', () => {
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/suppliers',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
{ page: 1, itemsPerPage: 25 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
@@ -65,7 +65,7 @@ describe('useSuppliersRepository', () => {
|
||||
'siteId[]': ['86', '17'],
|
||||
archivedOnly: true,
|
||||
page: 1,
|
||||
itemsPerPage: 10,
|
||||
itemsPerPage: 25,
|
||||
},
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
@@ -78,7 +78,7 @@ describe('useSuppliersRepository', () => {
|
||||
|
||||
expect(mockGet).toHaveBeenLastCalledWith(
|
||||
'/suppliers',
|
||||
{ page: 1, itemsPerPage: 10 },
|
||||
{ page: 1, itemsPerPage: 25 },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -49,5 +49,6 @@ export interface Client {
|
||||
* gerer.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('commercial.clients.edit.back')"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -25,9 +26,10 @@
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('commercial.clients.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
@@ -35,7 +37,7 @@
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('commercial.clients.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
@@ -46,7 +48,7 @@
|
||||
:options="relationOptions"
|
||||
:label="t('commercial.clients.form.main.relation')"
|
||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
@update:model-value="onRelationChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
@@ -54,7 +56,7 @@
|
||||
:model-value="main.brokerIri"
|
||||
:options="brokerOptions"
|
||||
:label="t('commercial.clients.form.main.brokerName')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.broker"
|
||||
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||
@@ -64,7 +66,7 @@
|
||||
:model-value="main.distributorIri"
|
||||
:options="distributorOptions"
|
||||
:label="t('commercial.clients.form.main.distributorName')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.distributor"
|
||||
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||
@@ -74,7 +76,7 @@
|
||||
v-model="main.triageService"
|
||||
:label="t('commercial.clients.form.main.triageService')"
|
||||
group-class="self-center"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -101,20 +103,24 @@
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.description"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.competitors"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('commercial.clients.form.information.competitors')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
: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
|
||||
v-model="information.foundedAt"
|
||||
:label="t('commercial.clients.form.information.foundedAt')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:editable="true"
|
||||
:max="maxFoundedAt"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
@@ -122,25 +128,30 @@
|
||||
v-model="information.employeesCount"
|
||||
:label="t('commercial.clients.form.information.employeesCount')"
|
||||
:mask="EMPLOYEES_MASK"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
: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
|
||||
v-model="information.revenueAmount"
|
||||
:key="revenueAmountKey"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.revenueAmount"
|
||||
@update:model-value="onRevenueAmountInput"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:label="t('commercial.clients.form.information.directorName')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.directorName"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-model="information.profitAmount"
|
||||
:label="t('commercial.clients.form.information.profitAmount')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.profitAmount"
|
||||
/>
|
||||
</div>
|
||||
@@ -167,7 +178,7 @@
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -204,7 +215,7 @@
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@@ -239,14 +250,15 @@
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
@@ -254,7 +266,7 @@
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="tvaModeOptions"
|
||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@@ -262,8 +274,9 @@
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
@@ -271,7 +284,7 @@
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="paymentDelayOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@@ -281,7 +294,7 @@
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="paymentTypeOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@@ -292,7 +305,7 @@
|
||||
:model-value="accounting.bankIri"
|
||||
:options="bankOptions"
|
||||
:label="t('commercial.clients.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@@ -319,21 +332,23 @@
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
@@ -423,6 +438,9 @@ import {
|
||||
type InformationFormDraft,
|
||||
type MainFormDraft,
|
||||
} 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 {
|
||||
buildClientFormTabKeys,
|
||||
isAddressValid,
|
||||
@@ -491,6 +509,22 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
|
||||
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
|
||||
const information = reactive<InformationFormDraft>(mapInformationDraft({} 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 addresses = ref<AddressFormDraft[]>([])
|
||||
const ribs = ref<RibFormDraft[]>([])
|
||||
@@ -668,6 +702,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) ───────────────────────────────
|
||||
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
|
||||
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
|
||||
@@ -767,6 +806,7 @@ function askRemoveContact(index: number): void {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyContact,
|
||||
onError: showError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -844,6 +884,7 @@ function askRemoveAddress(index: number): void {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyAddress,
|
||||
onError: showError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -944,6 +985,7 @@ function askRemoveRib(index: number): void {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyRib,
|
||||
onError: showError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('commercial.clients.consultation.back')"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -23,7 +24,7 @@
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
variant="danger"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('commercial.clients.action.archive')"
|
||||
@@ -48,43 +49,51 @@
|
||||
<!-- ── Formulaire principal (lecture seule) ──────────────────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(client.companyName)"
|
||||
:model-value="client.companyName"
|
||||
:label="t('commercial.clients.form.main.companyName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
v-if="isFilled(categoryIris)"
|
||||
:model-value="categoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('commercial.clients.form.main.categories')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
|
||||
<!-- Relation : masquee en consultation si aucune (ERP-193) ; en edition
|
||||
elle reste toujours visible (vide = « Aucun »). -->
|
||||
<MalioSelect
|
||||
v-if="isFilled(relation.type)"
|
||||
:model-value="relation.type"
|
||||
:options="relationOptions"
|
||||
:label="t('commercial.clients.form.main.relation')"
|
||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
|
||||
aucune valeur sans relation — meme comportement qu'en edition). -->
|
||||
<MalioInputText
|
||||
v-if="relation.type"
|
||||
v-if="relation.type && isFilled(relation.name)"
|
||||
:model-value="relation.name"
|
||||
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<!-- Service de triage : case a cocher masquee si non cochee (ERP-193). -->
|
||||
<MalioCheckbox
|
||||
v-if="isFilled(client.triageService === true)"
|
||||
:model-value="client.triageService === true"
|
||||
:label="t('commercial.clients.form.main.triageService')"
|
||||
group-class="self-center"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── 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 -->
|
||||
<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)]">
|
||||
@@ -92,42 +101,49 @@
|
||||
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
|
||||
coussin de chaque cote). -->
|
||||
<MalioInputTextArea
|
||||
v-if="isFilled(information.description)"
|
||||
:model-value="information.description"
|
||||
:label="t('commercial.clients.form.information.description')"
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.competitors)"
|
||||
:model-value="information.competitors"
|
||||
:label="t('commercial.clients.form.information.competitors')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioDate
|
||||
v-if="isFilled(information.foundedAt)"
|
||||
:model-value="information.foundedAt"
|
||||
:label="t('commercial.clients.form.information.foundedAt')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.employeesCount)"
|
||||
:model-value="information.employeesCount"
|
||||
:label="t('commercial.clients.form.information.employeesCount')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-if="isFilled(information.revenueAmount)"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.directorName)"
|
||||
:model-value="information.directorName"
|
||||
:label="t('commercial.clients.form.information.directorName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-if="isFilled(information.profitAmount)"
|
||||
:model-value="information.profitAmount"
|
||||
:label="t('commercial.clients.form.information.profitAmount')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -140,7 +156,8 @@
|
||||
:key="contact.id ?? index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -157,7 +174,8 @@
|
||||
:site-options="allSiteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -168,41 +186,47 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.siren)"
|
||||
:model-value="accounting.siren"
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.accountNumber)"
|
||||
:model-value="accounting.accountNumber"
|
||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.tvaModeIri)"
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="tvaModeOptions"
|
||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.nTva)"
|
||||
:model-value="accounting.nTva"
|
||||
:label="t('commercial.clients.form.accounting.nTva')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.paymentDelayIri)"
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="paymentDelayOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.paymentTypeIri)"
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="paymentTypeOptions"
|
||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="accounting.bankIri"
|
||||
@@ -210,7 +234,7 @@
|
||||
:options="bankOptions"
|
||||
:label="t('commercial.clients.form.accounting.bank')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,30 +247,30 @@
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.label)"
|
||||
:model-value="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.bic)"
|
||||
:model-value="rib.bic"
|
||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.iban)"
|
||||
:model-value="rib.iban"
|
||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
|
||||
<template #transport><ComingSoonPlaceholder /></template>
|
||||
<template #statistics><ComingSoonPlaceholder /></template>
|
||||
<template #reports><ComingSoonPlaceholder /></template>
|
||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
|
||||
Rapports / Echanges) ne sont plus rendus en consultation
|
||||
(masquage des onglets vides) — slots supprimes. -->
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
@@ -278,13 +302,14 @@
|
||||
</template>
|
||||
|
||||
<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 { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
import {
|
||||
canEditClient,
|
||||
categoryOptionsOf,
|
||||
clientConsultationVisibleTabs,
|
||||
contactOptionsOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressView,
|
||||
@@ -299,6 +324,7 @@ import {
|
||||
type SelectOption,
|
||||
} from '~/modules/commercial/utils/forms/clientConsultation'
|
||||
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
@@ -412,9 +438,11 @@ const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paym
|
||||
const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
|
||||
|
||||
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
|
||||
// 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
|
||||
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
|
||||
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
|
||||
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout
|
||||
// onglet de donnees vide. La liste depend donc du payload charge.
|
||||
const visibleTabKeys = computed(() => clientConsultationVisibleTabs(client.value, {
|
||||
canAccountingView: canAccountingView.value,
|
||||
}))
|
||||
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
information: 'mdi:account-outline',
|
||||
@@ -427,14 +455,26 @@ const TAB_ICONS: Record<string, string> = {
|
||||
exchanges: 'mdi:account-group-outline',
|
||||
}
|
||||
|
||||
const tabs = computed(() => tabKeys.value.map(key => ({
|
||||
const tabs = computed(() => visibleTabKeys.value.map(key => ({
|
||||
key,
|
||||
label: t(`commercial.clients.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
|
||||
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
|
||||
// Onglet initial : vide tant que le client n'est pas charge. Des que la 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 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 ─────────────────────────────────────────────────────────────
|
||||
function goBack(): void {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<!-- Categories : codes stables separes par une virgule (ERP-78). -->
|
||||
<!-- Categories : libelles (name) separes par une virgule (ERP-193). -->
|
||||
<template #cell-categories="{ item }">
|
||||
{{ formatCategories(item) }}
|
||||
</template>
|
||||
@@ -209,10 +209,10 @@ const columns = [
|
||||
{ key: 'lastActivity', label: t('commercial.clients.column.lastActivity') },
|
||||
]
|
||||
|
||||
/** Codes des categories du client, separes par une virgule (ERP-78). */
|
||||
/** Libelles (name) des categories du client, separes par une virgule (ERP-193). */
|
||||
function formatCategories(item: Record<string, unknown>): string {
|
||||
const categories = (item.categories as Client['categories']) ?? []
|
||||
return categories.map(c => c.code).join(', ')
|
||||
return categories.map(c => c.name).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('commercial.clients.form.back')"
|
||||
v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -19,9 +20,10 @@
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('commercial.clients.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
@@ -29,7 +31,7 @@
|
||||
:options="referentials.categories.value"
|
||||
:label="t('commercial.clients.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
@@ -40,7 +42,7 @@
|
||||
:options="relationOptions"
|
||||
:label="t('commercial.clients.form.main.relation')"
|
||||
:empty-option-label="t('commercial.clients.form.main.relationNone')"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
@update:model-value="onRelationChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
@@ -48,7 +50,7 @@
|
||||
:model-value="main.brokerIri"
|
||||
:options="referentials.brokers.value"
|
||||
:label="t('commercial.clients.form.main.brokerName')"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.broker"
|
||||
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
|
||||
@@ -58,7 +60,7 @@
|
||||
:model-value="main.distributorIri"
|
||||
:options="referentials.distributors.value"
|
||||
:label="t('commercial.clients.form.main.distributorName')"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.distributor"
|
||||
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
|
||||
@@ -68,7 +70,7 @@
|
||||
v-model="main.triageService"
|
||||
:label="t('commercial.clients.form.main.triageService')"
|
||||
group-class="self-center"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -96,20 +98,24 @@
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.description"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.competitors"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('commercial.clients.form.information.competitors')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
: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
|
||||
v-model="information.foundedAt"
|
||||
:label="t('commercial.clients.form.information.foundedAt')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:editable="true"
|
||||
:max="maxFoundedAt"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
@@ -117,37 +123,42 @@
|
||||
v-model="information.employeesCount"
|
||||
:label="t('commercial.clients.form.information.employeesCount')"
|
||||
:mask="EMPLOYEES_MASK"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
: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
|
||||
v-model="information.revenueAmount"
|
||||
:key="revenueAmountKey"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.clients.form.information.revenueAmount')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.revenueAmount"
|
||||
@update:model-value="onRevenueAmountInput"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:label="t('commercial.clients.form.information.directorName')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.directorName"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-model="information.profitAmount"
|
||||
:label="t('commercial.clients.form.information.profitAmount')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.profitAmount"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
|
||||
<!-- Desactive tant que le client n'est pas cree (evite un PATCH
|
||||
avant le POST si clic trop tot, Information etant l'onglet
|
||||
actif par defaut). Onglet facultatif : un enregistrement a
|
||||
vide reste possible, c'est le back qui valide. -->
|
||||
<!-- Masque tant que le client 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). Onglet facultatif :
|
||||
un enregistrement a vide reste possible, c'est le back qui valide. -->
|
||||
<div v-if="!isValidated('information') && clientId !== null" class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('commercial.clients.form.submit')"
|
||||
:disabled="tabSubmitting || clientId === null"
|
||||
:disabled="tabSubmitting"
|
||||
@click="submitInformation"
|
||||
/>
|
||||
</div>
|
||||
@@ -166,7 +177,7 @@
|
||||
:model-value="contact"
|
||||
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contact')"
|
||||
:disabled="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -203,7 +214,7 @@
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isValidated('address')"
|
||||
:disabled="isValidated('address')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@@ -237,14 +248,15 @@
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.clients.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
@@ -252,7 +264,7 @@
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('commercial.clients.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@@ -260,8 +272,9 @@
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
@@ -269,7 +282,7 @@
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('commercial.clients.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@@ -279,7 +292,7 @@
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('commercial.clients.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@@ -290,7 +303,7 @@
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('commercial.clients.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@@ -318,21 +331,23 @@
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.clients.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:label="t('commercial.clients.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
@@ -407,6 +422,9 @@ import {
|
||||
lastFillableTabKey,
|
||||
showsRelationAndTriageFields,
|
||||
} 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 {
|
||||
buildAddressPayload,
|
||||
buildMainPayload,
|
||||
@@ -665,6 +683,22 @@ const information = reactive({
|
||||
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. */
|
||||
async function submitInformation(): Promise<void> {
|
||||
if (clientId.value === null || tabSubmitting.value) return
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('commercial.suppliers.edit.back')"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -26,15 +27,16 @@
|
||||
v-model="main.companyName"
|
||||
:label="t('commercial.suppliers.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="mainErrors.errors.companyName"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.categoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('commercial.suppliers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
@@ -62,20 +64,24 @@
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.description"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.competitors"
|
||||
:label="t('commercial.suppliers.form.information.competitors')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
: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
|
||||
v-model="information.foundedAt"
|
||||
:label="t('commercial.suppliers.form.information.foundedAt')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:editable="true"
|
||||
:max="maxFoundedAt"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
@@ -83,25 +89,30 @@
|
||||
v-model="information.employeesCount"
|
||||
:label="t('commercial.suppliers.form.information.employeesCount')"
|
||||
:mask="EMPLOYEES_MASK"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
: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
|
||||
v-model="information.revenueAmount"
|
||||
:key="revenueAmountKey"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.revenueAmount"
|
||||
@update:model-value="onRevenueAmountInput"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
:label="t('commercial.suppliers.form.information.directorName')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.directorName"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-model="information.profitAmount"
|
||||
:label="t('commercial.suppliers.form.information.profitAmount')"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.profitAmount"
|
||||
/>
|
||||
<!-- Volume previsionnel : specifique fournisseur (entier). -->
|
||||
@@ -109,7 +120,7 @@
|
||||
v-model="information.volumeForecast"
|
||||
:label="t('commercial.suppliers.form.information.volumeForecast')"
|
||||
:mask="VOLUME_FORECAST_MASK"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="informationErrors.errors.volumeForecast"
|
||||
/>
|
||||
</div>
|
||||
@@ -136,7 +147,7 @@
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -173,7 +184,7 @@
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@@ -208,22 +219,23 @@
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.suppliers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('commercial.suppliers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="tvaModeOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@@ -232,15 +244,16 @@
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('commercial.suppliers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="paymentDelayOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@@ -250,7 +263,7 @@
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="paymentTypeOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@@ -261,7 +274,7 @@
|
||||
:model-value="accounting.bankIri"
|
||||
:options="bankOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@@ -288,23 +301,25 @@
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('commercial.suppliers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.bic"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('commercial.suppliers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.iban"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,6 +407,8 @@ import {
|
||||
type MainFormDraft,
|
||||
type SupplierEditAbilities,
|
||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||
import { todayIso } from '~/shared/utils/date'
|
||||
import {
|
||||
buildSupplierFormTabKeys,
|
||||
isAddressValid,
|
||||
@@ -412,6 +429,7 @@ import {
|
||||
} from '~/modules/commercial/types/supplierForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
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'
|
||||
|
||||
// Masques de saisie (la normalisation finale reste serveur).
|
||||
@@ -457,6 +475,22 @@ const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.
|
||||
const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail))
|
||||
const information = reactive<InformationFormDraft>(mapInformationDraft({} 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 addresses = ref<SupplierAddressFormDraft[]>([])
|
||||
const ribs = ref<SupplierRibFormDraft[]>([])
|
||||
@@ -583,6 +617,11 @@ function showError(e: unknown): void {
|
||||
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) ───────────────────────────────
|
||||
const {
|
||||
mainErrors,
|
||||
@@ -666,6 +705,7 @@ function askRemoveContact(index: number): void {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyContact,
|
||||
onError: showError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -734,6 +774,7 @@ function askRemoveAddress(index: number): void {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyAddress,
|
||||
onError: showError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -833,6 +874,7 @@ function askRemoveRib(index: number): void {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyRib,
|
||||
onError: showError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('commercial.suppliers.consultation.back')"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -23,7 +24,7 @@
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
variant="danger"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('commercial.suppliers.action.archive')"
|
||||
@@ -48,69 +49,82 @@
|
||||
<!-- ── Formulaire principal (lecture seule) ──────────────────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(supplier.companyName)"
|
||||
:model-value="supplier.companyName"
|
||||
:label="t('commercial.suppliers.form.main.companyName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
v-if="isFilled(categoryIris)"
|
||||
:model-value="categoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('commercial.suppliers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── 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 -->
|
||||
<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)]">
|
||||
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
|
||||
sur les inputs (champ 40px centre dans un h-12). -->
|
||||
<MalioInputTextArea
|
||||
v-if="isFilled(information.description)"
|
||||
:model-value="information.description"
|
||||
:label="t('commercial.suppliers.form.information.description')"
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.competitors)"
|
||||
:model-value="information.competitors"
|
||||
:label="t('commercial.suppliers.form.information.competitors')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioDate
|
||||
v-if="isFilled(information.foundedAt)"
|
||||
:model-value="information.foundedAt"
|
||||
:label="t('commercial.suppliers.form.information.foundedAt')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.employeesCount)"
|
||||
:model-value="information.employeesCount"
|
||||
:label="t('commercial.suppliers.form.information.employeesCount')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-if="isFilled(information.revenueAmount)"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.directorName)"
|
||||
:model-value="information.directorName"
|
||||
:label="t('commercial.suppliers.form.information.directorName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-if="isFilled(information.profitAmount)"
|
||||
:model-value="information.profitAmount"
|
||||
:label="t('commercial.suppliers.form.information.profitAmount')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<!-- Volume previsionnel : specifique fournisseur (entier). -->
|
||||
<MalioInputText
|
||||
v-if="isFilled(information.volumeForecast)"
|
||||
:model-value="information.volumeForecast"
|
||||
:label="t('commercial.suppliers.form.information.volumeForecast')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -123,7 +137,8 @@
|
||||
:key="contact.id ?? index"
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -140,7 +155,8 @@
|
||||
:site-options="allSiteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -151,41 +167,47 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.siren)"
|
||||
:model-value="accounting.siren"
|
||||
:label="t('commercial.suppliers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.accountNumber)"
|
||||
:model-value="accounting.accountNumber"
|
||||
:label="t('commercial.suppliers.form.accounting.accountNumber')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.tvaModeIri)"
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="tvaModeOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.tvaMode')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(accounting.nTva)"
|
||||
:model-value="accounting.nTva"
|
||||
:label="t('commercial.suppliers.form.accounting.nTva')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.paymentDelayIri)"
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="paymentDelayOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="isFilled(accounting.paymentTypeIri)"
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="paymentTypeOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.paymentType')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="accounting.bankIri"
|
||||
@@ -193,7 +215,7 @@
|
||||
:options="bankOptions"
|
||||
:label="t('commercial.suppliers.form.accounting.bank')"
|
||||
empty-option-label=""
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,30 +228,30 @@
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.label)"
|
||||
:model-value="rib.label"
|
||||
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.bic)"
|
||||
:model-value="rib.bic"
|
||||
:label="t('commercial.suppliers.form.accounting.ribBic')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isFilled(rib.iban)"
|
||||
:model-value="rib.iban"
|
||||
:label="t('commercial.suppliers.form.accounting.ribIban')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
|
||||
<template #transport><ComingSoonPlaceholder /></template>
|
||||
<template #statistics><ComingSoonPlaceholder /></template>
|
||||
<template #reports><ComingSoonPlaceholder /></template>
|
||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
|
||||
Rapports / Echanges) ne sont plus rendus en consultation
|
||||
(masquage des onglets vides) — slots supprimes. -->
|
||||
</MalioTabList>
|
||||
</template>
|
||||
|
||||
@@ -261,9 +283,9 @@
|
||||
</template>
|
||||
|
||||
<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 { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
|
||||
import { readHistoryTab } from '~/shared/utils/historyTab'
|
||||
import {
|
||||
canEditSupplier,
|
||||
@@ -278,10 +300,12 @@ import {
|
||||
referentialOptionOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
supplierConsultationVisibleTabs,
|
||||
type SelectOption,
|
||||
type SupplierDetail,
|
||||
} from '~/modules/commercial/utils/forms/supplierConsultation'
|
||||
import { emptyContact } from '~/modules/commercial/types/supplierForm'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
|
||||
const SIREN_MASK = '#########'
|
||||
@@ -387,9 +411,11 @@ const paymentTypeOptions = computed(() => referentialOptionOf(supplier.value?.pa
|
||||
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
|
||||
|
||||
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
|
||||
// 3 onglets actifs (Information, Contacts, Adresses, + Comptabilite si droit) et
|
||||
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
|
||||
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
|
||||
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout
|
||||
// onglet de donnees vide. La liste depend donc du payload charge.
|
||||
const visibleTabKeys = computed(() => supplierConsultationVisibleTabs(supplier.value, {
|
||||
canAccountingView: canAccountingView.value,
|
||||
}))
|
||||
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
information: 'mdi:account-outline',
|
||||
@@ -402,14 +428,25 @@ const TAB_ICONS: Record<string, string> = {
|
||||
exchanges: 'mdi:account-group-outline',
|
||||
}
|
||||
|
||||
const tabs = computed(() => tabKeys.value.map(key => ({
|
||||
const tabs = computed(() => visibleTabKeys.value.map(key => ({
|
||||
key,
|
||||
label: t(`commercial.suppliers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
})))
|
||||
|
||||
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
|
||||
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
|
||||
// Onglet initial : vide tant que le fournisseur n'est pas charge. Des que la
|
||||
// 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 ─────────────────────────────────────────────────────────────
|
||||
function goBack(): void {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<!-- Categories : libelles (name) separes par une virgule (spec M2). -->
|
||||
<!-- Categories : libelles (name) separes par une virgule, aligne sur le client (ERP-193). -->
|
||||
<template #cell-categories="{ item }">
|
||||
{{ formatCategories(item) }}
|
||||
</template>
|
||||
@@ -209,7 +209,7 @@ const columns = [
|
||||
{ key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') },
|
||||
]
|
||||
|
||||
/** Libelles des categories du fournisseur, separes par une virgule (spec M2 : name). */
|
||||
/** Libelles (name) des categories du fournisseur, separes par une virgule (aligne sur le client, ERP-193). */
|
||||
function formatCategories(item: Record<string, unknown>): string {
|
||||
const categories = (item.categories as Supplier['categories']) ?? []
|
||||
return categories.map(c => c.name).join(', ')
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('commercial.suppliers.form.back')"
|
||||
v-bind="{ ariaLabel: t('commercial.suppliers.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -21,15 +22,16 @@
|
||||
v-model="main.companyName"
|
||||
:label="t('commercial.suppliers.form.main.companyName')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.companyName"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
:model-value="main.categoryIris"
|
||||
:options="referentials.categories.value"
|
||||
:label="t('commercial.suppliers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
@@ -56,20 +58,24 @@
|
||||
resize="none"
|
||||
group-class="row-span-2 pt-1 pb-1"
|
||||
text-input="h-full text-lg"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.description"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.competitors"
|
||||
:label="t('commercial.suppliers.form.information.competitors')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
: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
|
||||
v-model="information.foundedAt"
|
||||
:label="t('commercial.suppliers.form.information.foundedAt')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:editable="true"
|
||||
:max="maxFoundedAt"
|
||||
:error="informationErrors.errors.foundedAt"
|
||||
@update:raw-value="(v: string) => information.foundedAtRaw = v"
|
||||
/>
|
||||
@@ -77,25 +83,30 @@
|
||||
v-model="information.employeesCount"
|
||||
:label="t('commercial.suppliers.form.information.employeesCount')"
|
||||
:mask="EMPLOYEES_MASK"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
: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
|
||||
v-model="information.revenueAmount"
|
||||
:key="revenueAmountKey"
|
||||
:model-value="information.revenueAmount"
|
||||
:label="t('commercial.suppliers.form.information.revenueAmount')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.revenueAmount"
|
||||
@update:model-value="onRevenueAmountInput"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="information.directorName"
|
||||
:label="t('commercial.suppliers.form.information.directorName')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.directorName"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
/>
|
||||
<MalioInputAmount
|
||||
v-model="information.profitAmount"
|
||||
:label="t('commercial.suppliers.form.information.profitAmount')"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.profitAmount"
|
||||
/>
|
||||
<!-- Volume previsionnel : specifique fournisseur. Champ texte
|
||||
@@ -104,15 +115,18 @@
|
||||
v-model="information.volumeForecast"
|
||||
:label="t('commercial.suppliers.form.information.volumeForecast')"
|
||||
:mask="VOLUME_FORECAST_MASK"
|
||||
:readonly="isValidated('information')"
|
||||
:disabled="isValidated('information')"
|
||||
:error="informationErrors.errors.volumeForecast"
|
||||
/>
|
||||
</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
|
||||
variant="primary"
|
||||
:label="t('commercial.suppliers.form.submit')"
|
||||
:disabled="tabSubmitting || supplierId === null"
|
||||
:disabled="tabSubmitting"
|
||||
@click="submitInformation"
|
||||
/>
|
||||
</div>
|
||||
@@ -131,7 +145,7 @@
|
||||
:model-value="contact"
|
||||
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contacts')"
|
||||
:disabled="isValidated('contacts')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -168,7 +182,7 @@
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isValidated('addresses')"
|
||||
:disabled="isValidated('addresses')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@@ -202,22 +216,23 @@
|
||||
v-model="accounting.siren"
|
||||
:label="t('commercial.suppliers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('commercial.suppliers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('commercial.suppliers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@@ -226,15 +241,16 @@
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('commercial.suppliers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
<MalioSelect
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@@ -244,7 +260,7 @@
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('commercial.suppliers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@@ -255,7 +271,7 @@
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('commercial.suppliers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@@ -282,23 +298,25 @@
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('commercial.suppliers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('commercial.suppliers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.bic"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('commercial.suppliers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="isRibRequired"
|
||||
:error="ribErrors[index]?.iban"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -375,6 +393,8 @@ import {
|
||||
buildMainPayload,
|
||||
buildRibPayload,
|
||||
} from '~/modules/commercial/utils/forms/supplierEdit'
|
||||
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
|
||||
import { todayIso } from '~/shared/utils/date'
|
||||
import {
|
||||
emptyAddress,
|
||||
emptyContact,
|
||||
@@ -385,6 +405,7 @@ import {
|
||||
} from '~/modules/commercial/types/supplierForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
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).
|
||||
const SIREN_MASK = '#########'
|
||||
@@ -564,6 +585,22 @@ const information = reactive({
|
||||
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. */
|
||||
async function submitInformation(): Promise<void> {
|
||||
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 {
|
||||
canEditClient,
|
||||
categoryOptionsOf,
|
||||
clientConsultationVisibleTabs,
|
||||
contactOptionsOf,
|
||||
hasAccountingData,
|
||||
hasInformationData,
|
||||
iriOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressToDraft,
|
||||
@@ -248,3 +251,73 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
|
||||
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,
|
||||
categoryOptionsOf,
|
||||
contactOptionsOf,
|
||||
hasAccountingData,
|
||||
hasInformationData,
|
||||
iriOf,
|
||||
mapAccountingDraft,
|
||||
mapAddressToDraft,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
siteOptionsOf,
|
||||
supplierConsultationVisibleTabs,
|
||||
type SupplierDetail,
|
||||
} from '../supplierConsultation'
|
||||
|
||||
@@ -237,3 +240,60 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
|
||||
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
|
||||
* — `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
|
||||
* — `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- En-tête du bloc : titre + boutons de pesée (bascule / manuelle). -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-[20px] font-semibold text-m-primary">{{ title }}</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
:label="t('logistique.weighingTickets.form.weighbridge.auto')"
|
||||
:disabled="disabled"
|
||||
@click="$emit('request-auto')"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('logistique.weighingTickets.form.weighbridge.manual')"
|
||||
:disabled="disabled"
|
||||
@click="$emit('request-manual')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<!-- Contrepartie : rendue par le parent (bloc vide uniquement) via le slot. -->
|
||||
<slot name="counterparty" />
|
||||
|
||||
<!-- Date de la pesée — jour par défaut (RG-5.07). MalioDate (composant
|
||||
projet pour le type date, exception tolérée @.claude/rules/frontend.md). -->
|
||||
<MalioDate
|
||||
:model-value="block.date"
|
||||
:label="t('logistique.weighingTickets.form.date')"
|
||||
:required="true"
|
||||
:editable="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.date"
|
||||
@update:model-value="(v: string | null) => emitBlock('date', v)"
|
||||
/>
|
||||
|
||||
<!-- Poids : readonly, rempli par la pesée (RG-5.07). Unité Kg dans le label. -->
|
||||
<MalioInputNumber
|
||||
:model-value="block.weight"
|
||||
:label="t('logistique.weighingTickets.form.weight')"
|
||||
:required="true"
|
||||
:readonly="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.weight"
|
||||
/>
|
||||
|
||||
<!-- DSD : readonly, rempli par la pesée (RG-5.04 / RG-5.07). -->
|
||||
<MalioInputNumber
|
||||
:model-value="block.dsd"
|
||||
:label="t('logistique.weighingTickets.form.dsd')"
|
||||
:required="true"
|
||||
:readonly="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.dsd"
|
||||
/>
|
||||
|
||||
<!-- Immatriculation : masque XX-000-XX (plaque FR SIV) sauf « Tout format ».
|
||||
PARTAGÉE entre les 2 blocs (RG-5.01) — v-model remonté au form parent.
|
||||
TODO migrer le masque plaque quand @malio/layer-ui couvrira le format. -->
|
||||
<MalioInputText
|
||||
:model-value="immatriculation"
|
||||
:mask="plateFreeFormat ? undefined : PLATE_MASK"
|
||||
:label="t('logistique.weighingTickets.form.immatriculation')"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:error="errors.immatriculation"
|
||||
@update:model-value="(v: string | null) => $emit('update:immatriculation', v)"
|
||||
/>
|
||||
|
||||
<!-- « Tout format » : désactive le masque plaque. Partagé entre blocs (RG-5.01). -->
|
||||
<MalioCheckbox
|
||||
:id="`${blockId}-plate-free-format`"
|
||||
:model-value="plateFreeFormat"
|
||||
:label="t('logistique.weighingTickets.form.plateFreeFormat')"
|
||||
group-class="self-center"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: boolean) => $emit('update:plateFreeFormat', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||
|
||||
/**
|
||||
* Bloc de pesée (« Poids à vide » ou « Poids à plein ») de l'écran Ticket de pesée.
|
||||
* Champs Date / Poids / DSD / Immatriculation / « Tout format » + boutons de pesée.
|
||||
* L'immatriculation et « Tout format » sont PARTAGÉS entre les 2 blocs (RG-5.01) :
|
||||
* portés par le form parent et remontés en `update:*`. Le slot `counterparty`
|
||||
* permet au parent d'injecter la contrepartie sur le seul bloc vide (RG-5.03).
|
||||
*/
|
||||
|
||||
// Masque plaque FR SIV `XX-000-XX` (maska) : 2 lettres, 3 chiffres, 2 lettres,
|
||||
// majuscules forcées. Désactivé quand « Tout format » est coché (RG-5.01).
|
||||
const PLATE_MASK = {
|
||||
mask: 'AA-###-AA',
|
||||
tokens: { A: { pattern: /[A-Za-z]/, transform: (c: string) => c.toUpperCase() } },
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Identifiant technique du bloc (pour les `id` de champs uniques). */
|
||||
blockId: string
|
||||
title: string
|
||||
block: WeighingBlockState
|
||||
/** Immatriculation partagée (RG-5.01) — portée par le form parent. */
|
||||
immatriculation: string | null
|
||||
/** « Tout format » partagé (RG-5.01) — porté par le form parent. */
|
||||
plateFreeFormat: boolean
|
||||
/** Erreurs 422 par champ (propertyPath → message). */
|
||||
errors?: Record<string, string>
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:block': [field: keyof WeighingBlockState, value: unknown]
|
||||
'update:immatriculation': [value: string | null]
|
||||
'update:plateFreeFormat': [value: boolean]
|
||||
'request-auto': []
|
||||
'request-manual': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const errors = computed(() => props.errors ?? {})
|
||||
|
||||
/** Remonte la mutation d'un champ du bloc au parent (état des pesées centralisé). */
|
||||
function emitBlock(field: keyof WeighingBlockState, value: unknown): void {
|
||||
emit('update:block', field, value)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// useApi / useI18n sont des auto-imports Nuxt : on les expose en globals.
|
||||
const mockPost = vi.hoisted(() => vi.fn())
|
||||
vi.stubGlobal('useApi', () => ({ post: mockPost }))
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
|
||||
const { useWeighbridge } = await import('../useWeighbridge')
|
||||
|
||||
describe('useWeighbridge', () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockReset()
|
||||
})
|
||||
|
||||
it('AUTO : POST { mode: AUTO } sans toast et renvoie la lecture', async () => {
|
||||
mockPost.mockResolvedValue({ weight: 23187, dsd: 42, mode: 'AUTO' })
|
||||
const { triggerAuto } = useWeighbridge()
|
||||
|
||||
const reading = await triggerAuto()
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/weighbridge_readings',
|
||||
{ mode: 'AUTO' },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
expect(reading).toEqual({ weight: 23187, dsd: 42, mode: 'AUTO' })
|
||||
})
|
||||
|
||||
it('MANUAL : POST { mode: MANUAL, weight, manualNumber } et renvoie la lecture', async () => {
|
||||
mockPost.mockResolvedValue({ weight: 5000, dsd: 43, manualNumber: 'PAP-555', mode: 'MANUAL' })
|
||||
const { triggerManual } = useWeighbridge()
|
||||
|
||||
const reading = await triggerManual(5000, 'PAP-555')
|
||||
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/weighbridge_readings',
|
||||
{ mode: 'MANUAL', weight: 5000, manualNumber: 'PAP-555' },
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
expect(reading.dsd).toBe(43)
|
||||
})
|
||||
|
||||
it('erreur (RG-5.06) : extractWeighbridgeError privilégie le detail du 503', () => {
|
||||
const { extractWeighbridgeError } = useWeighbridge()
|
||||
const error = { response: { status: 503, _data: { title: 'Pont bascule indisponible', detail: 'Passez en pesée manuelle.' } } }
|
||||
expect(extractWeighbridgeError(error)).toBe('Passez en pesée manuelle.')
|
||||
})
|
||||
|
||||
it('erreur sans payload exploitable : retombe sur le libellé i18n générique', () => {
|
||||
const { extractWeighbridgeError } = useWeighbridge()
|
||||
expect(extractWeighbridgeError(new Error('network')))
|
||||
.toBe('logistique.weighingTickets.form.weighbridge.unavailable')
|
||||
})
|
||||
|
||||
it('triggerAuto propage l\'erreur API (gestion par l\'écran)', async () => {
|
||||
mockPost.mockRejectedValue({ response: { status: 503 } })
|
||||
const { triggerAuto } = useWeighbridge()
|
||||
await expect(triggerAuto()).rejects.toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
// `todayIso` est importé par le composable : on le stubbe pour une date déterministe.
|
||||
vi.mock('~/shared/utils/date', () => ({ todayIso: () => '2026-06-22' }))
|
||||
|
||||
const { useWeighingTicketForm } = await import('../useWeighingTicketForm')
|
||||
|
||||
describe('useWeighingTicketForm', () => {
|
||||
it('initialise les 2 blocs à la date du jour (RG-5.07), sans poids ni DSD', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
expect(form.empty.date).toBe('2026-06-22')
|
||||
expect(form.full.date).toBe('2026-06-22')
|
||||
expect(form.empty.weight).toBeNull()
|
||||
expect(form.empty.dsd).toBeNull()
|
||||
expect(form.counterpartyType.value).toBeNull()
|
||||
})
|
||||
|
||||
// ── Contrepartie conditionnelle (RG-5.03) ────────────────────────────────
|
||||
it('CLIENT : ne conserve que le client, purge supplier et otherLabel', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.supplierIri.value = '/api/suppliers/3'
|
||||
form.otherLabel.value = 'Particulier'
|
||||
|
||||
form.setCounterpartyType('CLIENT')
|
||||
form.clientIri.value = '/api/clients/629'
|
||||
|
||||
expect(form.counterpartyField.value).toBe('client')
|
||||
expect(form.supplierIri.value).toBeNull()
|
||||
expect(form.otherLabel.value).toBeNull()
|
||||
|
||||
const payload = form.buildCreatePayload()
|
||||
expect(payload.counterpartyType).toBe('CLIENT')
|
||||
expect(payload.client).toBe('/api/clients/629')
|
||||
expect(payload).not.toHaveProperty('supplier')
|
||||
expect(payload).not.toHaveProperty('otherLabel')
|
||||
})
|
||||
|
||||
it('FOURNISSEUR : ne conserve que le supplier', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.clientIri.value = '/api/clients/1'
|
||||
form.setCounterpartyType('FOURNISSEUR')
|
||||
form.supplierIri.value = '/api/suppliers/7'
|
||||
|
||||
expect(form.counterpartyField.value).toBe('supplier')
|
||||
expect(form.clientIri.value).toBeNull()
|
||||
expect(form.buildCreatePayload().supplier).toBe('/api/suppliers/7')
|
||||
})
|
||||
|
||||
it('AUTRE : ne conserve que le libellé libre', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.clientIri.value = '/api/clients/1'
|
||||
form.setCounterpartyType('AUTRE')
|
||||
form.otherLabel.value = 'Reprise interne'
|
||||
|
||||
expect(form.counterpartyField.value).toBe('other')
|
||||
expect(form.clientIri.value).toBeNull()
|
||||
expect(form.buildCreatePayload().otherLabel).toBe('Reprise interne')
|
||||
})
|
||||
|
||||
// ── Immatriculation / « Tout format » partagés entre blocs (RG-5.01) ──────
|
||||
it('immatriculation et plateFreeFormat sont partagés (une seule valeur)', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.immatriculation.value = 'AB-123-CD'
|
||||
form.plateFreeFormat.value = true
|
||||
|
||||
// Les 2 payloads (création + finalisation) reflètent la même valeur.
|
||||
expect(form.buildCreatePayload().immatriculation).toBe('AB-123-CD')
|
||||
expect(form.buildCreatePayload().plateFreeFormat).toBe(true)
|
||||
expect(form.buildFullPayload().immatriculation).toBe('AB-123-CD')
|
||||
expect(form.buildFullPayload().plateFreeFormat).toBe(true)
|
||||
})
|
||||
|
||||
// ── Application d'une lecture de pesée ────────────────────────────────────
|
||||
it('applyReading remplit poids / DSD / mode du bloc visé', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
|
||||
expect(form.empty.weight).toBe(7150)
|
||||
expect(form.empty.dsd).toBe(1)
|
||||
expect(form.empty.mode).toBe('AUTO')
|
||||
expect(form.empty.manualNumber).toBeNull()
|
||||
|
||||
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'MANUAL', manualNumber: 'PAP-555' })
|
||||
expect(form.full.weight).toBe(14300)
|
||||
expect(form.full.manualNumber).toBe('PAP-555')
|
||||
})
|
||||
|
||||
it('buildCreatePayload porte la pesée à vide, buildFullPayload la pesée à plein', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.setCounterpartyType('CLIENT')
|
||||
form.clientIri.value = '/api/clients/1'
|
||||
form.applyReading(form.empty, { weight: 7150, dsd: 1, mode: 'AUTO' })
|
||||
form.applyReading(form.full, { weight: 14300, dsd: 2, mode: 'AUTO' })
|
||||
|
||||
const create = form.buildCreatePayload()
|
||||
expect(create.emptyWeight).toBe(7150)
|
||||
expect(create.emptyDsd).toBe(1)
|
||||
expect(create.emptyMode).toBe('AUTO')
|
||||
expect(create).not.toHaveProperty('fullWeight')
|
||||
|
||||
const full = form.buildFullPayload()
|
||||
expect(full.fullWeight).toBe(14300)
|
||||
expect(full.fullDsd).toBe(2)
|
||||
expect(full.fullMode).toBe('AUTO')
|
||||
})
|
||||
|
||||
// ── Pré-remplissage (écran Modification, ERP-190) ─────────────────────────
|
||||
it('hydrate pré-remplit l\'état depuis le détail (dates ISO ramenées à YYYY-MM-DD)', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.hydrate({
|
||||
id: 9,
|
||||
counterpartyType: 'CLIENT',
|
||||
client: { '@id': '/api/clients/629' },
|
||||
immatriculation: 'AB-123-CD',
|
||||
plateFreeFormat: false,
|
||||
emptyDate: '2026-06-17T09:00:00+02:00',
|
||||
emptyWeight: 7150,
|
||||
emptyDsd: 1,
|
||||
emptyMode: 'AUTO',
|
||||
fullDate: '2026-06-17T09:12:00+02:00',
|
||||
fullWeight: 14300,
|
||||
fullDsd: 2,
|
||||
fullMode: 'AUTO',
|
||||
})
|
||||
|
||||
expect(form.ticketId.value).toBe(9)
|
||||
expect(form.counterpartyType.value).toBe('CLIENT')
|
||||
expect(form.counterpartyField.value).toBe('client')
|
||||
expect(form.clientIri.value).toBe('/api/clients/629')
|
||||
expect(form.immatriculation.value).toBe('AB-123-CD')
|
||||
// Date datetime back -> date seule pour MalioDate.
|
||||
expect(form.empty.date).toBe('2026-06-17')
|
||||
expect(form.full.date).toBe('2026-06-17')
|
||||
expect(form.empty.weight).toBe(7150)
|
||||
expect(form.full.weight).toBe(14300)
|
||||
})
|
||||
|
||||
it('hydrate gère les champs null omis (skip_null_values) avec des défauts', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.hydrate({ id: 5, counterpartyType: 'AUTRE', otherLabel: 'Reprise' })
|
||||
expect(form.otherLabel.value).toBe('Reprise')
|
||||
expect(form.supplierIri.value).toBeNull()
|
||||
expect(form.plateFreeFormat.value).toBe(false)
|
||||
// Pas de date back -> repli sur le jour (stub 2026-06-22).
|
||||
expect(form.empty.date).toBe('2026-06-22')
|
||||
expect(form.empty.weight).toBeNull()
|
||||
})
|
||||
|
||||
it('buildUpdatePayload fusionne contrepartie + véhicule + les 2 pesées', () => {
|
||||
const form = useWeighingTicketForm()
|
||||
form.hydrate({
|
||||
id: 9,
|
||||
counterpartyType: 'CLIENT',
|
||||
client: { '@id': '/api/clients/629' },
|
||||
immatriculation: 'AB-123-CD',
|
||||
emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO',
|
||||
fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
|
||||
})
|
||||
|
||||
const payload = form.buildUpdatePayload()
|
||||
expect(payload.counterpartyType).toBe('CLIENT')
|
||||
expect(payload.client).toBe('/api/clients/629')
|
||||
expect(payload.emptyWeight).toBe(7150)
|
||||
expect(payload.fullWeight).toBe(14300)
|
||||
expect(payload.immatriculation).toBe('AB-123-CD')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Pesée au pont bascule (M5, ERP-189) — déclenche une lecture de poids via
|
||||
* `POST /api/weighbridge_readings` (spec-back § 4.2). Action autonome : le ticket
|
||||
* n'existe pas encore quand on pèse depuis le formulaire principal.
|
||||
*
|
||||
* Deux modes :
|
||||
* - AUTO (« Pesée bascule ») : le serveur résout le site courant, lit le poids
|
||||
* (stub aléatoire au M5) et alloue le DSD. Peut échouer (RG-5.06 → 503) : le
|
||||
* pont est indisponible, on invite l'utilisateur à passer en pesée manuelle.
|
||||
* - MANUAL (« Pesée manuelle ») : poids + numéro de pesée saisis ; le serveur
|
||||
* calcule le DSD = dernier + 1 (RG-5.04).
|
||||
*
|
||||
* Composable UI-agnostique : il appelle l'API (`useApi`, jamais `$fetch`) et
|
||||
* renvoie la lecture, ou lève l'erreur — la gestion de la modal/de l'affichage
|
||||
* reste à la charge de l'écran. `extractWeighbridgeError` factorise la lecture
|
||||
* du message d'erreur 503 (RG-5.06) pour l'afficher dans la modal.
|
||||
*/
|
||||
|
||||
/** Mode de pesée — miroir de l'enum back. */
|
||||
export type WeighbridgeMode = 'AUTO' | 'MANUAL'
|
||||
|
||||
/** Lecture renvoyée par le pont bascule (spec-back § 4.2). */
|
||||
export interface WeighbridgeReading {
|
||||
weight: number
|
||||
dsd: number
|
||||
mode: WeighbridgeMode
|
||||
/** Numéro de pesée saisi en mode MANUAL (absent en AUTO). */
|
||||
manualNumber?: string
|
||||
}
|
||||
|
||||
export function useWeighbridge() {
|
||||
const api = useApi()
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* Pesée bascule (AUTO). Le site courant est résolu serveur — rien à envoyer.
|
||||
* `toast: false` : l'erreur (RG-5.06) est affichée inline dans la modal, pas
|
||||
* en toast global.
|
||||
*/
|
||||
async function triggerAuto(): Promise<WeighbridgeReading> {
|
||||
return await api.post<WeighbridgeReading>(
|
||||
'/weighbridge_readings',
|
||||
{ mode: 'AUTO' },
|
||||
{ toast: false },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pesée manuelle (MANUAL). Le DSD est calculé serveur (dernier + 1, RG-5.04) ;
|
||||
* le `manualNumber` est la référence du ticket papier / autre bascule.
|
||||
*/
|
||||
async function triggerManual(weight: number, manualNumber: string): Promise<WeighbridgeReading> {
|
||||
return await api.post<WeighbridgeReading>(
|
||||
'/weighbridge_readings',
|
||||
{ mode: 'MANUAL', weight, manualNumber },
|
||||
{ toast: false },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Message d'erreur de pesée bascule (RG-5.06). Le back renvoie un 503
|
||||
* `{ title, detail }` (« Pont bascule indisponible » / « Passez en pesée
|
||||
* manuelle. ») — on privilégie le `detail`, puis le `title`, sinon un libellé
|
||||
* générique invitant à la pesée manuelle.
|
||||
*/
|
||||
function extractWeighbridgeError(error: unknown): string {
|
||||
const data = (error as { response?: { _data?: unknown } })?.response?._data as
|
||||
| { detail?: string, title?: string }
|
||||
| undefined
|
||||
return data?.detail || data?.title || t('logistique.weighingTickets.form.weighbridge.unavailable')
|
||||
}
|
||||
|
||||
return { triggerAuto, triggerManual, extractWeighbridgeError }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
|
||||
import type { CounterpartyType } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||
|
||||
/**
|
||||
* Détail d'un ticket de pesée (`GET /api/weighing_tickets/{id}`, spec-back
|
||||
* § 4.0.bis). Champs null OMIS du JSON (`skip_null_values`) → tous optionnels,
|
||||
* lus avec un défaut côté hydratation du formulaire.
|
||||
*/
|
||||
export interface WeighingTicketDetail {
|
||||
id: number
|
||||
/** Numéro `{siteCode}-TP-{NNNN}` — immuable (RG-5.09). */
|
||||
number: string
|
||||
/** Site rattaché (embarqué) — immuable (RG-5.09). */
|
||||
site?: { id: number, name: string, code: string } | null
|
||||
counterpartyType: CounterpartyType
|
||||
client?: { '@id': string, companyName: string } | null
|
||||
supplier?: { '@id': string, companyName: string } | null
|
||||
otherLabel?: string | null
|
||||
immatriculation?: string | null
|
||||
plateFreeFormat?: boolean
|
||||
// Pesée à vide
|
||||
emptyDate?: string | null
|
||||
emptyWeight?: number | null
|
||||
emptyDsd?: number | null
|
||||
emptyMode?: WeighbridgeMode | null
|
||||
emptyManualNumber?: string | null
|
||||
// Pesée à plein
|
||||
fullDate?: string | null
|
||||
fullWeight?: number | null
|
||||
fullDsd?: number | null
|
||||
fullMode?: WeighbridgeMode | null
|
||||
fullManualNumber?: string | null
|
||||
netWeight?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge le détail d'un ticket de pesée pour l'écran de modification (M5,
|
||||
* ERP-190). `Accept: application/ld+json` impose l'enveloppe Hydra (relations
|
||||
* embarquées : client/supplier/site). Appel via `useApi()` (jamais `$fetch`).
|
||||
*/
|
||||
export function useWeighingTicket() {
|
||||
const api = useApi()
|
||||
|
||||
async function fetchTicket(id: number | string): Promise<WeighingTicketDetail> {
|
||||
return await api.get<WeighingTicketDetail>(
|
||||
`/weighing_tickets/${id}`,
|
||||
{},
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
}
|
||||
|
||||
return { fetchTicket }
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { todayIso } from '~/shared/utils/date'
|
||||
import type { WeighbridgeMode } from '~/modules/logistique/composables/useWeighbridge'
|
||||
|
||||
/**
|
||||
* État et logique du formulaire « Ajouter / Modifier un ticket de pesée » (M5,
|
||||
* ERP-189). L'écran est composé de DEUX blocs empilés — pesée à vide puis pesée
|
||||
* à plein — qui partagent un même véhicule.
|
||||
*
|
||||
* Points clés (spec-front § Écran Ajouter, spec-back § 2.4 / 2.9 / 2.10) :
|
||||
* - **Contrepartie conditionnelle (RG-5.03)** : `counterpartyType` (CLIENT /
|
||||
* FOURNISSEUR / AUTRE) pilote le champ requis (client / supplier / otherLabel).
|
||||
* Changer de type purge les champs des autres types — aucune donnée fantôme.
|
||||
* - **Immatriculation + « Tout format » partagés entre les 2 blocs (RG-5.01)** :
|
||||
* une seule valeur (refs uniques) — modifier l'un met à jour l'autre puisque
|
||||
* les 2 blocs bindent la même ref.
|
||||
* - **Workflow 2 temps** : `buildCreatePayload()` (POST à l'« Enregistrer » du
|
||||
* bloc vide) crée le ticket avec la pesée à vide ; `buildFullPayload()` (PATCH
|
||||
* au « Valider ») ajoute la pesée à plein (net recalculé serveur, RG-5.05).
|
||||
*
|
||||
* Composable UI-agnostique et testable : aucune dépendance API ici (les appels
|
||||
* vivent dans l'écran via `useApi`). Instancié PAR écran (refs locales).
|
||||
*/
|
||||
|
||||
/** Type de contrepartie — miroir de l'enum back (spec-back § 2.9). */
|
||||
export type CounterpartyType = 'CLIENT' | 'FOURNISSEUR' | 'AUTRE'
|
||||
|
||||
/** Saisie d'une pesée (bloc vide OU bloc plein). */
|
||||
export interface WeighingBlockState {
|
||||
/** Date de la pesée (ISO `YYYY-MM-DD`) — jour par défaut (RG-5.07). */
|
||||
date: string | null
|
||||
/** Poids en kg — readonly, rempli par la pesée (bascule ou manuelle). */
|
||||
weight: number | null
|
||||
/** DSD — readonly, rempli par la pesée (RG-5.04). */
|
||||
dsd: number | null
|
||||
/** Mode de la dernière pesée appliquée au bloc. */
|
||||
mode: WeighbridgeMode | null
|
||||
/** Numéro de pesée (rempli uniquement en pesée manuelle). */
|
||||
manualNumber: string | null
|
||||
}
|
||||
|
||||
/** Forme minimale d'un détail de ticket consommée par `hydrate` (cf. useWeighingTicket). */
|
||||
export interface WeighingTicketHydration {
|
||||
id: number
|
||||
counterpartyType: CounterpartyType
|
||||
client?: { '@id': string } | null
|
||||
supplier?: { '@id': string } | null
|
||||
otherLabel?: string | null
|
||||
immatriculation?: string | null
|
||||
plateFreeFormat?: boolean
|
||||
emptyDate?: string | null
|
||||
emptyWeight?: number | null
|
||||
emptyDsd?: number | null
|
||||
emptyMode?: WeighbridgeMode | null
|
||||
emptyManualNumber?: string | null
|
||||
fullDate?: string | null
|
||||
fullWeight?: number | null
|
||||
fullDsd?: number | null
|
||||
fullMode?: WeighbridgeMode | null
|
||||
fullManualNumber?: string | null
|
||||
}
|
||||
|
||||
/** Extrait la partie date `YYYY-MM-DD` d'une chaîne ISO (datetime back) — null si absente. */
|
||||
function isoDateOnly(value: string | null | undefined): string | null {
|
||||
return value ? value.slice(0, 10) : null
|
||||
}
|
||||
|
||||
/** Crée l'état initial d'un bloc de pesée (date = aujourd'hui, RG-5.07). */
|
||||
function emptyBlock(today: string): WeighingBlockState {
|
||||
return {
|
||||
date: today,
|
||||
weight: null,
|
||||
dsd: null,
|
||||
mode: null,
|
||||
manualNumber: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function useWeighingTicketForm() {
|
||||
const today = todayIso()
|
||||
|
||||
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────
|
||||
const counterpartyType = ref<CounterpartyType | null>(null)
|
||||
const clientIri = ref<string | null>(null)
|
||||
const supplierIri = ref<string | null>(null)
|
||||
const otherLabel = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* Change le type de contrepartie et purge les champs devenus hors-sujet :
|
||||
* un seul de client / supplier / otherLabel est conservé selon le type
|
||||
* (RG-5.03 — pas de FK fantôme envoyée au back).
|
||||
*/
|
||||
function setCounterpartyType(type: CounterpartyType | null): void {
|
||||
counterpartyType.value = type
|
||||
if (type !== 'CLIENT') clientIri.value = null
|
||||
if (type !== 'FOURNISSEUR') supplierIri.value = null
|
||||
if (type !== 'AUTRE') otherLabel.value = null
|
||||
}
|
||||
|
||||
// ── Véhicule : partagé entre les 2 blocs (RG-5.01) ────────────────────────
|
||||
// Refs UNIQUES : les 2 blocs bindent la même valeur → connexion automatique.
|
||||
const immatriculation = ref<string | null>(null)
|
||||
const plateFreeFormat = ref<boolean>(false)
|
||||
|
||||
// ── Les deux pesées ───────────────────────────────────────────────────────
|
||||
const empty = reactive<WeighingBlockState>(emptyBlock(today))
|
||||
const full = reactive<WeighingBlockState>(emptyBlock(today))
|
||||
|
||||
// Id du ticket créé (POST du bloc vide) — pilote le PATCH du bloc plein.
|
||||
const ticketId = ref<number | null>(null)
|
||||
|
||||
/**
|
||||
* Champ de contrepartie attendu selon le type courant — utilisé par l'écran
|
||||
* pour afficher conditionnellement le bon champ (RG-5.03).
|
||||
*/
|
||||
const counterpartyField = computed<'client' | 'supplier' | 'other' | null>(() => {
|
||||
switch (counterpartyType.value) {
|
||||
case 'CLIENT': return 'client'
|
||||
case 'FOURNISSEUR': return 'supplier'
|
||||
case 'AUTRE': return 'other'
|
||||
default: return null
|
||||
}
|
||||
})
|
||||
|
||||
/** Applique une lecture de pesée (bascule/manuelle) à un bloc. */
|
||||
function applyReading(
|
||||
block: WeighingBlockState,
|
||||
reading: { weight: number, dsd: number, mode: WeighbridgeMode, manualNumber?: string },
|
||||
): void {
|
||||
block.weight = reading.weight
|
||||
block.dsd = reading.dsd
|
||||
block.mode = reading.mode
|
||||
block.manualNumber = reading.manualNumber ?? null
|
||||
}
|
||||
|
||||
/** Partie « contrepartie » du payload (FK en IRI ou libellé libre). */
|
||||
function counterpartyPayload(): Record<string, unknown> {
|
||||
switch (counterpartyType.value) {
|
||||
case 'CLIENT': return { client: clientIri.value }
|
||||
case 'FOURNISSEUR': return { supplier: supplierIri.value }
|
||||
case 'AUTRE': return { otherLabel: otherLabel.value || null }
|
||||
default: return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de CRÉATION (POST /weighing_tickets, spec-back § 4.3) : contrepartie
|
||||
* + véhicule + pesée à VIDE. Le numéro, le site et le net sont attribués
|
||||
* serveur (rien à envoyer). Les noms de champs miroir des `propertyPath` back
|
||||
* pour que `useFormErrors` mappe les 422 inline.
|
||||
*/
|
||||
function buildCreatePayload(): Record<string, unknown> {
|
||||
return {
|
||||
counterpartyType: counterpartyType.value,
|
||||
...counterpartyPayload(),
|
||||
immatriculation: immatriculation.value || null,
|
||||
plateFreeFormat: plateFreeFormat.value,
|
||||
emptyDate: empty.date || null,
|
||||
emptyWeight: empty.weight,
|
||||
emptyDsd: empty.dsd,
|
||||
emptyMode: empty.mode,
|
||||
emptyManualNumber: empty.manualNumber || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pré-remplit le formulaire à partir du détail d'un ticket existant (écran
|
||||
* Modification, ERP-190). Le numéro et le site sont immuables (RG-5.09) →
|
||||
* non repris dans l'état éditable (affichés en lecture seule par l'écran).
|
||||
* Les dates ISO du back (datetime) sont ramenées à `YYYY-MM-DD` pour MalioDate.
|
||||
*/
|
||||
function hydrate(detail: WeighingTicketHydration): void {
|
||||
ticketId.value = detail.id
|
||||
counterpartyType.value = detail.counterpartyType ?? null
|
||||
clientIri.value = detail.client?.['@id'] ?? null
|
||||
supplierIri.value = detail.supplier?.['@id'] ?? null
|
||||
otherLabel.value = detail.otherLabel ?? null
|
||||
immatriculation.value = detail.immatriculation ?? null
|
||||
plateFreeFormat.value = detail.plateFreeFormat ?? false
|
||||
|
||||
empty.date = isoDateOnly(detail.emptyDate) ?? today
|
||||
empty.weight = detail.emptyWeight ?? null
|
||||
empty.dsd = detail.emptyDsd ?? null
|
||||
empty.mode = detail.emptyMode ?? null
|
||||
empty.manualNumber = detail.emptyManualNumber ?? null
|
||||
|
||||
full.date = isoDateOnly(detail.fullDate) ?? today
|
||||
full.weight = detail.fullWeight ?? null
|
||||
full.dsd = detail.fullDsd ?? null
|
||||
full.mode = detail.fullMode ?? null
|
||||
full.manualNumber = detail.fullManualNumber ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de MODIFICATION (PATCH /weighing_tickets/{id}, ERP-190) : tous les
|
||||
* champs éditables (contrepartie + véhicule + les 2 pesées). Le numéro et le
|
||||
* site sont immuables (RG-5.09, ignorés par le back même si envoyés). Le net
|
||||
* est recalculé serveur (RG-5.05).
|
||||
*/
|
||||
function buildUpdatePayload(): Record<string, unknown> {
|
||||
return { ...buildCreatePayload(), ...buildFullPayload() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload de FINALISATION (PATCH /weighing_tickets/{id}, spec-back § 4.4) :
|
||||
* pesée à PLEIN. Le véhicule (immat / tout format) peut avoir été ajusté entre
|
||||
* les 2 blocs → on le repousse aussi (valeur partagée, RG-5.01). Le net est
|
||||
* recalculé serveur (RG-5.05).
|
||||
*/
|
||||
function buildFullPayload(): Record<string, unknown> {
|
||||
return {
|
||||
immatriculation: immatriculation.value || null,
|
||||
plateFreeFormat: plateFreeFormat.value,
|
||||
fullDate: full.date || null,
|
||||
fullWeight: full.weight,
|
||||
fullDsd: full.dsd,
|
||||
fullMode: full.mode,
|
||||
fullManualNumber: full.manualNumber || null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// contrepartie
|
||||
counterpartyType,
|
||||
counterpartyField,
|
||||
clientIri,
|
||||
supplierIri,
|
||||
otherLabel,
|
||||
setCounterpartyType,
|
||||
// véhicule partagé
|
||||
immatriculation,
|
||||
plateFreeFormat,
|
||||
// pesées
|
||||
empty,
|
||||
full,
|
||||
applyReading,
|
||||
// workflow
|
||||
ticketId,
|
||||
hydrate,
|
||||
buildCreatePayload,
|
||||
buildFullPayload,
|
||||
buildUpdatePayload,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Référentiels alimentant les selects de contrepartie de l'écran « Ticket de
|
||||
* pesée » (M5, ERP-189) : liste des clients (M1) et des fournisseurs (M2).
|
||||
*
|
||||
* Collections récupérées en entier via l'échappatoire `?pagination=false`
|
||||
* (référentiels de quelques dizaines d'entrées), avec l'en-tête
|
||||
* `Accept: application/ld+json` imposé par API Platform 4 pour obtenir
|
||||
* l'enveloppe Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`) —
|
||||
* renvoyée telle quelle dans le payload POST/PATCH (relation ManyToOne).
|
||||
*
|
||||
* Miroir de `useClientReferentials` (M1). État 100 % local à l'instance.
|
||||
*/
|
||||
|
||||
/** Option au format attendu par MalioSelect ({ label, value }). */
|
||||
export interface RefOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface PartyMember {
|
||||
'@id': string
|
||||
companyName: string
|
||||
}
|
||||
|
||||
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
|
||||
|
||||
export function useWeighingTicketReferentials() {
|
||||
const api = useApi()
|
||||
|
||||
const clients = ref<RefOption[]>([])
|
||||
const suppliers = ref<RefOption[]>([])
|
||||
|
||||
/** Récupère une collection complète (pagination désactivée) en Hydra. */
|
||||
async function fetchAll(url: string): Promise<PartyMember[]> {
|
||||
const res = await api.get<{ member?: PartyMember[] }>(
|
||||
url,
|
||||
{ pagination: 'false' },
|
||||
{ headers: LD_JSON_HEADERS, toast: false },
|
||||
)
|
||||
return res.member ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge en parallèle clients + fournisseurs (résilient : un référentiel en
|
||||
* échec — ex. 403 selon le rôle — laisse simplement son select vide sans
|
||||
* faire échouer l'autre).
|
||||
*/
|
||||
async function load(): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
fetchAll('/clients').then((list) => {
|
||||
clients.value = list.map(c => ({ value: c['@id'], label: c.companyName }))
|
||||
}),
|
||||
fetchAll('/suppliers').then((list) => {
|
||||
suppliers.value = list.map(s => ({ value: s['@id'], label: s.companyName }))
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
return { clients, suppliers, load }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
|
||||
/**
|
||||
* Vue MINIMALE d'une contrepartie embarquee (Client M1 ou Fournisseur M2) dans la
|
||||
* LISTE des tickets de pesee. Seul `companyName` alimente les colonnes
|
||||
* « Client » / « Fournisseur » ; l'objet sort embarque (`client:read` /
|
||||
* `supplier:read`) ou est carrement absent du JSON quand null (`skip_null_values`,
|
||||
* spec-back § 4.0.bis) — d'ou le `?? null` systematique cote page.
|
||||
*/
|
||||
export interface WeighingTicketParty {
|
||||
id: number
|
||||
companyName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue MINIMALE d'un ticket de pesee pour la datatable (M5, ERP-188). Volontairement
|
||||
* partielle : seuls les champs des colonnes (docx p.3) + l'id (navigation) sont
|
||||
* types. Le detail complet (pesees vide/plein, immatriculation, site, DSD) releve
|
||||
* de l'ecran Modification (ERP-190) — hors perimetre de cet ecran.
|
||||
*
|
||||
* Contrepartie mutuellement exclusive (RG-5.03) : un seul de `client` / `supplier`
|
||||
* / `otherLabel` est renseigne ; les deux autres sont omis du JSON (null).
|
||||
* `displayDate` = getter serveur `fullDate ?? emptyDate` (spec-back § 4.0).
|
||||
* `netWeight` = plein − vide en kg (RG-5.05).
|
||||
*/
|
||||
export interface WeighingTicket {
|
||||
id: number
|
||||
/** Numero metier `{siteCode}-TP-{NNNN}` attribue par site (RG-5.02). */
|
||||
number: string
|
||||
/** Embarque uniquement si contrepartie = Client (RG-5.03), sinon absent. */
|
||||
client: WeighingTicketParty | null
|
||||
/** Embarque uniquement si contrepartie = Fournisseur (RG-5.03), sinon absent. */
|
||||
supplier: WeighingTicketParty | null
|
||||
/** Libelle libre si contrepartie = Autre (RG-5.03), sinon absent. */
|
||||
otherLabel: string | null
|
||||
/** Date ISO du ticket (`fullDate ?? emptyDate`) — colonne « Date ». */
|
||||
displayDate: string | null
|
||||
/** Poids net en kg (= plein − vide, RG-5.05) — colonne « Poids ». */
|
||||
netWeight: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtres de la liste des tickets de pesee, branches sur les query params de
|
||||
* `GET /api/weighing_tickets` (spec-back § 4.1). La liste est par ailleurs
|
||||
* cloisonnee par site courant cote back (`SiteScopedQueryExtension`, § 2.3) — le
|
||||
* front n'a pas a envoyer le site.
|
||||
*/
|
||||
export interface WeighingTicketFilters {
|
||||
search?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des tickets de pesee (M5, ERP-188) — simple enveloppe de
|
||||
* `usePaginatedList<WeighingTicket>` sur la ressource `/weighing_tickets`
|
||||
* (URL API en snake_case ; la route Nuxt reste `/weighing-tickets`). Pagination
|
||||
* serveur obligatoire (regle ABSOLUE n°13), etat 100 % local (regle ABSOLUE n°6).
|
||||
*
|
||||
* Miroir de `useCarriersRepository` (M4). Volontairement PAR INSTANCE (pas de
|
||||
* singleton) : l'etat tableau est propre a l'ecran et meurt avec lui.
|
||||
*/
|
||||
export function useWeighingTicketsRepository() {
|
||||
return usePaginatedList<WeighingTicket, WeighingTicketFilters>({ url: '/weighing_tickets' })
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, reactive, Suspense } from 'vue'
|
||||
|
||||
// ── Mocks des composables modules (le form RÉEL est conservé pour vérifier le
|
||||
// pré-remplissage via hydrate). ─────────────────────────────────────────────
|
||||
const mockFetchTicket = vi.hoisted(() => vi.fn())
|
||||
const mockPatch = vi.hoisted(() => vi.fn())
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockOpen = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('~/modules/logistique/composables/useWeighingTicket', () => ({
|
||||
useWeighingTicket: () => ({ fetchTicket: mockFetchTicket }),
|
||||
}))
|
||||
vi.mock('~/modules/logistique/composables/useWeighingTicketReferentials', () => ({
|
||||
useWeighingTicketReferentials: () => ({ clients: ref([]), suppliers: ref([]), load: vi.fn().mockResolvedValue(undefined) }),
|
||||
}))
|
||||
vi.mock('~/modules/logistique/composables/useWeighbridge', () => ({
|
||||
useWeighbridge: () => ({ triggerAuto: vi.fn(), triggerManual: vi.fn(), extractWeighbridgeError: () => 'err' }),
|
||||
}))
|
||||
|
||||
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useHead', () => undefined)
|
||||
vi.stubGlobal('useApi', () => ({ get: vi.fn(), post: vi.fn(), patch: mockPatch }))
|
||||
vi.stubGlobal('useRoute', () => ({ params: { id: '9' } }))
|
||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||
vi.stubGlobal('usePermissions', () => ({ can: () => true }))
|
||||
vi.stubGlobal('navigateTo', vi.fn())
|
||||
vi.stubGlobal('useFormErrors', () => ({ errors: reactive({}), clearErrors: vi.fn(), handleApiError: vi.fn() }))
|
||||
globalThis.open = mockOpen
|
||||
|
||||
const EditPage = (await import('../weighing-tickets/[id]/edit.vue')).default
|
||||
|
||||
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||
const ButtonStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||
},
|
||||
})
|
||||
|
||||
const InputStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, modelValue: { default: null } },
|
||||
setup(props) {
|
||||
return () => h('input', { 'data-label': props.label, 'value': props.modelValue as string })
|
||||
},
|
||||
})
|
||||
|
||||
// WeighingBlock stubbe : rend le slot counterparty (présent sur le bloc vide).
|
||||
const BlockStub = defineComponent({
|
||||
setup(_, { slots }) { return () => h('div', { 'data-testid': 'block' }, slots.counterparty?.()) },
|
||||
})
|
||||
|
||||
const ModalStub = defineComponent({
|
||||
props: { modelValue: { type: Boolean, default: false } },
|
||||
setup(_, { slots }) { return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()]) },
|
||||
})
|
||||
|
||||
const stubs = {
|
||||
MalioButtonIcon: ButtonStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioInputText: InputStub,
|
||||
MalioInputNumber: InputStub,
|
||||
MalioSelect: InputStub,
|
||||
MalioDate: InputStub,
|
||||
MalioCheckbox: InputStub,
|
||||
MalioModal: ModalStub,
|
||||
WeighingBlock: BlockStub,
|
||||
}
|
||||
|
||||
// Monte la page (setup async : top-level await) via Suspense.
|
||||
async function mountPage() {
|
||||
const wrapper = mount(defineComponent({
|
||||
components: { EditPage },
|
||||
setup: () => () => h(Suspense, null, { default: () => h(EditPage) }),
|
||||
}), { global: { stubs } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const DETAIL = {
|
||||
id: 9,
|
||||
number: '86-TP-0001',
|
||||
site: { id: 1, name: 'Chatellerault', code: '86' },
|
||||
counterpartyType: 'CLIENT',
|
||||
client: { '@id': '/api/clients/629', companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
|
||||
immatriculation: 'AB-123-CD',
|
||||
plateFreeFormat: false,
|
||||
emptyDate: '2026-06-17T09:00:00+02:00', emptyWeight: 7150, emptyDsd: 1, emptyMode: 'AUTO',
|
||||
fullDate: '2026-06-17T09:12:00+02:00', fullWeight: 14300, fullDsd: 2, fullMode: 'AUTO',
|
||||
}
|
||||
|
||||
describe('Écran Modification ticket de pesée (page /weighing-tickets/{id}/edit)', () => {
|
||||
beforeEach(() => {
|
||||
mockFetchTicket.mockReset().mockResolvedValue({ ...DETAIL })
|
||||
mockPatch.mockReset().mockResolvedValue({})
|
||||
mockPush.mockReset()
|
||||
mockOpen.mockReset()
|
||||
})
|
||||
|
||||
it('pré-remplit le numéro et le site en lecture seule (RG-5.09)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
expect(mockFetchTicket).toHaveBeenCalledWith('9')
|
||||
expect(wrapper.find('[data-label="logistique.weighingTickets.form.number"]').attributes('value')).toBe('86-TP-0001')
|
||||
expect(wrapper.find('[data-label="logistique.weighingTickets.form.site"]').attributes('value')).toBe('Chatellerault')
|
||||
})
|
||||
|
||||
it('bascule des boutons : « Enregistrer » + « Imprimer » présents, pas de « Valider »', async () => {
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.find('[data-label="logistique.weighingTickets.form.save"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-label="logistique.weighingTickets.form.print"]').exists()).toBe(true)
|
||||
// « Valider » est le bouton de l'écran d'AJOUT — absent en modification (RG-5.08).
|
||||
expect(wrapper.find('[data-label="logistique.weighingTickets.form.validate"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('« Imprimer » ouvre le bon de pesée PDF servi par le back (RG-5.08)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.find('[data-label="logistique.weighingTickets.form.print"]').trigger('click')
|
||||
expect(mockOpen).toHaveBeenCalledWith('/api/weighing_tickets/9/print.pdf', '_blank')
|
||||
})
|
||||
|
||||
it('« Enregistrer » PATCH le ticket puis revient à la liste', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await wrapper.find('[data-label="logistique.weighingTickets.form.save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/weighing_tickets/9',
|
||||
expect.objectContaining({ counterpartyType: 'CLIENT', client: '/api/clients/629', fullWeight: 14300 }),
|
||||
expect.objectContaining({ toast: false }),
|
||||
)
|
||||
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
|
||||
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
|
||||
// La page ne les importe pas (auto-import) : on les expose en globals pour le
|
||||
// runtime de test (happy-dom). Meme philosophie que les specs M1→M4.
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockApiGet = vi.hoisted(() => vi.fn())
|
||||
const mockCan = vi.hoisted(() => vi.fn())
|
||||
const mockFetch = vi.hoisted(() => vi.fn())
|
||||
const mockReset = vi.hoisted(() => vi.fn())
|
||||
const mockToastError = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('useHead', () => undefined)
|
||||
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
|
||||
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
|
||||
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
|
||||
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
|
||||
// Site courant (switcher global) : ref pilotable pour simuler un changement de site.
|
||||
const currentSiteRef = ref<{ id: number } | null>(null)
|
||||
vi.stubGlobal('useCurrentSite', () => ({ currentSite: currentSiteRef }))
|
||||
|
||||
// Le repository est lui aussi un auto-import : on controle les items renvoyes.
|
||||
// Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values).
|
||||
vi.stubGlobal('useWeighingTicketsRepository', () => ({
|
||||
items: ref([
|
||||
{
|
||||
id: 9,
|
||||
number: '86-TP-0001',
|
||||
client: { id: 629, companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' },
|
||||
supplier: null,
|
||||
otherLabel: null,
|
||||
displayDate: '2026-06-17T09:12:00+02:00',
|
||||
netWeight: 7150,
|
||||
},
|
||||
]),
|
||||
totalItems: ref(1),
|
||||
currentPage: ref(1),
|
||||
itemsPerPage: ref(10),
|
||||
itemsPerPageOptions: ref([10, 25, 50]),
|
||||
fetch: mockFetch,
|
||||
goToPage: vi.fn(),
|
||||
setItemsPerPage: vi.fn(),
|
||||
setFilters: vi.fn(),
|
||||
reset: mockReset,
|
||||
}))
|
||||
|
||||
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
|
||||
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
|
||||
const WeighingTicketsIndex = (await import('../weighing-tickets/index.vue')).default
|
||||
|
||||
// ── Stubs de composants ──────────────────────────────────────────────────────
|
||||
const ButtonStub = defineComponent({
|
||||
props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } },
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
|
||||
},
|
||||
})
|
||||
|
||||
// Capture les `items` (rows) passes par la page : on rend chaque ligne avec ses
|
||||
// cellules formatees (date / poids) pour pouvoir asserter le mapping des colonnes.
|
||||
const capturedRows = ref<Array<Record<string, unknown>>>([])
|
||||
const DataTableStub = defineComponent({
|
||||
props: { items: { type: Array, default: () => [] } },
|
||||
emits: ['row-click', 'update:page', 'update:per-page'],
|
||||
setup(props, { emit }) {
|
||||
return () => {
|
||||
capturedRows.value = props.items as Array<Record<string, unknown>>
|
||||
return h('div', { 'data-testid': 'datatable' },
|
||||
(props.items as Array<Record<string, unknown>>).map(it =>
|
||||
h('tr', { 'data-row-id': it.id as number, onClick: () => emit('row-click', it) }, [
|
||||
h('td', { 'data-cell': 'displayDate' }, it.displayDate as string),
|
||||
h('td', { 'data-cell': 'netWeight' }, it.netWeight as string),
|
||||
h('td', { 'data-cell': 'client' }, it.client as string),
|
||||
h('td', { 'data-cell': 'supplier' }, it.supplier as string),
|
||||
]),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const PageHeaderStub = defineComponent({
|
||||
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
|
||||
})
|
||||
|
||||
// Suivi des wrappers montés pour les démonter entre tests : sans cela, les
|
||||
// watchers sur la ref module-level `currentSiteRef` (site courant) fuiteraient
|
||||
// d'un test à l'autre et se déclencheraient en double.
|
||||
const mountedWrappers: VueWrapper[] = []
|
||||
|
||||
function mountPage() {
|
||||
const wrapper = mount(WeighingTicketsIndex, {
|
||||
global: {
|
||||
stubs: {
|
||||
PageHeader: PageHeaderStub,
|
||||
MalioButton: ButtonStub,
|
||||
MalioDataTable: DataTableStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('Liste des tickets de pesée (page /weighing-tickets)', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockReset()
|
||||
mockApiGet.mockReset().mockResolvedValue(new Blob())
|
||||
mockCan.mockReset().mockReturnValue(true)
|
||||
mockFetch.mockReset()
|
||||
mockReset.mockReset()
|
||||
mockToastError.mockReset()
|
||||
capturedRows.value = []
|
||||
currentSiteRef.value = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Démonte les composants montés → libère leurs watchers (site courant).
|
||||
while (mountedWrappers.length > 0) {
|
||||
mountedWrappers.pop()?.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('charge la liste au montage', async () => {
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
expect(mockFetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recharge la liste (page 1) quand le site courant change', async () => {
|
||||
mountPage()
|
||||
await flushPromises()
|
||||
expect(mockReset).not.toHaveBeenCalled()
|
||||
|
||||
// Simule un switch de site via le switcher global.
|
||||
currentSiteRef.value = { id: 2 }
|
||||
await flushPromises()
|
||||
expect(mockReset).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('formate la date au format JJ-MM-AAAA', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-cell="displayDate"]').text()).toBe('17-06-2026')
|
||||
})
|
||||
|
||||
it('formate le poids net en kg avec separateur de milliers', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-cell="netWeight"]').text()).toBe('7 150 Kg')
|
||||
})
|
||||
|
||||
it('mappe la contrepartie Client (supplier vide car contrepartie ≠ Fournisseur)', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-cell="client"]').text()).toBe('NÉGOCE MÉTAUX ATLANTIQUE')
|
||||
expect(wrapper.find('[data-cell="supplier"]').text()).toBe('')
|
||||
})
|
||||
|
||||
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.manage')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
|
||||
mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.view')
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('navigue vers la modification au clic sur une ligne', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('tr[data-row-id="9"]').trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith('/weighing-tickets/9/edit')
|
||||
})
|
||||
|
||||
it('appelle l\'export XLSX sur /weighing_tickets/export.xlsx en blob', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-label="logistique.weighingTickets.export"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mockApiGet).toHaveBeenCalledWith(
|
||||
'/weighing_tickets/export.xlsx',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ responseType: 'blob', toast: false }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tête : retour vers la liste + titre. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('logistique.weighingTickets.form.back')"
|
||||
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- États de chargement / introuvable. -->
|
||||
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('logistique.weighingTickets.edit.loading') }}</p>
|
||||
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('logistique.weighingTickets.edit.notFound') }}</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- Numéro + site : lecture seule, immuables (RG-5.09). -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
:model-value="ticketNumber"
|
||||
:label="t('logistique.weighingTickets.form.number')"
|
||||
:readonly="true"
|
||||
:disabled="true"
|
||||
/>
|
||||
<MalioInputText
|
||||
:model-value="siteName"
|
||||
:label="t('logistique.weighingTickets.form.site')"
|
||||
:readonly="true"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<!-- ── Bloc « Poids à vide » (porte la contrepartie, RG-5.03) ──── -->
|
||||
<WeighingBlock
|
||||
block-id="empty"
|
||||
:title="t('logistique.weighingTickets.form.emptyBlock')"
|
||||
:block="form.empty"
|
||||
:immatriculation="form.immatriculation.value"
|
||||
:plate-free-format="form.plateFreeFormat.value"
|
||||
:errors="emptyBlockErrors"
|
||||
@update:block="(field, value) => updateBlock('empty', field, value)"
|
||||
@update:immatriculation="(v) => form.immatriculation.value = v"
|
||||
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
|
||||
@request-auto="openAuto('empty')"
|
||||
@request-manual="openManual('empty')"
|
||||
>
|
||||
<template #counterparty>
|
||||
<MalioSelect
|
||||
:model-value="form.counterpartyType.value"
|
||||
:options="counterpartyOptions"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.type')"
|
||||
:required="true"
|
||||
empty-option-label=""
|
||||
:error="errors.counterpartyType"
|
||||
@update:model-value="onCounterpartyTypeChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="form.counterpartyField.value === 'supplier'"
|
||||
:model-value="form.supplierIri.value"
|
||||
:options="referentials.suppliers.value"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
|
||||
:required="true"
|
||||
empty-option-label=""
|
||||
:error="errors.supplier"
|
||||
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-else-if="form.counterpartyField.value === 'client'"
|
||||
:model-value="form.clientIri.value"
|
||||
:options="referentials.clients.value"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.client')"
|
||||
:required="true"
|
||||
empty-option-label=""
|
||||
:error="errors.client"
|
||||
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else-if="form.counterpartyField.value === 'other'"
|
||||
:model-value="form.otherLabel.value"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.other')"
|
||||
:required="true"
|
||||
:error="errors.otherLabel"
|
||||
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
||||
/>
|
||||
</template>
|
||||
</WeighingBlock>
|
||||
|
||||
<!-- Bloc « Poids à plein » : le bouton « Enregistrer » du bloc vide
|
||||
DISPARAÎT en modification (RG-5.08) — on enregistre via le bas. -->
|
||||
<WeighingBlock
|
||||
block-id="full"
|
||||
:title="t('logistique.weighingTickets.form.fullBlock')"
|
||||
:block="form.full"
|
||||
:immatriculation="form.immatriculation.value"
|
||||
:plate-free-format="form.plateFreeFormat.value"
|
||||
:errors="fullBlockErrors"
|
||||
@update:block="(field, value) => updateBlock('full', field, value)"
|
||||
@update:immatriculation="(v) => form.immatriculation.value = v"
|
||||
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
|
||||
@request-auto="openAuto('full')"
|
||||
@request-manual="openManual('full')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bas d'écran : « Enregistrer » (remplace « Valider », RG-5.08) +
|
||||
« Imprimer » (absent à l'ajout, RG-5.08). -->
|
||||
<div class="mt-12 flex justify-center gap-6">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
icon-name="mdi:printer-outline"
|
||||
icon-position="left"
|
||||
:label="t('logistique.weighingTickets.form.print')"
|
||||
@click="printTicket"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('logistique.weighingTickets.form.save')"
|
||||
:disabled="saving"
|
||||
@click="submitSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
||||
<MalioModal v-model="autoModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
||||
</template>
|
||||
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p>
|
||||
<p v-if="autoModal.error" class="mt-4 text-m-danger">{{ autoModal.error }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('logistique.weighingTickets.form.weighbridge.cancel')"
|
||||
@click="autoModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="flex-1"
|
||||
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
|
||||
:disabled="autoModal.loading"
|
||||
@click="confirmAuto"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
|
||||
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
|
||||
<MalioModal v-model="manualModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<MalioInputNumber
|
||||
v-model="manualModal.weight"
|
||||
:label="t('logistique.weighingTickets.form.manual.weight')"
|
||||
:required="true"
|
||||
:min="0"
|
||||
:error="manualModal.errors.weight"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="manualModal.manualNumber"
|
||||
:label="t('logistique.weighingTickets.form.manual.number')"
|
||||
:required="true"
|
||||
:error="manualModal.errors.manualNumber"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('logistique.weighingTickets.form.manual.cancel')"
|
||||
@click="manualModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="flex-1"
|
||||
:label="t('logistique.weighingTickets.form.manual.save')"
|
||||
:disabled="manualModal.loading"
|
||||
@click="confirmManual"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||
import { useWeighingTicket } from '~/modules/logistique/composables/useWeighingTicket'
|
||||
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
// Modification réservée à `manage` (Admin / Bureau / Usine) — sinon retour liste.
|
||||
if (!can('logistique.weighing_tickets.manage')) {
|
||||
await navigateTo('/weighing-tickets')
|
||||
}
|
||||
|
||||
const ticketId = route.params.id as string
|
||||
|
||||
const form = useWeighingTicketForm()
|
||||
const weighbridge = useWeighbridge()
|
||||
const referentials = useWeighingTicketReferentials()
|
||||
const { fetchTicket } = useWeighingTicket()
|
||||
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
// Numéro + site immuables (RG-5.09), affichés en lecture seule.
|
||||
const ticketNumber = ref<string>('')
|
||||
const siteName = ref<string>('')
|
||||
|
||||
const headerTitle = computed(() =>
|
||||
ticketNumber.value
|
||||
? t('logistique.weighingTickets.edit.title', { number: ticketNumber.value })
|
||||
: t('logistique.weighingTickets.edit.titleFallback'),
|
||||
)
|
||||
|
||||
useHead({ title: t('logistique.weighingTickets.edit.titleFallback') })
|
||||
|
||||
/** Retour vers la liste (flèche d'en-tête). */
|
||||
function goBack(): void {
|
||||
router.push('/weighing-tickets')
|
||||
}
|
||||
|
||||
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────────
|
||||
const counterpartyOptions = computed<RefOption[]>(() => [
|
||||
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
|
||||
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
|
||||
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
|
||||
])
|
||||
|
||||
function onCounterpartyTypeChange(value: string | number | null): void {
|
||||
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
|
||||
form.setCounterpartyType(type)
|
||||
}
|
||||
|
||||
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
|
||||
const emptyBlockErrors = computed<Record<string, string>>(() => ({
|
||||
date: errors.emptyDate,
|
||||
weight: errors.emptyWeight,
|
||||
dsd: errors.emptyDsd,
|
||||
immatriculation: errors.immatriculation,
|
||||
}))
|
||||
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||
date: errors.fullDate,
|
||||
weight: errors.fullWeight,
|
||||
dsd: errors.fullDsd,
|
||||
immatriculation: errors.immatriculation,
|
||||
}))
|
||||
|
||||
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
|
||||
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
|
||||
(form[target] as Record<string, unknown>)[field as string] = value
|
||||
}
|
||||
|
||||
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
|
||||
const autoModal = reactive({
|
||||
open: false,
|
||||
error: '',
|
||||
loading: false,
|
||||
target: 'empty' as 'empty' | 'full',
|
||||
})
|
||||
|
||||
function openAuto(target: 'empty' | 'full'): void {
|
||||
autoModal.target = target
|
||||
autoModal.error = ''
|
||||
autoModal.open = true
|
||||
}
|
||||
|
||||
async function confirmAuto(): Promise<void> {
|
||||
if (autoModal.loading) return
|
||||
autoModal.loading = true
|
||||
autoModal.error = ''
|
||||
try {
|
||||
const reading = await weighbridge.triggerAuto()
|
||||
form.applyReading(form[autoModal.target], reading)
|
||||
autoModal.open = false
|
||||
}
|
||||
catch (e) {
|
||||
autoModal.error = weighbridge.extractWeighbridgeError(e)
|
||||
}
|
||||
finally {
|
||||
autoModal.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
|
||||
const manualModal = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
target: 'empty' as 'empty' | 'full',
|
||||
weight: null as string | number | null,
|
||||
manualNumber: null as string | null,
|
||||
errors: {} as Record<string, string>,
|
||||
})
|
||||
|
||||
function openManual(target: 'empty' | 'full'): void {
|
||||
manualModal.target = target
|
||||
manualModal.weight = null
|
||||
manualModal.manualNumber = null
|
||||
manualModal.errors = {}
|
||||
manualModal.open = true
|
||||
}
|
||||
|
||||
async function confirmManual(): Promise<void> {
|
||||
if (manualModal.loading) return
|
||||
manualModal.errors = {}
|
||||
|
||||
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
|
||||
const manualNumber = (manualModal.manualNumber ?? '').trim()
|
||||
if (weight === null || Number.isNaN(weight)) {
|
||||
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
|
||||
}
|
||||
if (manualNumber === '') {
|
||||
manualModal.errors = { ...manualModal.errors, manualNumber: t('logistique.weighingTickets.form.manual.numberRequired') }
|
||||
}
|
||||
if (Object.keys(manualModal.errors).length > 0) return
|
||||
|
||||
manualModal.loading = true
|
||||
try {
|
||||
const reading = await weighbridge.triggerManual(weight as number, manualNumber)
|
||||
form.applyReading(form[manualModal.target], reading)
|
||||
manualModal.open = false
|
||||
}
|
||||
catch (e) {
|
||||
manualModal.errors = { weight: weighbridge.extractWeighbridgeError(e) }
|
||||
}
|
||||
finally {
|
||||
manualModal.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Soumission / impression ───────────────────────────────────────────────────
|
||||
/** « Enregistrer » : PATCH /weighing_tickets/{id} (recalcul net serveur, RG-5.05). */
|
||||
async function submitSave(): Promise<void> {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
clearErrors()
|
||||
try {
|
||||
await api.patch(`/weighing_tickets/${ticketId}`, form.buildUpdatePayload(), { toast: false })
|
||||
router.push('/weighing-tickets')
|
||||
}
|
||||
catch (e) {
|
||||
handleApiError(e, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* « Imprimer » : ouvre le bon de pesée PDF servi par le back (Twig, ERP-192).
|
||||
* Le front ne dessine AUCUN gabarit — il ouvre seulement l'URL (RG-5.08).
|
||||
*/
|
||||
function printTicket(): void {
|
||||
window.open(`/api/weighing_tickets/${ticketId}/print.pdf`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Référentiels (selects contrepartie) en parallèle, non bloquants.
|
||||
referentials.load().catch(() => {})
|
||||
try {
|
||||
const detail = await fetchTicket(ticketId)
|
||||
ticketNumber.value = detail.number
|
||||
siteName.value = detail.site?.name ?? ''
|
||||
form.hydrate(detail)
|
||||
}
|
||||
catch {
|
||||
error.value = true
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('logistique.weighingTickets.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('logistique.weighingTickets.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Datatable branchee sur usePaginatedList via useWeighingTicketsRepository :
|
||||
pagination serveur (defaut 10), tri number DESC par defaut (cote back),
|
||||
liste cloisonnee par site courant (spec-back § 2.3). Etat 100 % local
|
||||
(regle ABSOLUE n°6). -->
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
:empty-message="t('logistique.weighingTickets.empty')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
/>
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<MalioButton
|
||||
v-if="canView"
|
||||
variant="primary"
|
||||
:label="t('logistique.weighingTickets.export')"
|
||||
:disabled="exporting"
|
||||
@click="exportXlsx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { formatDateFr, formatWeightKg } from '~/modules/logistique/utils/weighingTicketFormat'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { can } = usePermissions()
|
||||
// Site courant (switcher global) : la liste est cloisonnée par site côté back
|
||||
// (spec-back § 2.3). Le front n'envoie PAS le site (résolu serveur) — il se
|
||||
// contente de recharger quand le site change pour refléter le bon périmètre.
|
||||
const { currentSite } = useCurrentSite()
|
||||
|
||||
useHead({ title: t('logistique.weighingTickets.title') })
|
||||
|
||||
// Bouton « + Ajouter » reserve a `manage` (Admin / Bureau / Usine). « Exporter »
|
||||
// suit `view`. Compta et Commerciale n'ont aucun acces (item sidebar masque cote
|
||||
// back) — spec-front § Acces.
|
||||
const canManage = computed(() => can('logistique.weighing_tickets.manage'))
|
||||
const canView = computed(() => can('logistique.weighing_tickets.view'))
|
||||
|
||||
const {
|
||||
items: tickets,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadTickets,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
reset: reloadFromFirstPage,
|
||||
} = useWeighingTicketsRepository()
|
||||
|
||||
// Mappe les tickets en objets « plats » formates pour MalioDataTable (items typees
|
||||
// Record<string, unknown>[]). La contrepartie est mutuellement exclusive (RG-5.03) :
|
||||
// une seule des colonnes client / supplier / otherLabel est renseignee, les autres
|
||||
// restent vides. Date et poids sont formates ici (cf. helpers ci-dessous).
|
||||
const rows = computed(() => tickets.value.map(ticket => ({
|
||||
id: ticket.id,
|
||||
number: ticket.number,
|
||||
client: ticket.client?.companyName ?? '',
|
||||
supplier: ticket.supplier?.companyName ?? '',
|
||||
otherLabel: ticket.otherLabel ?? '',
|
||||
displayDate: formatDateFr(ticket.displayDate),
|
||||
netWeight: formatWeightKg(ticket.netWeight),
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'number', label: t('logistique.weighingTickets.column.number') },
|
||||
{ key: 'client', label: t('logistique.weighingTickets.column.client') },
|
||||
{ key: 'supplier', label: t('logistique.weighingTickets.column.supplier') },
|
||||
{ key: 'otherLabel', label: t('logistique.weighingTickets.column.other') },
|
||||
{ key: 'displayDate', label: t('logistique.weighingTickets.column.date') },
|
||||
{ key: 'netWeight', label: t('logistique.weighingTickets.column.weight') },
|
||||
]
|
||||
|
||||
/** Clic sur une ligne → ecran Modification (pas de consultation separee, spec § Navigation). */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/weighing-tickets/${item.id}/edit`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/weighing-tickets/new')
|
||||
}
|
||||
|
||||
// ── Export XLSX ─────────────────────────────────────────────────────────────
|
||||
// Exporte toute la liste (site courant applique cote back, spec-back § 4.5).
|
||||
const exporting = ref(false)
|
||||
|
||||
async function exportXlsx(): Promise<void> {
|
||||
if (exporting.value) {
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
try {
|
||||
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
|
||||
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
|
||||
// contenu faute d'overload blob sur le client partage (meme pattern M2/M3/M4).
|
||||
const blob = await api.get<Blob>('/weighing_tickets/export.xlsx', {}, {
|
||||
responseType: 'blob',
|
||||
toast: false,
|
||||
} as unknown as Parameters<typeof api.get>[2])
|
||||
|
||||
triggerDownload(blob, 'tickets-pesee.xlsx')
|
||||
}
|
||||
catch {
|
||||
toast.error({
|
||||
title: t('logistique.weighingTickets.toast.error'),
|
||||
message: t('logistique.weighingTickets.toast.exportError'),
|
||||
})
|
||||
}
|
||||
finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Declenche le telechargement d'un blob via un lien temporaire. */
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Changement de site courant → recharge la liste en page 1 (nouveau périmètre).
|
||||
// usePaginatedList ne passe pas par useAsyncData : le refreshNuxtData() de
|
||||
// switchSite ne l'atteint pas, d'où ce watcher explicite. On compare l'id pour
|
||||
// ignorer l'hydratation initiale (même site) et les ré-affectations sans réel
|
||||
// changement.
|
||||
watch(() => currentSite.value?.id, (id, previousId) => {
|
||||
if (id !== previousId) {
|
||||
reloadFromFirstPage()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(loadTickets)
|
||||
</script>
|
||||
@@ -0,0 +1,375 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- En-tête : retour vers la liste + titre. -->
|
||||
<div class="flex items-center gap-3 pt-11">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('logistique.weighingTickets.form.back')"
|
||||
v-bind="{ ariaLabel: t('logistique.weighingTickets.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('logistique.weighingTickets.form.addTitle') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-[48px] flex flex-col gap-8">
|
||||
<!-- ── Bloc « Poids à vide » (porte la contrepartie, RG-5.03) ──────── -->
|
||||
<WeighingBlock
|
||||
block-id="empty"
|
||||
:title="t('logistique.weighingTickets.form.emptyBlock')"
|
||||
:block="form.empty"
|
||||
:immatriculation="form.immatriculation.value"
|
||||
:plate-free-format="form.plateFreeFormat.value"
|
||||
:errors="emptyBlockErrors"
|
||||
:disabled="emptyLocked"
|
||||
@update:block="(field, value) => updateBlock('empty', field, value)"
|
||||
@update:immatriculation="(v) => form.immatriculation.value = v"
|
||||
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
|
||||
@request-auto="openAuto('empty')"
|
||||
@request-manual="openManual('empty')"
|
||||
>
|
||||
<!-- Contrepartie : sélecteur + champ conditionnel (RG-5.03). -->
|
||||
<template #counterparty>
|
||||
<MalioSelect
|
||||
:model-value="form.counterpartyType.value"
|
||||
:options="counterpartyOptions"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.type')"
|
||||
:required="true"
|
||||
:disabled="emptyLocked"
|
||||
empty-option-label=""
|
||||
:error="errors.counterpartyType"
|
||||
@update:model-value="onCounterpartyTypeChange"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="form.counterpartyField.value === 'supplier'"
|
||||
:model-value="form.supplierIri.value"
|
||||
:options="referentials.suppliers.value"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.supplier')"
|
||||
:required="true"
|
||||
:disabled="emptyLocked"
|
||||
empty-option-label=""
|
||||
:error="errors.supplier"
|
||||
@update:model-value="(v: string | number | null) => form.supplierIri.value = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-else-if="form.counterpartyField.value === 'client'"
|
||||
:model-value="form.clientIri.value"
|
||||
:options="referentials.clients.value"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.client')"
|
||||
:required="true"
|
||||
:disabled="emptyLocked"
|
||||
empty-option-label=""
|
||||
:error="errors.client"
|
||||
@update:model-value="(v: string | number | null) => form.clientIri.value = v === null ? null : String(v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else-if="form.counterpartyField.value === 'other'"
|
||||
:model-value="form.otherLabel.value"
|
||||
:label="t('logistique.weighingTickets.form.counterparty.other')"
|
||||
:required="true"
|
||||
:disabled="emptyLocked"
|
||||
:error="errors.otherLabel"
|
||||
@update:model-value="(v: string | null) => form.otherLabel.value = v"
|
||||
/>
|
||||
</template>
|
||||
</WeighingBlock>
|
||||
|
||||
<!-- « Enregistrer » du bloc vide : POST initial du ticket (disparaît une
|
||||
fois le ticket créé — RG-5.08). -->
|
||||
<div v-if="form.ticketId.value === null" class="flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('logistique.weighingTickets.form.save')"
|
||||
:disabled="creating"
|
||||
@click="submitCreate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Bloc « Poids à plein » ───────────────────────────────────────-->
|
||||
<WeighingBlock
|
||||
block-id="full"
|
||||
:title="t('logistique.weighingTickets.form.fullBlock')"
|
||||
:block="form.full"
|
||||
:immatriculation="form.immatriculation.value"
|
||||
:plate-free-format="form.plateFreeFormat.value"
|
||||
:errors="fullBlockErrors"
|
||||
@update:block="(field, value) => updateBlock('full', field, value)"
|
||||
@update:immatriculation="(v) => form.immatriculation.value = v"
|
||||
@update:plate-free-format="(v) => form.plateFreeFormat.value = v"
|
||||
@request-auto="openAuto('full')"
|
||||
@request-manual="openManual('full')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- « Valider » (bas d'écran) : PATCH de la pesée à plein puis ouverture du
|
||||
bon de pesée PDF (RG-5.08). Indisponible tant que le ticket n'est pas créé. -->
|
||||
<div class="mt-12 flex justify-center">
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('logistique.weighingTickets.form.validate')"
|
||||
:disabled="validating || form.ticketId.value === null"
|
||||
@click="submitValidate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Modal « Confirmation pesée bascule » (RG-5.06) ──────────────────-->
|
||||
<MalioModal v-model="autoModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.weighbridge.confirmTitle') }}</h2>
|
||||
</template>
|
||||
<p>{{ t('logistique.weighingTickets.form.weighbridge.confirmMessage') }}</p>
|
||||
<!-- Erreur de pont indisponible affichée INLINE dans la modal + invite
|
||||
à la pesée manuelle (RG-5.06). -->
|
||||
<p v-if="autoModal.error" class="mt-4 text-m-danger">{{ autoModal.error }}</p>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('logistique.weighingTickets.form.weighbridge.cancel')"
|
||||
@click="autoModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="flex-1"
|
||||
:label="t('logistique.weighingTickets.form.weighbridge.validate')"
|
||||
:disabled="autoModal.loading"
|
||||
@click="confirmAuto"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
|
||||
<!-- ── Modal « Pesée manuelle » ────────────────────────────────────────-->
|
||||
<MalioModal v-model="manualModal.open" modal-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-[24px] font-bold">{{ t('logistique.weighingTickets.form.manual.title') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<MalioInputNumber
|
||||
v-model="manualModal.weight"
|
||||
:label="t('logistique.weighingTickets.form.manual.weight')"
|
||||
:required="true"
|
||||
:min="0"
|
||||
:error="manualModal.errors.weight"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="manualModal.manualNumber"
|
||||
:label="t('logistique.weighingTickets.form.manual.number')"
|
||||
:required="true"
|
||||
:error="manualModal.errors.manualNumber"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="flex-1"
|
||||
:label="t('logistique.weighingTickets.form.manual.cancel')"
|
||||
@click="manualModal.open = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
button-class="flex-1"
|
||||
:label="t('logistique.weighingTickets.form.manual.save')"
|
||||
:disabled="manualModal.loading"
|
||||
@click="confirmManual"
|
||||
/>
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useWeighingTicketForm, type WeighingBlockState } from '~/modules/logistique/composables/useWeighingTicketForm'
|
||||
import { useWeighbridge } from '~/modules/logistique/composables/useWeighbridge'
|
||||
import { useWeighingTicketReferentials, type RefOption } from '~/modules/logistique/composables/useWeighingTicketReferentials'
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('logistique.weighingTickets.form.addTitle') })
|
||||
|
||||
// Création réservée à `manage` (Admin / Bureau / Usine) — sinon retour à la liste.
|
||||
if (!can('logistique.weighing_tickets.manage')) {
|
||||
await navigateTo('/weighing-tickets')
|
||||
}
|
||||
|
||||
const form = useWeighingTicketForm()
|
||||
const weighbridge = useWeighbridge()
|
||||
const referentials = useWeighingTicketReferentials()
|
||||
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||
|
||||
// Le bloc vide se verrouille une fois le ticket créé (numéro/site attribués).
|
||||
const emptyLocked = computed(() => form.ticketId.value !== null)
|
||||
|
||||
const creating = ref(false)
|
||||
const validating = ref(false)
|
||||
|
||||
/** Retour vers la liste (flèche d'en-tête). */
|
||||
function goBack(): void {
|
||||
router.push('/weighing-tickets')
|
||||
}
|
||||
|
||||
// ── Contrepartie (RG-5.03) ───────────────────────────────────────────────────
|
||||
// Ordre maquette : Fournisseur / Client / Autre.
|
||||
const counterpartyOptions = computed<RefOption[]>(() => [
|
||||
{ value: 'FOURNISSEUR', label: t('logistique.weighingTickets.form.counterparty.supplier') },
|
||||
{ value: 'CLIENT', label: t('logistique.weighingTickets.form.counterparty.client') },
|
||||
{ value: 'AUTRE', label: t('logistique.weighingTickets.form.counterparty.other') },
|
||||
])
|
||||
|
||||
function onCounterpartyTypeChange(value: string | number | null): void {
|
||||
const type = (value === null || value === '') ? null : (String(value) as 'CLIENT' | 'FOURNISSEUR' | 'AUTRE')
|
||||
form.setCounterpartyType(type)
|
||||
}
|
||||
|
||||
// ── Erreurs par bloc (mapping propertyPath back → champs du composant) ────────
|
||||
const emptyBlockErrors = computed<Record<string, string>>(() => ({
|
||||
date: errors.emptyDate,
|
||||
weight: errors.emptyWeight,
|
||||
dsd: errors.emptyDsd,
|
||||
immatriculation: errors.immatriculation,
|
||||
}))
|
||||
const fullBlockErrors = computed<Record<string, string>>(() => ({
|
||||
date: errors.fullDate,
|
||||
weight: errors.fullWeight,
|
||||
dsd: errors.fullDsd,
|
||||
immatriculation: errors.immatriculation,
|
||||
}))
|
||||
|
||||
/** Mute un champ d'un bloc de pesée (état centralisé dans le form). */
|
||||
function updateBlock(target: 'empty' | 'full', field: keyof WeighingBlockState, value: unknown): void {
|
||||
// Affectation typée via l'index du bloc reactif (date est le seul champ éditable).
|
||||
(form[target] as Record<string, unknown>)[field as string] = value
|
||||
}
|
||||
|
||||
// ── Modal pesée bascule (AUTO) ────────────────────────────────────────────────
|
||||
const autoModal = reactive({
|
||||
open: false,
|
||||
error: '',
|
||||
loading: false,
|
||||
target: 'empty' as 'empty' | 'full',
|
||||
})
|
||||
|
||||
function openAuto(target: 'empty' | 'full'): void {
|
||||
autoModal.target = target
|
||||
autoModal.error = ''
|
||||
autoModal.open = true
|
||||
}
|
||||
|
||||
/** Déclenche la pesée bascule ; erreur (RG-5.06) affichée dans la modal. */
|
||||
async function confirmAuto(): Promise<void> {
|
||||
if (autoModal.loading) return
|
||||
autoModal.loading = true
|
||||
autoModal.error = ''
|
||||
try {
|
||||
const reading = await weighbridge.triggerAuto()
|
||||
form.applyReading(form[autoModal.target], reading)
|
||||
autoModal.open = false
|
||||
}
|
||||
catch (error) {
|
||||
// Pont indisponible : message inline + invite à la pesée manuelle.
|
||||
autoModal.error = weighbridge.extractWeighbridgeError(error)
|
||||
}
|
||||
finally {
|
||||
autoModal.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal pesée manuelle (MANUAL) ─────────────────────────────────────────────
|
||||
const manualModal = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
target: 'empty' as 'empty' | 'full',
|
||||
weight: null as string | number | null,
|
||||
manualNumber: null as string | null,
|
||||
errors: {} as Record<string, string>,
|
||||
})
|
||||
|
||||
function openManual(target: 'empty' | 'full'): void {
|
||||
manualModal.target = target
|
||||
manualModal.weight = null
|
||||
manualModal.manualNumber = null
|
||||
manualModal.errors = {}
|
||||
manualModal.open = true
|
||||
}
|
||||
|
||||
/** Valide la saisie manuelle puis remplit le bloc (DSD calculé serveur, RG-5.04). */
|
||||
async function confirmManual(): Promise<void> {
|
||||
if (manualModal.loading) return
|
||||
manualModal.errors = {}
|
||||
|
||||
const weight = manualModal.weight === null || manualModal.weight === '' ? null : Number(manualModal.weight)
|
||||
const manualNumber = (manualModal.manualNumber ?? '').trim()
|
||||
if (weight === null || Number.isNaN(weight)) {
|
||||
manualModal.errors = { ...manualModal.errors, weight: t('logistique.weighingTickets.form.manual.weightRequired') }
|
||||
}
|
||||
if (manualNumber === '') {
|
||||
manualModal.errors = { ...manualModal.errors, manualNumber: t('logistique.weighingTickets.form.manual.numberRequired') }
|
||||
}
|
||||
if (Object.keys(manualModal.errors).length > 0) return
|
||||
|
||||
manualModal.loading = true
|
||||
try {
|
||||
const reading = await weighbridge.triggerManual(weight as number, manualNumber)
|
||||
form.applyReading(form[manualModal.target], reading)
|
||||
manualModal.open = false
|
||||
}
|
||||
catch (error) {
|
||||
manualModal.errors = { weight: weighbridge.extractWeighbridgeError(error) }
|
||||
}
|
||||
finally {
|
||||
manualModal.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Soumissions ──────────────────────────────────────────────────────────────
|
||||
interface TicketResponse { id: number }
|
||||
|
||||
/** « Enregistrer » du bloc vide : POST /weighing_tickets (création + pesée à vide). */
|
||||
async function submitCreate(): Promise<void> {
|
||||
if (creating.value) return
|
||||
creating.value = true
|
||||
clearErrors()
|
||||
try {
|
||||
const created = await api.post<TicketResponse>('/weighing_tickets', form.buildCreatePayload(), {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
form.ticketId.value = created.id
|
||||
}
|
||||
catch (error) {
|
||||
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||
}
|
||||
finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** « Valider » : PATCH de la pesée à plein puis ouverture du bon de pesée PDF (RG-5.08). */
|
||||
async function submitValidate(): Promise<void> {
|
||||
if (validating.value || form.ticketId.value === null) return
|
||||
validating.value = true
|
||||
clearErrors()
|
||||
try {
|
||||
await api.patch(`/weighing_tickets/${form.ticketId.value}`, form.buildFullPayload(), { toast: false })
|
||||
// Bon de pesée = PDF généré côté back (Twig, ERP-192) — on l'ouvre, on ne
|
||||
// dessine aucun gabarit côté front (RG-5.08).
|
||||
window.open(`/api/weighing_tickets/${form.ticketId.value}/print.pdf`, '_blank')
|
||||
router.push('/weighing-tickets')
|
||||
}
|
||||
catch (error) {
|
||||
handleApiError(error, { fallbackMessage: t('logistique.weighingTickets.toast.error') })
|
||||
}
|
||||
finally {
|
||||
validating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Échec du chargement des référentiels non bloquant : les selects restent vides.
|
||||
referentials.load().catch(() => {})
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatDateFr, formatWeightKg, formatPlate } from '../weighingTicketFormat'
|
||||
|
||||
describe('weighingTicketFormat', () => {
|
||||
// ── Date JJ-MM-AAAA ───────────────────────────────────────────────────────
|
||||
describe('formatDateFr', () => {
|
||||
it('formate un datetime ISO en JJ-MM-AAAA', () => {
|
||||
expect(formatDateFr('2026-06-17T09:12:00+02:00')).toBe('17-06-2026')
|
||||
})
|
||||
|
||||
it('zéro-pad le jour et le mois', () => {
|
||||
expect(formatDateFr('2026-01-05T00:00:00Z')).toBe('05-01-2026')
|
||||
})
|
||||
|
||||
it('retourne une chaîne vide si absente ou invalide', () => {
|
||||
expect(formatDateFr(null)).toBe('')
|
||||
expect(formatDateFr(undefined)).toBe('')
|
||||
expect(formatDateFr('pas-une-date')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Poids « X XXX Kg » ────────────────────────────────────────────────────
|
||||
describe('formatWeightKg', () => {
|
||||
it('ajoute un séparateur de milliers (espace) et le suffixe Kg', () => {
|
||||
expect(formatWeightKg(7150)).toBe('7 150 Kg')
|
||||
expect(formatWeightKg(14300)).toBe('14 300 Kg')
|
||||
expect(formatWeightKg(1000000)).toBe('1 000 000 Kg')
|
||||
})
|
||||
|
||||
it('gère les petits nombres sans séparateur', () => {
|
||||
expect(formatWeightKg(0)).toBe('0 Kg')
|
||||
expect(formatWeightKg(999)).toBe('999 Kg')
|
||||
})
|
||||
|
||||
it('retourne une chaîne vide si le poids est absent', () => {
|
||||
expect(formatWeightKg(null)).toBe('')
|
||||
expect(formatWeightKg(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Immatriculation UPPER ─────────────────────────────────────────────────
|
||||
describe('formatPlate', () => {
|
||||
it('met en majuscules et trim', () => {
|
||||
expect(formatPlate(' ab-123-cd ')).toBe('AB-123-CD')
|
||||
})
|
||||
|
||||
it('retourne une chaîne vide si absente', () => {
|
||||
expect(formatPlate(null)).toBe('')
|
||||
expect(formatPlate('')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Filtres d'affichage du module « Tickets de pesée » (M5, ERP-191). Helpers PURS
|
||||
* et testables, partagés par la liste et les écrans. Le serveur reste l'autorité
|
||||
* de normalisation (spec-front § Règles de formatage) : ces helpers ne font que
|
||||
* mettre en forme la valeur déjà normalisée renvoyée par l'API.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Date courte française `JJ-MM-AAAA` (spec M5). Chaîne vide si la valeur est
|
||||
* absente ou invalide. Lit les composantes locales (cohérent avec l'affichage
|
||||
* des autres répertoires M1→M4).
|
||||
*/
|
||||
export function formatDateFr(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${day}-${month}-${date.getFullYear()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Poids en kg avec séparateur de milliers (espace) + suffixe « Kg »
|
||||
* (spec-front : « 7 150 Kg »). Chaîne vide si le poids est absent (ticket dont la
|
||||
* pesée à plein n'est pas finalisée). Groupement manuel (espace ASCII) pour un
|
||||
* rendu déterministe, indépendant de l'ICU de l'environnement.
|
||||
*/
|
||||
export function formatWeightKg(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
const grouped = String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||
return `${grouped} Kg`
|
||||
}
|
||||
|
||||
/**
|
||||
* Immatriculation en MAJUSCULES (cohérent avec la normalisation serveur RG-5.01 :
|
||||
* trim + UPPER). Chaîne vide si absente.
|
||||
*/
|
||||
export function formatPlate(value: string | null | undefined): string {
|
||||
return value ? value.trim().toUpperCase() : ''
|
||||
}
|
||||
@@ -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)]">
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -12,91 +12,91 @@
|
||||
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.siteIris)"
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('technique.providers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('technique.providers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('technique.providers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
v-if="!hideEmpty || isFilled(model.country)"
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('technique.providers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.postalCode)"
|
||||
:model-value="model.postalCode"
|
||||
:label="t('technique.providers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('technique.providers.form.address.streetNotFound')"
|
||||
@@ -109,17 +109,20 @@
|
||||
:model-value="model.street"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('technique.providers.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
@@ -131,6 +134,8 @@
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
@@ -138,8 +143,6 @@ const POSTAL_CODE_MASK = '#####'
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: ProviderAddressFormDraft
|
||||
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
|
||||
categoryOptions: RefOption[]
|
||||
/** Sites Starseed disponibles. */
|
||||
siteOptions: RefOption[]
|
||||
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||
@@ -148,6 +151,10 @@ const props = defineProps<{
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -193,11 +200,37 @@ const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
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). */
|
||||
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||
* a chaque frappe).
|
||||
*/
|
||||
function onCityChange(value: string | number | null): void {
|
||||
const next = value === null ? null : String(value)
|
||||
if (next === (props.modelValue.city ?? null)) {
|
||||
return
|
||||
}
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
city: next,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
@@ -208,9 +241,27 @@ function notifyUnavailable(): void {
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||
|
||||
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||
if (digits.length === 5 && digits !== previousDigits) {
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
postalCode: value,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
else {
|
||||
update('postalCode', value)
|
||||
}
|
||||
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -12,44 +12,56 @@
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('technique.providers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('technique.providers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('technique.providers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('technique.providers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('technique.providers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('technique.providers.form.contact.addPhone')"
|
||||
@@ -58,11 +70,12 @@
|
||||
/>
|
||||
<!-- 2e numero : revele a la demande (max 2 telephones par contact). -->
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('technique.providers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
@@ -71,6 +84,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProviderContactFormDraft } from '~/modules/technique/types/providerForm'
|
||||
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
@@ -82,6 +97,10 @@ const props = defineProps<{
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -96,6 +115,10 @@ const { t } = useI18n()
|
||||
// Alias local pour la lisibilite du template.
|
||||
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). */
|
||||
function update<K extends keyof ProviderContactFormDraft>(field: K, value: ProviderContactFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
|
||||
@@ -46,7 +46,6 @@ function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<str
|
||||
return mount(ProviderAddressBlock, {
|
||||
props: {
|
||||
modelValue: { ...emptyProviderAddress(), ...overrides },
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
@@ -79,17 +78,14 @@ describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/tri
|
||||
})
|
||||
|
||||
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
|
||||
it('affiche l\'erreur serveur sur sites (RG-3.05)', () => {
|
||||
const wrapper = mountBlock({}, {
|
||||
sites: 'Au moins un site est obligatoire.',
|
||||
categories: 'Au moins une catégorie est obligatoire.',
|
||||
})
|
||||
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
|
||||
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
|
||||
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
|
||||
|
||||
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
|
||||
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
|
||||
})
|
||||
|
||||
it('affiche l\'erreur serveur sur le code postal', () => {
|
||||
|
||||
@@ -330,17 +330,16 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
return form
|
||||
}
|
||||
|
||||
/** Remplit un bloc adresse valide (site + categorie + scalaires requis). */
|
||||
/** Remplit un bloc adresse valide (site + scalaires requis). */
|
||||
function fillValidAddress(form: ProviderForm, index = 0): void {
|
||||
const a = addressAt(form, index)
|
||||
a.siteIris = [SITE_86]
|
||||
a.categoryIris = [CAT_MAINT]
|
||||
a.postalCode = '86100'
|
||||
a.city = 'Châtellerault'
|
||||
a.street = '1 rue du Test'
|
||||
}
|
||||
|
||||
it('RG-3.05 : « + Nouvelle adresse » desactive tant que site + categorie manquent', () => {
|
||||
it('RG-3.05 : « + Nouvelle adresse » desactive tant que le site manque', () => {
|
||||
const form = createdForm()
|
||||
expect(form.canAddAddress.value).toBe(false)
|
||||
|
||||
@@ -349,8 +348,6 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
expect(form.addresses.value).toHaveLength(1)
|
||||
|
||||
addressAt(form).siteIris = [SITE_86]
|
||||
expect(form.canAddAddress.value).toBe(false) // categorie manquante
|
||||
addressAt(form).categoryIris = [CAT_MAINT]
|
||||
expect(form.canAddAddress.value).toBe(true)
|
||||
form.addAddress()
|
||||
expect(form.addresses.value).toHaveLength(2)
|
||||
@@ -377,7 +374,7 @@ describe('useProviderForm — onglet Adresse (ERP-143)', () => {
|
||||
expect(ok).toBe(true)
|
||||
const [url, body, opts] = mockPost.mock.calls[0] ?? []
|
||||
expect(url).toBe('/providers/7/addresses')
|
||||
expect(body).toMatchObject({ sites: [SITE_86], categories: [CAT_MAINT], city: 'Châtellerault' })
|
||||
expect(body).toMatchObject({ sites: [SITE_86], city: 'Châtellerault' })
|
||||
expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } })
|
||||
expect(addressAt(form).id).toBe(88)
|
||||
expect(form.isValidated('address')).toBe(true)
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('useProvidersRepository', () => {
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/providers')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
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 ────────────────────────────────────────────
|
||||
const providerId = ref<number | null>(null)
|
||||
const mainLocked = ref(false)
|
||||
@@ -339,6 +344,7 @@ export function useProviderForm() {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyProviderContact,
|
||||
onError: notifyRemovalError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -417,6 +423,7 @@ export function useProviderForm() {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyProviderAddress,
|
||||
onError: notifyRemovalError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -518,6 +525,7 @@ export function useProviderForm() {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyProviderRib,
|
||||
onError: notifyRemovalError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -59,5 +59,6 @@ export interface Provider {
|
||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('technique.providers.edit.back')"
|
||||
v-bind="{ ariaLabel: t('technique.providers.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -22,8 +23,9 @@
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:required="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
@@ -31,7 +33,7 @@
|
||||
:options="referentials.categories.value"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
@@ -41,7 +43,7 @@
|
||||
:options="referentials.sites.value"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
||||
@@ -71,7 +73,7 @@
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -102,12 +104,11 @@
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:category-options="referentials.categories.value"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="businessReadonly"
|
||||
:disabled="businessReadonly"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@@ -141,14 +142,15 @@
|
||||
v-model="accounting.siren"
|
||||
:label="t('technique.providers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('technique.providers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
@@ -156,7 +158,7 @@
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('technique.providers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@@ -165,7 +167,8 @@
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('technique.providers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
@@ -173,7 +176,7 @@
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('technique.providers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@@ -183,7 +186,7 @@
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('technique.providers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@@ -194,7 +197,7 @@
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('technique.providers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@@ -221,21 +224,23 @@
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('technique.providers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('technique.providers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
@@ -313,6 +318,7 @@ import {
|
||||
} from '~/modules/technique/types/providerForm'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
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).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('technique.providers.consultation.back')"
|
||||
v-bind="{ ariaLabel: t('technique.providers.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -22,7 +23,7 @@
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
variant="danger"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('technique.providers.action.archive')"
|
||||
@@ -47,28 +48,32 @@
|
||||
<!-- ── Bloc principal (lecture seule) ─────────────────────────────── -->
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-if="isFilled(provider.companyName)"
|
||||
:model-value="provider.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
v-if="isFilled(mainCategoryIris)"
|
||||
:model-value="mainCategoryIris"
|
||||
:options="mainCategoryOptions"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
v-if="isFilled(mainSiteIris)"
|
||||
:model-value="mainSiteIris"
|
||||
:options="mainSiteOptions"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── 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 -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
@@ -76,7 +81,8 @@
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -88,31 +94,29 @@
|
||||
v-for="(view, index) in addressViews"
|
||||
:key="index"
|
||||
:model-value="view.draft"
|
||||
:category-options="view.categoryOptions"
|
||||
:site-options="view.siteOptions"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptionsFor(view.draft.country)"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglets placeholder « A venir » (comme les autres modules). -->
|
||||
<template #reports><ComingSoonPlaceholder /></template>
|
||||
<template #exchanges><ComingSoonPlaceholder /></template>
|
||||
<!-- ERP-193 : les onglets « a venir » (Rapports / Echanges) ne sont
|
||||
plus rendus en consultation (masquage des onglets vides). -->
|
||||
|
||||
<!-- Onglet Comptabilite (present uniquement si accounting.view). -->
|
||||
<template v-if="canAccountingView" #accounting>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" readonly />
|
||||
<MalioInputText :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" readonly />
|
||||
<MalioSelect :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" readonly empty-option-label="" />
|
||||
<MalioInputText :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" readonly />
|
||||
<MalioSelect :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" readonly empty-option-label="" />
|
||||
<MalioSelect :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" readonly empty-option-label="" />
|
||||
<MalioSelect v-if="isBankRequired" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" readonly empty-option-label="" />
|
||||
<MalioInputText v-if="isFilled(accounting.siren)" :model-value="accounting.siren" :label="t('technique.providers.form.accounting.siren')" disabled />
|
||||
<MalioInputText v-if="isFilled(accounting.accountNumber)" :model-value="accounting.accountNumber" :label="t('technique.providers.form.accounting.accountNumber')" disabled />
|
||||
<MalioSelect v-if="isFilled(accounting.tvaModeIri)" :model-value="accounting.tvaModeIri" :options="tvaModeOptions" :label="t('technique.providers.form.accounting.tvaMode')" disabled empty-option-label="" />
|
||||
<MalioInputText v-if="isFilled(accounting.nTva)" :model-value="accounting.nTva" :label="t('technique.providers.form.accounting.nTva')" disabled />
|
||||
<MalioSelect v-if="isFilled(accounting.paymentDelayIri)" :model-value="accounting.paymentDelayIri" :options="paymentDelayOptions" :label="t('technique.providers.form.accounting.paymentDelay')" disabled empty-option-label="" />
|
||||
<MalioSelect v-if="isFilled(accounting.paymentTypeIri)" :model-value="accounting.paymentTypeIri" :options="paymentTypeOptions" :label="t('technique.providers.form.accounting.paymentType')" disabled empty-option-label="" />
|
||||
<MalioSelect v-if="isBankRequired && isFilled(accounting.bankIri)" :model-value="accounting.bankIri" :options="bankOptions" :label="t('technique.providers.form.accounting.bank')" disabled empty-option-label="" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,9 +127,9 @@
|
||||
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">
|
||||
<MalioInputText :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" readonly />
|
||||
<MalioInputText :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" readonly />
|
||||
<MalioInputText :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" readonly />
|
||||
<MalioInputText v-if="isFilled(rib.label)" :model-value="rib.label" :label="t('technique.providers.form.accounting.ribLabel')" disabled />
|
||||
<MalioInputText v-if="isFilled(rib.bic)" :model-value="rib.bic" :label="t('technique.providers.form.accounting.ribBic')" disabled />
|
||||
<MalioInputText v-if="isFilled(rib.iban)" :model-value="rib.iban" :label="t('technique.providers.form.accounting.ribIban')" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,7 +162,7 @@
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
canEditProvider,
|
||||
@@ -170,6 +174,7 @@ import {
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
providerConsultationVisibleTabs,
|
||||
referentialOptionOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
@@ -177,6 +182,7 @@ import {
|
||||
} from '~/modules/technique/utils/forms/providerDetail'
|
||||
import { isBankRequiredForPaymentType, isRibRequiredForPaymentType } from '~/modules/technique/utils/forms/providerAccounting'
|
||||
import { emptyProviderAddress, emptyProviderContact } from '~/modules/technique/types/providerForm'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
@@ -197,7 +203,6 @@ const headerTitle = computed(() => provider.value?.companyName || t('technique.p
|
||||
useHead({ title: t('technique.providers.consultation.title') })
|
||||
|
||||
// ── Onglets (ordre spec : Contacts · Adresse · Rapports · Échanges · Comptabilité) ──
|
||||
const activeTab = ref('contacts')
|
||||
const TAB_ICONS: Record<string, string> = {
|
||||
contacts: 'mdi:account-box-plus-outline',
|
||||
address: 'mdi:map-marker-outline',
|
||||
@@ -205,11 +210,27 @@ const TAB_ICONS: Record<string, string> = {
|
||||
exchanges: 'mdi:swap-horizontal',
|
||||
accounting: 'mdi:bank-circle-outline',
|
||||
}
|
||||
const tabs = computed(() => {
|
||||
const keys = ['contacts', 'address', 'reports', 'exchanges']
|
||||
if (canAccountingView.value) keys.push('accounting')
|
||||
return keys.map(key => ({ key, label: t(`technique.providers.tab.${key}`), icon: TAB_ICONS[key] }))
|
||||
})
|
||||
// ERP-193 (retour metier) : on masque les coquilles (Rapports / Echanges) ET
|
||||
// tout onglet de donnees vide. La liste depend donc du payload charge.
|
||||
const visibleTabKeys = computed(() => providerConsultationVisibleTabs(provider.value, {
|
||||
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 ─────────────────────────────
|
||||
const mainCategoryIris = computed(() => irisOf(provider.value?.categories))
|
||||
@@ -226,16 +247,15 @@ const contacts = computed(() => {
|
||||
// Contacts rattachables (pour resoudre les libelles des contacts lies aux adresses).
|
||||
const contactOptions = computed(() => contactOptionsOf(provider.value?.contacts))
|
||||
|
||||
// Vue par adresse : brouillon + options propres a l'adresse (sites/categories embarques).
|
||||
// Vue par adresse : brouillon + options propres a l'adresse (sites embarques).
|
||||
const addressViews = computed(() => {
|
||||
const views = (provider.value?.addresses ?? []).map(address => ({
|
||||
draft: mapAddressToDraft(address),
|
||||
siteOptions: siteOptionsOf(address.sites),
|
||||
categoryOptions: categoryOptionsOf(address.categories),
|
||||
}))
|
||||
return views.length > 0
|
||||
? views
|
||||
: [{ draft: emptyProviderAddress(), siteOptions: [], categoryOptions: [] }]
|
||||
: [{ draft: emptyProviderAddress(), siteOptions: [] }]
|
||||
})
|
||||
|
||||
/** Pays : une seule option (la valeur courante), suffisant pour l'affichage readonly. */
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<!-- Categories : libelles (name) separes par une virgule. -->
|
||||
<!-- Categories : libelles (name) separes par une virgule, aligne sur le client (ERP-193). -->
|
||||
<template #cell-categories="{ item }">
|
||||
{{ formatCategories(item) }}
|
||||
</template>
|
||||
@@ -210,7 +210,7 @@ const columns = [
|
||||
{ key: 'lastActivity', label: t('technique.providers.column.lastActivity') },
|
||||
]
|
||||
|
||||
/** Libelles des categories du prestataire, separes par une virgule (name). */
|
||||
/** Libelles (name) des categories du prestataire, separes par une virgule (aligne sur le client, ERP-193). */
|
||||
function formatCategories(item: Record<string, unknown>): string {
|
||||
const categories = (item.categories as Provider['categories']) ?? []
|
||||
return categories.map(c => c.name).join(', ')
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('technique.providers.form.back')"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -21,8 +22,9 @@
|
||||
<MalioInputText
|
||||
v-model="main.companyName"
|
||||
:label="t('technique.providers.form.main.companyName')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.companyName"
|
||||
/>
|
||||
<MalioSelectCheckbox
|
||||
@@ -30,7 +32,7 @@
|
||||
:options="referentials.categories.value"
|
||||
:label="t('technique.providers.form.main.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.categories"
|
||||
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
|
||||
@@ -40,7 +42,7 @@
|
||||
:options="referentials.sites.value"
|
||||
:label="t('technique.providers.form.main.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.sites"
|
||||
@update:model-value="(v: (string | number)[]) => main.siteIris = v.map(String)"
|
||||
@@ -72,12 +74,16 @@
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contact')"
|
||||
:disabled="isValidated('contact')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@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
|
||||
variant="secondary"
|
||||
icon-name="mdi:add-bold"
|
||||
@@ -89,7 +95,7 @@
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('technique.providers.form.submit')"
|
||||
:disabled="tabSubmitting || providerId === null"
|
||||
:disabled="tabSubmitting"
|
||||
@click="onSubmitContacts"
|
||||
/>
|
||||
</div>
|
||||
@@ -102,12 +108,11 @@
|
||||
v-for="(address, index) in addresses"
|
||||
:key="index"
|
||||
:model-value="address"
|
||||
:category-options="referentials.categories.value"
|
||||
:site-options="referentials.sites.value"
|
||||
:contact-options="contactOptions"
|
||||
:country-options="countryOptions"
|
||||
:removable="isRowRemovable(addresses, index)"
|
||||
:readonly="isValidated('address')"
|
||||
:disabled="isValidated('address')"
|
||||
:errors="addressErrors[index]"
|
||||
@update:model-value="(v) => addresses[index] = v"
|
||||
@remove="askRemoveAddress(index)"
|
||||
@@ -140,14 +145,15 @@
|
||||
v-model="accounting.siren"
|
||||
:label="t('technique.providers.form.accounting.siren')"
|
||||
:mask="SIREN_MASK"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.siren"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="accounting.accountNumber"
|
||||
:label="t('technique.providers.form.accounting.accountNumber')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.accountNumber"
|
||||
/>
|
||||
@@ -155,7 +161,7 @@
|
||||
:model-value="accounting.tvaModeIri"
|
||||
:options="referentials.tvaModes.value"
|
||||
:label="t('technique.providers.form.accounting.tvaMode')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.tvaMode"
|
||||
@@ -164,7 +170,8 @@
|
||||
<MalioInputText
|
||||
v-model="accounting.nTva"
|
||||
:label="t('technique.providers.form.accounting.nTva')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.nTva"
|
||||
/>
|
||||
@@ -172,7 +179,7 @@
|
||||
:model-value="accounting.paymentDelayIri"
|
||||
:options="referentials.paymentDelays.value"
|
||||
:label="t('technique.providers.form.accounting.paymentDelay')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentDelay"
|
||||
@@ -182,7 +189,7 @@
|
||||
:model-value="accounting.paymentTypeIri"
|
||||
:options="referentials.paymentTypes.value"
|
||||
:label="t('technique.providers.form.accounting.paymentType')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.paymentType"
|
||||
@@ -194,7 +201,7 @@
|
||||
:model-value="accounting.bankIri"
|
||||
:options="referentials.banks.value"
|
||||
:label="t('technique.providers.form.accounting.bank')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="accountingErrors.errors.bank"
|
||||
@@ -221,21 +228,23 @@
|
||||
<MalioInputText
|
||||
v-model="rib.label"
|
||||
:label="t('technique.providers.form.accounting.ribLabel')"
|
||||
:readonly="accountingReadonly"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.label"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.bic"
|
||||
:label="t('technique.providers.form.accounting.ribBic')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.bic"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="rib.iban"
|
||||
:label="t('technique.providers.form.accounting.ribIban')"
|
||||
:readonly="accountingReadonly"
|
||||
:mask="CODE_ALNUM_MASK"
|
||||
:disabled="accountingReadonly"
|
||||
:required="true"
|
||||
:error="ribErrors[index]?.iban"
|
||||
/>
|
||||
@@ -297,6 +306,7 @@ import {
|
||||
} from '~/modules/technique/utils/forms/providerAccounting'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
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).
|
||||
const SIREN_MASK = '#########'
|
||||
|
||||
@@ -97,8 +97,6 @@ export interface ProviderAddressFormDraft {
|
||||
city: string | null
|
||||
street: string | null
|
||||
streetComplement: string | null
|
||||
/** IRI des categories rattachees (type PRESTATAIRE, RG-3.09 ; >= 1). */
|
||||
categoryIris: string[]
|
||||
/** IRI des sites rattaches a l'adresse (M2M `provider_address_site`, RG-3.05 ; >= 1). */
|
||||
siteIris: string[]
|
||||
/** IRI des contacts rattaches (= blocs Contact deja persistes de l'onglet Contact). */
|
||||
@@ -114,7 +112,6 @@ export function emptyProviderAddress(): ProviderAddressFormDraft {
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
categoryIris: [],
|
||||
siteIris: [],
|
||||
contactIris: [],
|
||||
}
|
||||
|
||||
@@ -12,21 +12,15 @@ import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||
*/
|
||||
describe('providerAddress helpers', () => {
|
||||
const SITE = '/api/sites/1'
|
||||
const CAT = '/api/categories/7'
|
||||
|
||||
describe('isProviderAddressValid (RG-3.05 / RG-3.09)', () => {
|
||||
describe('isProviderAddressValid (RG-3.05)', () => {
|
||||
it('false sans site', () => {
|
||||
const address = { ...emptyProviderAddress(), categoryIris: [CAT] }
|
||||
const address = { ...emptyProviderAddress() }
|
||||
expect(isProviderAddressValid(address)).toBe(false)
|
||||
})
|
||||
|
||||
it('false sans categorie', () => {
|
||||
it('true avec au moins un site', () => {
|
||||
const address = { ...emptyProviderAddress(), siteIris: [SITE] }
|
||||
expect(isProviderAddressValid(address)).toBe(false)
|
||||
})
|
||||
|
||||
it('true avec au moins un site ET une categorie', () => {
|
||||
const address = { ...emptyProviderAddress(), siteIris: [SITE], categoryIris: [CAT] }
|
||||
expect(isProviderAddressValid(address)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -39,7 +33,6 @@ describe('providerAddress helpers', () => {
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
siteIris: [SITE],
|
||||
categoryIris: [CAT],
|
||||
contactIris: ['/api/provider_contacts/9'],
|
||||
})
|
||||
expect(payload).toEqual({
|
||||
@@ -48,7 +41,6 @@ describe('providerAddress helpers', () => {
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
streetComplement: null,
|
||||
categories: [CAT],
|
||||
sites: [SITE],
|
||||
contacts: ['/api/provider_contacts/9'],
|
||||
})
|
||||
@@ -61,7 +53,6 @@ describe('providerAddress helpers', () => {
|
||||
const payload = buildProviderAddressPayload({
|
||||
...emptyProviderAddress(),
|
||||
siteIris: [SITE],
|
||||
categoryIris: [CAT],
|
||||
})
|
||||
expect(payload).not.toHaveProperty('postalCode')
|
||||
expect(payload).not.toHaveProperty('city')
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
canEditProvider,
|
||||
categoryOptionsOf,
|
||||
contactOptionsOf,
|
||||
hasAccountingData,
|
||||
iriOf,
|
||||
irisOf,
|
||||
mapAccountingDraft,
|
||||
@@ -17,6 +18,7 @@ const {
|
||||
mapContactToDraft,
|
||||
mapRibToDraft,
|
||||
paymentTypeCodeOf,
|
||||
providerConsultationVisibleTabs,
|
||||
referentialOptionOf,
|
||||
showArchiveAction,
|
||||
showRestoreAction,
|
||||
@@ -74,7 +76,7 @@ describe('providerDetail helpers', () => {
|
||||
})
|
||||
|
||||
describe('mapAddressToDraft', () => {
|
||||
it('extrait les IRI des sites / categories / contacts embarques', () => {
|
||||
it('extrait les IRI des sites / contacts embarques', () => {
|
||||
const draft = mapAddressToDraft({
|
||||
'@id': '/api/provider_addresses/3',
|
||||
id: 3,
|
||||
@@ -83,11 +85,9 @@ describe('providerDetail helpers', () => {
|
||||
city: 'Châtellerault',
|
||||
street: '1 rue du Test',
|
||||
sites: [{ '@id': '/api/sites/1' }],
|
||||
categories: [{ '@id': '/api/categories/7' }],
|
||||
contacts: [{ '@id': '/api/provider_contacts/5' }, '/api/provider_contacts/6'],
|
||||
})
|
||||
expect(draft.siteIris).toEqual(['/api/sites/1'])
|
||||
expect(draft.categoryIris).toEqual(['/api/categories/7'])
|
||||
expect(draft.contactIris).toEqual(['/api/provider_contacts/5', '/api/provider_contacts/6'])
|
||||
expect(draft.id).toBe(3)
|
||||
})
|
||||
@@ -165,3 +165,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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,12 +14,12 @@ import type { ProviderAddressFormDraft } from '~/modules/technique/types/provide
|
||||
const REQUIRED_NON_NULLABLE_KEYS = ['postalCode', 'city', 'street'] as const
|
||||
|
||||
/**
|
||||
* RG-3.05 (+ RG-3.09) : une adresse est « valide » pour autoriser l'ajout d'un
|
||||
* nouveau bloc des qu'elle porte au moins un site ET au moins une categorie. Les
|
||||
* scalaires (CP/ville/rue) restent valides par le back (422 inline).
|
||||
* RG-3.05 : une adresse est « valide » pour autoriser l'ajout d'un nouveau bloc
|
||||
* des qu'elle porte au moins un site. Les scalaires (CP/ville/rue) restent valides
|
||||
* par le back (422 inline).
|
||||
*/
|
||||
export function isProviderAddressValid(address: ProviderAddressFormDraft): boolean {
|
||||
return address.siteIris.length >= 1 && address.categoryIris.length >= 1
|
||||
return address.siteIris.length >= 1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,6 @@ export function buildProviderAddressPayload(address: ProviderAddressFormDraft):
|
||||
city: address.city || null,
|
||||
street: address.street || null,
|
||||
streetComplement: address.streetComplement || null,
|
||||
categories: [...address.categoryIris],
|
||||
sites: [...address.siteIris],
|
||||
contacts: [...address.contactIris],
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ export interface AddressRead extends HydraRef {
|
||||
street?: string | null
|
||||
streetComplement?: string | null
|
||||
sites?: SiteRead[]
|
||||
categories?: CategoryRead[]
|
||||
// L'embed M2M des contacts d'adresse peut etre un objet (partiel) ou un IRI nu.
|
||||
contacts?: Array<HydraRef | string>
|
||||
}
|
||||
@@ -146,7 +145,6 @@ export function mapAddressToDraft(address: AddressRead): ProviderAddressFormDraf
|
||||
city: address.city ?? null,
|
||||
street: address.street ?? null,
|
||||
streetComplement: address.streetComplement ?? null,
|
||||
categoryIris: (address.categories ?? []).map(c => c['@id']),
|
||||
siteIris: (address.sites ?? []).map(s => s['@id']),
|
||||
contactIris: (address.contacts ?? []).map(c => (typeof c === 'string' ? c : c['@id'])),
|
||||
}
|
||||
@@ -224,6 +222,58 @@ export function paymentTypeCodeOf(relation: Relation): string | 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 —
|
||||
* `manage` (onglets metier) OU `accounting.manage` (le role Compta doit pouvoir
|
||||
|
||||
@@ -3,63 +3,72 @@
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Pays : prerempli « France » (RG-4.05). -->
|
||||
<MalioSelect
|
||||
v-if="!hideEmpty || isFilled(model.country)"
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('transport.carriers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.country"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<!-- Code postal (RG-4.06) : declenche l'autocomplete ville (BAN). -->
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.postalCode)"
|
||||
:model-value="model.postalCode"
|
||||
:label="t('transport.carriers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
v-if="!degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('transport.carriers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
v-else-if="degraded && (!hideEmpty || isFilled(model.city))"
|
||||
:model-value="model.city"
|
||||
:label="t('transport.carriers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Filler : aligne le debut de la ligne suivante sur la grille. -->
|
||||
<div aria-hidden="true" />
|
||||
<!-- Filler : aligne le debut de la ligne suivante sur la grille. Inutile en
|
||||
consultation masquee (la grille se recompose sans les champs vides). -->
|
||||
<div v-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.street)" class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('transport.carriers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('transport.carriers.form.address.streetNotFound')"
|
||||
@@ -72,16 +81,20 @@
|
||||
:model-value="model.street"
|
||||
:label="t('transport.carriers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.streetComplement)"
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('transport.carriers.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
@@ -91,6 +104,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { CarrierAddressFormDraft } from '~/modules/transport/types/carrierForm'
|
||||
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
interface RefOption {
|
||||
value: string
|
||||
@@ -106,6 +121,10 @@ const props = defineProps<{
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -150,11 +169,36 @@ const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
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). */
|
||||
function update<K extends keyof CarrierAddressFormDraft>(field: K, value: CarrierAddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||
* a chaque frappe).
|
||||
*/
|
||||
function onCityChange(value: string | number | null): void {
|
||||
const next = value === null ? null : String(value)
|
||||
if (next === (props.modelValue.city ?? null)) {
|
||||
return
|
||||
}
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
city: next,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
@@ -165,9 +209,27 @@ function notifyUnavailable(): void {
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||
|
||||
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||
if (digits.length === 5 && digits !== previousDigits) {
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
postalCode: value,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
else {
|
||||
update('postalCode', value)
|
||||
}
|
||||
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Suppression : ouvre une modal de confirmation côté parent. Masquée si
|
||||
non supprimable (1er bloc) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -12,44 +12,56 @@
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('transport.carriers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('transport.carriers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText (inheritAttrs:false)
|
||||
renvoie `class` sur l'input interne, pas sur la cellule de grille. -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('transport.carriers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('transport.carriers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<!-- Téléphone principal + bouton « + » révélant le 2e numéro (max 2). -->
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('transport.carriers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('transport.carriers.form.contact.addPhone')"
|
||||
@@ -58,11 +70,12 @@
|
||||
/>
|
||||
<!-- 2e numéro : révélé à la demande (max 2 téléphones — RG-4.08). -->
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('transport.carriers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
@@ -71,6 +84,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
|
||||
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque téléphone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
@@ -82,6 +97,10 @@ const props = defineProps<{
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet validé). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexées par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -96,6 +115,10 @@ const { t } = useI18n()
|
||||
// Alias local pour la lisibilité du template.
|
||||
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é). */
|
||||
function update<K extends keyof CarrierContactFormDraft>(field: K, value: CarrierContactFormDraft[K]): void {
|
||||
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)]">
|
||||
<!-- Suppression : modal de confirmation côté parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -20,7 +20,7 @@
|
||||
:name="`price-direction-${uid}`"
|
||||
value="CLIENT"
|
||||
:label="t('transport.carriers.form.price.directionClient')"
|
||||
:disabled="readonly"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="onDirectionChange"
|
||||
/>
|
||||
@@ -29,7 +29,7 @@
|
||||
:name="`price-direction-${uid}`"
|
||||
value="FOURNISSEUR"
|
||||
:label="t('transport.carriers.form.price.directionSupplier')"
|
||||
:disabled="readonly"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="onDirectionChange"
|
||||
/>
|
||||
@@ -46,6 +46,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.client"
|
||||
@update:model-value="onClientChange"
|
||||
/>
|
||||
@@ -56,6 +57,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.clientDeliveryAddress"
|
||||
@update:model-value="(v: string | number | null) => update('clientDeliveryAddressIri', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -66,6 +68,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.departureSite"
|
||||
@update:model-value="(v: string | number | null) => update('departureSiteIri', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -80,6 +83,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.supplier"
|
||||
@update:model-value="onSupplierChange"
|
||||
/>
|
||||
@@ -90,6 +94,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.supplierSupplyAddress"
|
||||
@update:model-value="(v: string | number | null) => update('supplierSupplyAddressIri', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -100,6 +105,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.deliverySite"
|
||||
@update:model-value="(v: string | number | null) => update('deliverySiteIri', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -115,7 +121,7 @@
|
||||
:name="`price-container-${uid}`"
|
||||
value="BENNE"
|
||||
:label="t('transport.carriers.containerType.BENNE')"
|
||||
:disabled="readonly"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -124,7 +130,7 @@
|
||||
:name="`price-container-${uid}`"
|
||||
value="FOND_MOUVANT"
|
||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||
:disabled="readonly"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('containerType', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -140,7 +146,7 @@
|
||||
:name="`price-unit-${uid}`"
|
||||
value="FORFAIT"
|
||||
:label="t('transport.carriers.form.price.pricingForfait')"
|
||||
:disabled="readonly"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@update:model-value="(v: string | number | boolean | null) => update('pricingUnit', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -149,7 +155,7 @@
|
||||
:name="`price-unit-${uid}`"
|
||||
value="TONNE"
|
||||
:label="t('transport.carriers.form.price.pricingTonne')"
|
||||
:disabled="readonly"
|
||||
:disabled="readonly || disabled"
|
||||
group-class="mt-0"
|
||||
@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')"
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.price"
|
||||
@update:model-value="(v: string) => update('price', v)"
|
||||
/>
|
||||
@@ -173,6 +180,7 @@
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.priceState"
|
||||
@update:model-value="(v: string | number | null) => update('priceState', v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -200,6 +208,8 @@ const props = defineProps<{
|
||||
siteOptions: SelectOption[]
|
||||
removable?: 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). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
@@ -37,7 +37,7 @@ vi.stubGlobal('useToast', () => ({
|
||||
}))
|
||||
|
||||
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 { 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(form.address.value.id).toBe(88)
|
||||
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 () => {
|
||||
@@ -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)', () => {
|
||||
expect(isCarrierContactBlank(emptyCarrierContact())).toBe(true)
|
||||
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)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const body = buildCarrierContactPayload({ ...emptyCarrierContact(), phonePrimary: '0102030405' })
|
||||
expect(body.phones).toEqual(['0102030405'])
|
||||
@@ -635,23 +629,18 @@ describe('useCarrierForm — onglet Contacts (ERP-168)', () => {
|
||||
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()
|
||||
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()
|
||||
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]
|
||||
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)
|
||||
form.addContact()
|
||||
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 })
|
||||
})
|
||||
|
||||
it('RG-4.08 : onglet vide → soumet l\'amorce pour déclencher la 422 inline', async () => {
|
||||
mockPost.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
_data: { violations: [{ propertyPath: 'firstName', message: 'Au moins un champ du contact est obligatoire.' }] },
|
||||
},
|
||||
})
|
||||
it('ERP-193 : onglet Contact vide → aucun POST, onglet finalisé (bloc optionnel)', async () => {
|
||||
const form = createdForm()
|
||||
|
||||
// Bloc vide → rien n'est soumis, l'onglet se finalise et déverrouille Prix.
|
||||
const ok = await form.submitContacts(vi.fn())
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(mockPost).toHaveBeenCalledTimes(1)
|
||||
expect(form.contactErrors.value[0]?.firstName).toBe('Au moins un champ du contact est obligatoire.')
|
||||
expect(form.isValidated('contacts')).toBe(false)
|
||||
expect(ok).toBe(true)
|
||||
expect(mockPost).not.toHaveBeenCalled()
|
||||
expect(form.isValidated('contacts')).toBe(true)
|
||||
})
|
||||
|
||||
it('removeContact : DELETE /carrier_contacts/{id} puis retrait du bloc', async () => {
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('useCarriersRepository', () => {
|
||||
expect(mockApiGet).toHaveBeenCalledTimes(1)
|
||||
const [url, query, opts] = mockApiGet.mock.calls[0]
|
||||
expect(url).toBe('/carriers')
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 10 })
|
||||
expect(query).toMatchObject({ page: 1, itemsPerPage: 25 })
|
||||
expect(opts).toMatchObject({
|
||||
toast: false,
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type CarrierPriceFormDraft,
|
||||
} from '~/modules/transport/types/carrierForm'
|
||||
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 {
|
||||
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 :
|
||||
* 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 })
|
||||
}
|
||||
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
|
||||
}
|
||||
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.
|
||||
const contactErrors = ref<Record<string, string>[]>([])
|
||||
|
||||
// « + Nouveau contact » désactivé tant que le DERNIER bloc n'est pas « nommé »
|
||||
// (prénom OU nom) — aligné sur M1/M2/M3 (fonction / téléphone / email seuls ne
|
||||
// suffisent pas à ajouter un nouveau bloc).
|
||||
// « + Nouveau contact » désactivé tant que le DERNIER bloc est VIDE. ERP-193 :
|
||||
// l'onglet Contact n'est plus obligatoire — on ne réclame plus prénom OU nom,
|
||||
// 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 last = contacts.value[contacts.value.length - 1]
|
||||
return last !== undefined && isCarrierContactNamed(last)
|
||||
return last !== undefined && !isCarrierContactBlank(last)
|
||||
})
|
||||
|
||||
function addContact(): void {
|
||||
@@ -535,16 +545,18 @@ export function useCarrierForm() {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyCarrierContact,
|
||||
onError: notifyRemovalError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide l'onglet Contacts : POST des nouveaux contacts sur
|
||||
* /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)
|
||||
* re-validée back → 422 par ligne. Si l'onglet ne contient QUE des amorces
|
||||
* vides, on soumet la 1re pour déclencher la 422 RG-4.08 inline plutôt que de
|
||||
* finaliser un onglet vide. Retourne true si l'onglet a été validé.
|
||||
* (groupe carrier:write:contacts). Max 2 téléphones re-validé back → 422 par
|
||||
* ligne. ERP-193 : l'onglet Contact est OPTIONNEL — les amorces vides neuves
|
||||
* sont systématiquement ignorées (pas de contact vide créé) et un onglet sans
|
||||
* 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> {
|
||||
if (carrierId.value === null || tabSubmitting.value) {
|
||||
@@ -552,7 +564,6 @@ export function useCarrierForm() {
|
||||
}
|
||||
tabSubmitting.value = true
|
||||
try {
|
||||
const hasSubmittable = contacts.value.some(c => c.id !== null || !isCarrierContactBlank(c))
|
||||
const hasError = await submitRows(
|
||||
contacts.value,
|
||||
contactErrors,
|
||||
@@ -571,9 +582,9 @@ export function useCarrierForm() {
|
||||
}
|
||||
},
|
||||
onError,
|
||||
// Amorce vide neuve ignorée s'il reste un autre bloc soumettable ;
|
||||
// sinon on la soumet pour déclencher la 422 RG-4.08 (sur firstName).
|
||||
contact => hasSubmittable && contact.id === null && isCarrierContactBlank(contact),
|
||||
// Amorce vide neuve toujours ignorée (bloc Contact optionnel, ERP-193) :
|
||||
// un onglet sans aucun bloc rempli se finalise sans rien créer.
|
||||
contact => contact.id === null && isCarrierContactBlank(contact),
|
||||
)
|
||||
if (hasError) {
|
||||
return false
|
||||
@@ -648,6 +659,7 @@ export function useCarrierForm() {
|
||||
deleteRow: url => api.delete(url, {}, { toast: false }),
|
||||
makeEmpty: emptyCarrierPrice,
|
||||
onError: notifyRemovalError,
|
||||
onSuccess: notifyRemovalSuccess,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,5 +66,6 @@ export interface CarrierFilters {
|
||||
* `usePaginatedList`. Aucun reset au logout a gerer.
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('transport.carriers.edit.back')"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.edit.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -20,18 +21,24 @@
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.name"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('transport.carriers.form.main.name')"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.name"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="isLiot"
|
||||
v-model="main.liotPlates"
|
||||
:label="t('transport.carriers.form.main.liotPlates')"
|
||||
:hint="t('transport.carriers.form.main.liotPlatesHint')"
|
||||
:required="true"
|
||||
:error="mainErrors.errors.liotPlates"
|
||||
/>
|
||||
<!-- Cas LIOT : le champ immatriculations occupe les colonnes restantes
|
||||
de la ligne (3 en xl, 2 sinon). Wrapper pour le col-span car
|
||||
MalioInputText (inheritAttrs:false) renvoie `class` sur l'input. -->
|
||||
<div v-if="isLiot" class="col-span-2 xl:col-span-3">
|
||||
<MalioInputText
|
||||
v-model="main.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">
|
||||
<MalioSelect
|
||||
:model-value="main.certificationType"
|
||||
@@ -39,7 +46,7 @@
|
||||
:label="t('transport.carriers.form.main.certificationType')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="certificationReadonly"
|
||||
:disabled="certificationReadonly"
|
||||
:error="mainErrors.errors.certificationType"
|
||||
@update:model-value="(v: string | number | null) => setCertification(v === null ? null : String(v))"
|
||||
/>
|
||||
@@ -49,7 +56,7 @@
|
||||
:label="t('transport.carriers.form.main.discharge')"
|
||||
accept="application/pdf,image/*"
|
||||
:required="true"
|
||||
:readonly="dischargeUploading"
|
||||
:disabled="dischargeUploading"
|
||||
:clearable="true"
|
||||
:error="mainErrors.errors.dischargeDocument"
|
||||
@update:model-value="(v: string) => dischargeFileName = v"
|
||||
@@ -213,7 +220,8 @@ import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTa
|
||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||
import { useCarrier } from '~/modules/transport/composables/useCarrier'
|
||||
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 {
|
||||
value: string
|
||||
@@ -284,9 +292,13 @@ const TAB_ICONS: Record<string, string> = {
|
||||
contacts: 'mdi:account-box-plus-outline',
|
||||
prices: 'mdi:payment',
|
||||
}
|
||||
const activeTab = ref('addresses')
|
||||
// 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,
|
||||
label: t(`transport.carriers.tab.${key}`),
|
||||
icon: TAB_ICONS[key],
|
||||
@@ -364,7 +376,8 @@ function onIndexationInput(value: string): 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). */
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('transport.carriers.consultation.back')"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.consultation.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -22,7 +23,7 @@
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchive"
|
||||
variant="secondary"
|
||||
variant="danger"
|
||||
icon-name="mdi:archive-arrow-down-outline"
|
||||
icon-position="left"
|
||||
:label="t('transport.carriers.action.archive')"
|
||||
@@ -45,56 +46,58 @@
|
||||
<template v-else-if="carrier">
|
||||
<!-- ── 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">
|
||||
<MalioInputText :model-value="main.name" :label="t('transport.carriers.form.main.name')" readonly />
|
||||
<MalioInputText v-if="isFilled(main.name)" :model-value="main.name" :label="t('transport.carriers.form.main.name')" disabled />
|
||||
|
||||
<!-- Cas LIOT : seul le champ immatriculations. -->
|
||||
<MalioInputText
|
||||
v-if="isLiot"
|
||||
:model-value="main.liotPlates"
|
||||
:label="t('transport.carriers.form.main.liotPlates')"
|
||||
readonly
|
||||
/>
|
||||
<!-- Cas LIOT : le champ immatriculations occupe les colonnes restantes
|
||||
de la ligne (3 en xl, 2 sinon), comme à l'ajout / la modification. -->
|
||||
<div v-if="isLiot && isFilled(main.liotPlates)" class="col-span-2 xl:col-span-3">
|
||||
<MalioInputText
|
||||
:model-value="main.liotPlates"
|
||||
:label="t('transport.carriers.form.main.liotPlates')"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cas standard : certification + décharge (col 3 réservée) + affrètement (col 4). -->
|
||||
<template v-if="!isLiot">
|
||||
<MalioInputText
|
||||
v-if="isFilled(certificationLabel)"
|
||||
:model-value="certificationLabel"
|
||||
:label="t('transport.carriers.form.main.certificationType')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
|
||||
<!-- Colonne 3 réservée à la décharge (si AUTRE), sinon vide (xl). -->
|
||||
<!-- Décharge (si AUTRE) : affichee uniquement si un document est attache. -->
|
||||
<MalioInputText
|
||||
v-if="main.certificationType === 'AUTRE'"
|
||||
v-if="main.certificationType === 'AUTRE' && isFilled(dischargeLabel)"
|
||||
:model-value="dischargeLabel"
|
||||
:label="t('transport.carriers.form.main.discharge')"
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
<div v-else class="hidden xl:block"></div>
|
||||
|
||||
<!-- Affréter : colonne 4, centré (h-12) comme à l'ajout. -->
|
||||
<div class="flex h-12 items-center">
|
||||
<!-- Affréter : masquee si non cochee (ERP-193). -->
|
||||
<div v-if="isFilled(main.isChartered)" class="flex h-12 items-center">
|
||||
<MalioCheckbox
|
||||
id="carrier-view-chartered"
|
||||
:label="t('transport.carriers.form.main.isChartered')"
|
||||
:model-value="main.isChartered"
|
||||
readonly
|
||||
disabled
|
||||
:reserve-message-space="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Champs d'affrètement (ligne 2) si affrété. -->
|
||||
<template v-if="main.isChartered">
|
||||
<MalioInputText :model-value="indexationDisplay" :label="t('transport.carriers.form.main.indexationRate')" readonly />
|
||||
<MalioInputText v-if="isFilled(indexationDisplay)" :model-value="indexationDisplay" :label="t('transport.carriers.form.main.indexationRate')" disabled />
|
||||
<!-- Contenant : radios désactivés (lecture seule), aligné sur l'ajout / la modif. -->
|
||||
<div>
|
||||
<div v-if="isFilled(main.containerType)">
|
||||
<div class="flex h-12 items-center gap-4">
|
||||
<MalioRadioButton
|
||||
:model-value="main.containerType"
|
||||
name="carrier-view-container"
|
||||
value="BENNE"
|
||||
:label="t('transport.carriers.containerType.BENNE')"
|
||||
readonly
|
||||
disabled
|
||||
group-class="mt-0"
|
||||
/>
|
||||
<MalioRadioButton
|
||||
@@ -102,25 +105,27 @@
|
||||
name="carrier-view-container"
|
||||
value="FOND_MOUVANT"
|
||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||
readonly
|
||||
disabled
|
||||
group-class="mt-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MalioInputText :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" readonly />
|
||||
<MalioInputText v-if="isFilled(main.volumeM3)" :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" disabled />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── 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>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<!-- Adresse UNIQUE (ERP-172). -->
|
||||
<CarrierAddressBlock
|
||||
:model-value="address"
|
||||
:country-options="countryOptionsFor(address.country)"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -131,7 +136,8 @@
|
||||
v-for="(contact, index) in contacts"
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
readonly
|
||||
disabled
|
||||
hide-empty
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -144,14 +150,15 @@
|
||||
groupe (Fond Mouvant / Benne) fusionné en rowspan ; séparateur
|
||||
é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">
|
||||
<!-- Répartition (table-fixed) : « Type de transport » un peu plus
|
||||
large ; Transporteurs et Adresse livraisons larges ; Forfait /
|
||||
Tonne / Indexation / État réduits. -->
|
||||
<!-- Répartition (table-fixed) : « Transport » étroit (libellé
|
||||
court Benne / Fond mouvant) ; Fournisseurs/Clients et
|
||||
Adresse livraisons larges ; Forfait / Tonne / Indexation
|
||||
/ État réduits. -->
|
||||
<colgroup>
|
||||
<col class="w-[170px]" />
|
||||
<col class="w-[120px]" />
|
||||
<col class="w-[20%]" />
|
||||
<col class="w-[11%]" />
|
||||
<col class="w-[24%]" />
|
||||
<col class="w-[11%]" />
|
||||
<col class="w-[9%]" />
|
||||
<col class="w-[9%]" />
|
||||
<col class="w-[9%]" />
|
||||
@@ -162,8 +169,8 @@
|
||||
<!-- 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-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.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.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>
|
||||
@@ -171,28 +178,21 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="(group, gi) in priceGroups" :key="group.label">
|
||||
<template v-for="(group, gi) in priceGroups" :key="gi">
|
||||
<tr
|
||||
v-for="(row, i) in group.rows"
|
||||
:key="`${gi}-${i}`"
|
||||
>
|
||||
<!-- Cellule de groupe fusionnée (rowspan), centrée verticalement ;
|
||||
séparateur épais en bas entre les groupes (sauf dernier). -->
|
||||
<td
|
||||
v-if="i === 0"
|
||||
:rowspan="group.rows.length"
|
||||
class="border-r border-black px-3 text-center align-middle text-[14px] font-medium"
|
||||
:class="groupBorder(gi)"
|
||||
>
|
||||
{{ group.label }}
|
||||
</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>
|
||||
<!-- Contenant par ligne (plus de cellule fusionnée) ; séparateur
|
||||
à droite, comme l'ancienne colonne de groupe. -->
|
||||
<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>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.party }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.delivery }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.apro }}</td>
|
||||
<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>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.indexation }}</td>
|
||||
<td class="px-3 py-4 text-[14px]" :class="dataBorder(gi, i)">{{ row.state }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="!hasPrices">
|
||||
@@ -241,12 +241,13 @@
|
||||
</template>
|
||||
|
||||
<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 CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
|
||||
import { useCarrier } from '~/modules/transport/composables/useCarrier'
|
||||
import {
|
||||
canEditCarrier,
|
||||
carrierConsultationVisibleTabs,
|
||||
labelOfRelation,
|
||||
mapAddressToDraft,
|
||||
mapContactToDraft,
|
||||
@@ -257,6 +258,7 @@ import {
|
||||
type Relation,
|
||||
} from '~/modules/transport/utils/forms/carrierMappers'
|
||||
import { extractApiErrorMessage } from '~/shared/utils/api'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
@@ -300,18 +302,36 @@ const dischargeLabel = computed(() => {
|
||||
})
|
||||
|
||||
// ── 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> = {
|
||||
addresses: 'mdi:map-marker-outline',
|
||||
contacts: 'mdi:account-box-plus-outline',
|
||||
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,
|
||||
label: t(`transport.carriers.tab.${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).
|
||||
const address = computed(() => carrier.value?.address
|
||||
? mapAddressToDraft(carrier.value.address)
|
||||
@@ -326,10 +346,17 @@ function countryOptionsFor(country: string): SelectOption[] {
|
||||
return country ? [{ value: country, label: country }] : []
|
||||
}
|
||||
|
||||
// ── Tableau Prix consultation (regroupé par contenant Fond Mouvant / Benne) ───
|
||||
const PRICE_GROUP_ORDER = ['FOND_MOUVANT', 'BENNE'] as const
|
||||
// ── Tableau Prix consultation (regroupé par ADRESSE DE LIVRAISON) ─────────────
|
||||
// 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 {
|
||||
/** 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
|
||||
delivery: string
|
||||
forfait: string
|
||||
@@ -338,9 +365,8 @@ interface PriceRowView {
|
||||
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 {
|
||||
label: string
|
||||
rows: PriceRowView[]
|
||||
}
|
||||
|
||||
@@ -367,13 +393,19 @@ function siteCode(relation: Relation): string {
|
||||
|
||||
/**
|
||||
* 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 livraisons » = l'adresse (voie) du client/fournisseur ;
|
||||
* - le prix tombe dans Forfait € OU Tonne € selon `pricingUnit`.
|
||||
*/
|
||||
function toPriceRow(price: CarrierPriceRead): PriceRowView {
|
||||
const isClient = price.direction === 'CLIENT'
|
||||
const containerType = price.containerType ?? ''
|
||||
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),
|
||||
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
|
||||
forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '',
|
||||
@@ -392,39 +424,48 @@ function stateSuffix(state: string): string {
|
||||
return map[state] ?? ''
|
||||
}
|
||||
|
||||
// Prix regroupés par contenant (Fond Mouvant puis Benne) — une cellule fusionnée
|
||||
// par groupe (rowspan) à gauche, conformément à la maquette.
|
||||
// Prix regroupés par ADRESSE DE LIVRAISON : les lignes d'une même adresse sont
|
||||
// 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 list = carrier.value?.prices ?? []
|
||||
return PRICE_GROUP_ORDER
|
||||
.map(container => ({
|
||||
label: t(`transport.carriers.containerType.${container}`),
|
||||
rows: list.filter(p => p.containerType === container).map(toPriceRow),
|
||||
const rows = (carrier.value?.prices ?? []).map(toPriceRow)
|
||||
const byDelivery = new Map<string, PriceRowView[]>()
|
||||
for (const row of rows) {
|
||||
const list = byDelivery.get(row.delivery)
|
||||
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)
|
||||
|
||||
/**
|
||||
* Bordure basse d'une cellule de données :
|
||||
* - ligne interne d'un groupe → fine grise ;
|
||||
* - dernière ligne d'un groupe NON final → épaisse noire (séparateur de groupe) ;
|
||||
* - 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épare deux adresses) ;
|
||||
* - dernière ligne du DERNIER groupe → aucune (le cadre du tableau s'en charge,
|
||||
* é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 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) {
|
||||
return 'border-b border-m-muted/30'
|
||||
return 'border-b border-b-m-muted/30'
|
||||
}
|
||||
return isLastGroup ? '' : 'border-b-2 border-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'
|
||||
return isLastGroup ? '' : 'border-b-2 border-b-black'
|
||||
}
|
||||
|
||||
// ── Export XLSX des prix ─────────────────────────────────────────────────────
|
||||
@@ -465,7 +506,8 @@ function goBack(): 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: '' })
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
icon="mdi:arrow-left-bold"
|
||||
icon-size="24"
|
||||
variant="ghost"
|
||||
:title="t('transport.carriers.form.back')"
|
||||
v-bind="{ ariaLabel: t('transport.carriers.form.back') }"
|
||||
@click="goBack"
|
||||
/>
|
||||
@@ -20,22 +21,28 @@
|
||||
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
|
||||
<MalioInputText
|
||||
v-model="main.name"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:label="t('transport.carriers.form.main.name')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.name"
|
||||
/>
|
||||
|
||||
<!-- Cas LIOT : seul le champ immatriculations est pertinent. -->
|
||||
<MalioInputText
|
||||
v-if="isLiot"
|
||||
v-model="main.liotPlates"
|
||||
:label="t('transport.carriers.form.main.liotPlates')"
|
||||
:hint="t('transport.carriers.form.main.liotPlatesHint')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:error="mainErrors.errors.liotPlates"
|
||||
/>
|
||||
<!-- Cas LIOT : seul le champ immatriculations est pertinent. Il occupe
|
||||
les colonnes restantes de la ligne (3 en xl, 2 sinon) — le wrapper
|
||||
porte le col-span car MalioInputText (inheritAttrs:false) renvoie
|
||||
`class` sur l'input interne, pas sur la cellule de grille. -->
|
||||
<div v-if="isLiot" class="col-span-2 xl:col-span-3">
|
||||
<MalioInputText
|
||||
v-model="main.liotPlates"
|
||||
:mask="LIOT_PLATES_MASK"
|
||||
: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. -->
|
||||
<template v-if="!isLiot">
|
||||
@@ -45,7 +52,7 @@
|
||||
:label="t('transport.carriers.form.main.certificationType')"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:readonly="certificationReadonly"
|
||||
:disabled="certificationReadonly"
|
||||
:error="mainErrors.errors.certificationType"
|
||||
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
|
||||
/>
|
||||
@@ -61,7 +68,7 @@
|
||||
:label="t('transport.carriers.form.main.discharge')"
|
||||
accept="application/pdf,image/*"
|
||||
:required="true"
|
||||
:readonly="mainLocked || dischargeUploading"
|
||||
:disabled="mainLocked || dischargeUploading"
|
||||
:clearable="true"
|
||||
:error="mainErrors.errors.dischargeDocument"
|
||||
@update:model-value="(v: string) => dischargeFileName = v"
|
||||
@@ -78,7 +85,7 @@
|
||||
id="carrier-is-chartered"
|
||||
:label="t('transport.carriers.form.main.isChartered')"
|
||||
:model-value="main.isChartered"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:reserve-message-space="false"
|
||||
@update:model-value="(val: boolean) => main.isChartered = val"
|
||||
/>
|
||||
@@ -98,7 +105,7 @@
|
||||
icon-name="mdi:percent"
|
||||
icon-position="right"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.indexationRate"
|
||||
@update:model-value="onIndexationInput"
|
||||
/>
|
||||
@@ -134,7 +141,7 @@
|
||||
:model-value="main.volumeM3"
|
||||
:label="t('transport.carriers.form.main.volumeM3')"
|
||||
:required="true"
|
||||
:readonly="mainLocked"
|
||||
:disabled="mainLocked"
|
||||
:error="mainErrors.errors.volumeM3"
|
||||
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
|
||||
/>
|
||||
@@ -174,7 +181,7 @@
|
||||
<CarrierAddressBlock
|
||||
:model-value="address"
|
||||
:country-options="countryOptions"
|
||||
:readonly="isQualimat || isValidated('addresses')"
|
||||
:disabled="isQualimat || isValidated('addresses')"
|
||||
:errors="addressErrors"
|
||||
@update:model-value="(v) => address = v"
|
||||
@degraded="onAddressDegraded"
|
||||
@@ -192,8 +199,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Onglet Contacts (ERP-168) : un bloc par contact (RG-4.08 ≥ 1 champ,
|
||||
max 2 téléphones). Erreurs 422 par ligne. -->
|
||||
<!-- Onglet Contacts (ERP-168) : un bloc par contact (bloc optionnel,
|
||||
ERP-193 ; max 2 téléphones). Erreurs 422 par ligne. -->
|
||||
<template #contacts>
|
||||
<div class="mt-12 flex flex-col gap-6">
|
||||
<CarrierContactBlock
|
||||
@@ -201,7 +208,7 @@
|
||||
:key="index"
|
||||
:model-value="contact"
|
||||
:removable="isRowRemovable(contacts, index)"
|
||||
:readonly="isValidated('contacts')"
|
||||
:disabled="isValidated('contacts')"
|
||||
:errors="contactErrors[index]"
|
||||
@update:model-value="(v) => contacts[index] = v"
|
||||
@remove="askRemoveContact(index)"
|
||||
@@ -237,7 +244,7 @@
|
||||
:supplier-options="supplierOptions"
|
||||
:site-options="siteOptions"
|
||||
:removable="!isValidated('prices')"
|
||||
:readonly="isValidated('prices')"
|
||||
:disabled="isValidated('prices')"
|
||||
:errors="priceErrors[index]"
|
||||
@update:model-value="(v) => prices[index] = v"
|
||||
@remove="askRemovePrice(index)"
|
||||
@@ -307,7 +314,8 @@ import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.
|
||||
import CarrierQualimatTab from '~/modules/transport/components/CarrierQualimatTab.vue'
|
||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||
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 {
|
||||
value: string
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
canEditCarrier,
|
||||
carrierConsultationVisibleTabs,
|
||||
hasAddressData,
|
||||
iriOf,
|
||||
labelOfRelation,
|
||||
mapAddressToDraft,
|
||||
@@ -25,6 +27,10 @@ describe('carrierMappers', () => {
|
||||
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', () => {
|
||||
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')
|
||||
@@ -118,3 +124,47 @@ describe('carrierMappers', () => {
|
||||
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 { clampPercent, sanitizeDecimal } from '../numberInput'
|
||||
import { Mask } from 'maska'
|
||||
import { clampPercent, LIOT_PLATES_MASK, sanitizeDecimal } from '../numberInput'
|
||||
|
||||
describe('numberInput — saisie volume / indexation (ERP-170)', () => {
|
||||
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('')).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É
|
||||
* sur `providerContact.ts` (M3) / les autres modules : mêmes règles de validité et
|
||||
* de gating « + Nouveau contact » (un contact est « nommé » dès qu'il porte un
|
||||
* prénom OU un nom). Seule spécificité M4 conservée : les téléphones partent au back
|
||||
* dans le tableau virtuel `phones` (max 2), mappés par le CarrierContactProcessor.
|
||||
* Testables sans Vue ni API.
|
||||
* Helpers purs de l'onglet Contact transporteur (M4 Transport, ERP-168). ERP-193
|
||||
* (retour métier) : l'onglet Contact n'est plus obligatoire — la règle « prénom OU
|
||||
* nom » est retirée. Le gating « + Nouveau contact » repose désormais sur « le
|
||||
* dernier bloc n'est pas vide » (et non plus « nommé »). Spécificité M4 conservée :
|
||||
* les téléphones partent au back dans le tableau virtuel `phones` (max 2), mappés
|
||||
* par le CarrierContactProcessor. Testables sans Vue ni API.
|
||||
*/
|
||||
|
||||
import type { CarrierContactFormDraft } from '~/modules/transport/types/carrierForm'
|
||||
@@ -30,15 +30,6 @@ export function isCarrierContactBlank(contact: CarrierContactFormDraft): boolean
|
||||
].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
|
||||
* 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
|
||||
* condensée (voie · CP · ville). Chaîne vide si la relation est un IRI nu / absente.
|
||||
* Libellé d'affichage d'une relation embarquée : `companyName` (client/fournisseur)
|
||||
* à 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 {
|
||||
if (!relation || typeof relation === 'string') {
|
||||
return ''
|
||||
}
|
||||
const companyName = relation.companyName as string | undefined
|
||||
if (companyName) {
|
||||
return companyName
|
||||
}
|
||||
const name = relation.name as string | undefined
|
||||
if (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). */
|
||||
export function canEditCarrier(can: (code: string) => boolean): boolean {
|
||||
return can('transport.carriers.manage')
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Helpers de saisie numérique du formulaire principal transporteur (ERP-170).
|
||||
* Champs texte restreints (volume m³ décimal, indexation plafonnée). Purs / testables.
|
||||
* Helpers de saisie du formulaire principal transporteur (ERP-170).
|
||||
* 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³,
|
||||
@@ -26,3 +28,20 @@ export function clampPercent(value: string): string {
|
||||
const n = Number(String(value ?? '').replace(',', '.').replace(/\s/g, ''))
|
||||
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, ''),
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -7,7 +7,7 @@
|
||||
"name": "starseed-frontend",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.12",
|
||||
"@malio/layer-ui": "^1.7.15",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -1866,9 +1866,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.7.12",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.12/layer-ui-1.7.12.tgz",
|
||||
"integrity": "sha512-ezQLqi19K2ogI3XwSMsUyluU9x5C4W0tu1muxFbL3foKjibRYRg/FdvySivEhEsalAAt1E88V6Sv/06xPqyYTw==",
|
||||
"version": "1.7.15",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.7.15/layer-ui-1.7.15.tgz",
|
||||
"integrity": "sha512-CgEC0l2pkR6rlzpi1zZqswHs+/yGTSd861tdT678/wSKtQPQ6JxUIf63ugFDItyvyLW+nbcNWuHTFC2Bimp1EQ==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.7.12",
|
||||
"@malio/layer-ui": "^1.7.15",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -22,11 +22,12 @@ describe('removeCollectionRow', () => {
|
||||
const errors: Record<string, string>[] = [{}, {}]
|
||||
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||
const onError = vi.fn()
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const removed = await removeCollectionRow({
|
||||
rows, errors, index: 0,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow, makeEmpty, onError,
|
||||
deleteRow, makeEmpty, onError, onSuccess,
|
||||
})
|
||||
|
||||
expect(deleteRow).toHaveBeenCalledOnce()
|
||||
@@ -35,6 +36,8 @@ describe('removeCollectionRow', () => {
|
||||
expect(rows).toEqual([{ id: 11, label: 'B' }])
|
||||
expect(errors).toHaveLength(1)
|
||||
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 () => {
|
||||
@@ -42,16 +45,19 @@ describe('removeCollectionRow', () => {
|
||||
const errors: Record<string, string>[] = [{}, {}]
|
||||
const deleteRow = vi.fn().mockResolvedValue(undefined)
|
||||
const onError = vi.fn()
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const removed = await removeCollectionRow({
|
||||
rows, errors, index: 1,
|
||||
endpoint: '/client_contacts',
|
||||
deleteRow, makeEmpty, onError,
|
||||
deleteRow, makeEmpty, onError, onSuccess,
|
||||
})
|
||||
|
||||
expect(deleteRow).not.toHaveBeenCalled()
|
||||
expect(removed).toBe(true)
|
||||
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 () => {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isFilled } from '../consultationDisplay'
|
||||
|
||||
describe('isFilled (masquage des champs vides en consultation, ERP-193)', () => {
|
||||
it('considere VIDE : null / undefined / chaine vide ou espaces / tableau vide / false', () => {
|
||||
expect(isFilled(null)).toBe(false)
|
||||
expect(isFilled(undefined)).toBe(false)
|
||||
expect(isFilled('')).toBe(false)
|
||||
expect(isFilled(' ')).toBe(false)
|
||||
expect(isFilled([])).toBe(false)
|
||||
// Case a cocher non cochee => masquee.
|
||||
expect(isFilled(false)).toBe(false)
|
||||
})
|
||||
|
||||
it('considere REMPLI : chaine non vide / tableau non vide / nombre (y compris 0) / true / objet', () => {
|
||||
expect(isFilled('Dupont')).toBe(true)
|
||||
expect(isFilled(['/api/sites/1'])).toBe(true)
|
||||
expect(isFilled(0)).toBe(true)
|
||||
expect(isFilled(42)).toBe(true)
|
||||
expect(isFilled(true)).toBe(true)
|
||||
expect(isFilled({ '@id': '/api/x/1' })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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, 'O’Brien')).toBe('O’Brien')
|
||||
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('')
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,12 @@ export interface RemoveCollectionRowOptions<T extends DeletableRow> {
|
||||
makeEmpty: () => T
|
||||
/** Remontee d'erreur 409/422 mappee proprement (message back, pas de toast fourre-tout). */
|
||||
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>(
|
||||
options: RemoveCollectionRowOptions<T>,
|
||||
): 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]
|
||||
let serverDeleted = false
|
||||
|
||||
// Bloc existant : suppression serveur d'abord, retrait local seulement si OK.
|
||||
if (removed?.id != null) {
|
||||
@@ -67,6 +74,7 @@ export async function removeCollectionRow<T extends DeletableRow>(
|
||||
onError(error)
|
||||
return false
|
||||
}
|
||||
serverDeleted = true
|
||||
}
|
||||
|
||||
rows.splice(index, 1)
|
||||
@@ -75,5 +83,9 @@ export async function removeCollectionRow<T extends DeletableRow>(
|
||||
if (rows.length === 0) {
|
||||
rows.push(makeEmpty())
|
||||
}
|
||||
// Toast de succes uniquement quand le serveur a confirme une vraie suppression.
|
||||
if (serverDeleted) {
|
||||
onSuccess?.()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Helpers d'affichage en CONSULTATION (lecture seule).
|
||||
*
|
||||
* Decision metier (ERP-193) : en consultation, on masque les champs non remplis
|
||||
* (et les cases a cocher non cochees) pour ne montrer que l'information saisie.
|
||||
* Mutualise entre modules (fournisseur, prestataire, client, transporteur) : la
|
||||
* meme regle « vide » s'applique partout.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Indique si une valeur est « remplie » (donc a afficher en consultation).
|
||||
*
|
||||
* Sont consideres VIDES (donc masques) :
|
||||
* - null / undefined
|
||||
* - chaine vide ou composee uniquement d'espaces
|
||||
* - tableau vide (multiselect / cases a cocher sans selection)
|
||||
* - booleen `false` (case a cocher non cochee)
|
||||
*
|
||||
* Restent AFFICHES : tout nombre (y compris 0, qui est une valeur saisie), les
|
||||
* objets non nuls, et toute chaine non vide.
|
||||
*/
|
||||
export function isFilled(value: unknown): boolean {
|
||||
if (value === null || value === undefined) {
|
||||
return false
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() !== ''
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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).$_$');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* ERP-193 (retours metier M3) : suppression du champ « Categorie » du BLOC ADRESSE
|
||||
* prestataire (ProviderAddress) — fonctionnalite jugee inutile cote metier. Seule
|
||||
* la categorie du PRESTATAIRE lui-meme (table provider_category) est conservee.
|
||||
*
|
||||
* Drop de la table de jointure M2M provider_address_category (creee par
|
||||
* Version20260612100000). Migration au namespace racine DoctrineMigrations (et non
|
||||
* modulaire Technique) : elle DEPEND d une table creee au namespace racine et doit
|
||||
* donc s executer APRES sur base vide. Le tri cross-namespace de Doctrine Migrations
|
||||
* est alphabetique par FQCN (cf. regle ABSOLUE n°11) : une migration modulaire
|
||||
* « App\... » trierait AVANT « DoctrineMigrations\... » et passerait le DROP avant
|
||||
* le CREATE (table recreee a la fin). Rester en racine garantit l ordre par version.
|
||||
*/
|
||||
final class Version20260622100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'ERP-193 : suppression de la categorie du bloc adresse prestataire (drop provider_address_category).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS provider_address_category');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE provider_address_category (
|
||||
provider_address_id INT NOT NULL,
|
||||
category_id INT NOT NULL,
|
||||
PRIMARY KEY (provider_address_id, category_id),
|
||||
CONSTRAINT fk_provider_address_category_address
|
||||
FOREIGN KEY (provider_address_id) REFERENCES provider_address (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_provider_address_category_category
|
||||
FOREIGN KEY (category_id) REFERENCES category (id) ON DELETE RESTRICT
|
||||
)
|
||||
SQL);
|
||||
$this->comment('provider_address_category', '_table', 'Jointure M2M provider_address <-> category — categories d adresse de type PRESTATAIRE (RG-3.09).');
|
||||
$this->comment('provider_address_category', 'provider_address_id', 'FK -> provider_address.id, ON DELETE CASCADE — adresse concernee.');
|
||||
$this->comment('provider_address_category', 'category_id', 'FK -> category.id, ON DELETE RESTRICT — categorie d adresse de type PRESTATAIRE (RG-3.09).');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose un COMMENT ON TABLE/COLUMN en dollar-quoting Postgres ($_$...$_$)
|
||||
* pour eviter tout echappement d'apostrophes dans les descriptions.
|
||||
*/
|
||||
private function comment(string $table, string $column, string $description): void
|
||||
{
|
||||
$quotedTable = '"'.str_replace('"', '""', $table).'"';
|
||||
|
||||
if ('_table' === $column) {
|
||||
$this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addSql(sprintf(
|
||||
'COMMENT ON COLUMN %s.%s IS $_$%s$_$',
|
||||
$quotedTable,
|
||||
'"'.str_replace('"', '""', $column).'"',
|
||||
$description,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use App\Shared\Domain\Contract\ClientInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -162,6 +163,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
#[ORM\Column(length: 180)]
|
||||
#[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\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['client:read', 'client:write:main'])]
|
||||
private ?string $companyName = null;
|
||||
|
||||
@@ -214,11 +216,14 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $competitors = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[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
|
||||
// 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
|
||||
@@ -233,12 +238,16 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
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)]
|
||||
#[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'])]
|
||||
private ?string $revenueAmount = null;
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['client:read', 'client:write:information'])]
|
||||
private ?string $directorName = null;
|
||||
|
||||
@@ -257,6 +266,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
private ?string $accountNumber = null;
|
||||
|
||||
@@ -267,6 +277,7 @@ class Client implements TimestampableInterface, BlamableInterface, ClientInterfa
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['client:read:accounting', 'client:write:accounting'])]
|
||||
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\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -158,17 +159,20 @@ class ClientAddress implements TimestampableInterface, BlamableInterface, Client
|
||||
#[ORM\Column(length: 120)]
|
||||
#[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\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[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\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['client_address:read', 'client_address:write'])]
|
||||
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\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -94,16 +95,19 @@ class ClientContact implements TimestampableInterface, BlamableInterface
|
||||
// deux restent nullable au niveau ORM.
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[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'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[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'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[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'])]
|
||||
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\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -171,6 +172,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
#[ORM\Column(length: 180)]
|
||||
#[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\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['supplier:read', 'supplier:write:main'])]
|
||||
private ?string $companyName = null;
|
||||
|
||||
@@ -195,11 +197,14 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
private ?string $competitors = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
#[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
|
||||
// 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
|
||||
@@ -212,12 +217,16 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
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)]
|
||||
#[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'])]
|
||||
private ?string $revenueAmount = null;
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['supplier:read', 'supplier:write:information'])]
|
||||
private ?string $directorName = null;
|
||||
|
||||
@@ -243,6 +252,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
|
||||
private ?string $accountNumber = null;
|
||||
|
||||
@@ -253,6 +263,7 @@ class Supplier implements TimestampableInterface, BlamableInterface, SupplierInt
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['supplier:read:accounting', 'supplier:write:accounting'])]
|
||||
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\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -154,17 +155,20 @@ class SupplierAddress implements TimestampableInterface, BlamableInterface, Supp
|
||||
#[ORM\Column(length: 120)]
|
||||
#[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\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[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\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['supplier:item:read', 'supplier:write:addresses', 'supplier_address:read'])]
|
||||
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\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -99,16 +100,19 @@ class SupplierContact implements TimestampableInterface, BlamableInterface
|
||||
// deux restent nullable au niveau ORM.
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[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'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[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'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[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'])]
|
||||
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\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -156,6 +157,7 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
#[ORM\Column(length: 180)]
|
||||
#[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\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['provider:read', 'provider:write:main'])]
|
||||
private ?string $companyName = null;
|
||||
|
||||
@@ -200,6 +202,7 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?string $accountNumber = null;
|
||||
|
||||
@@ -210,6 +213,7 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::CODE_ALNUM, message: TextInputPattern::CODE_ALNUM_MESSAGE)]
|
||||
#[Groups(['provider:read:accounting', 'provider:write:accounting'])]
|
||||
private ?string $nTva = null;
|
||||
|
||||
@@ -274,8 +278,7 @@ class Provider implements TimestampableInterface, BlamableInterface
|
||||
/**
|
||||
* RG-3.09 : toute categorie posee sur le prestataire doit etre de type
|
||||
* PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). Miroir de
|
||||
* ProviderAddress::validateCategoryType. S'appuie sur
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
|
||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API
|
||||
|
||||
@@ -14,30 +14,26 @@ use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddr
|
||||
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\CategoryInterface;
|
||||
use App\Shared\Domain\Contract\SiteInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* Adresse d'un prestataire (1:n) — onglet Adresse. Version SIMPLIFIEE de
|
||||
* SupplierAddress : PAS de address_type (PROSPECT/DEPART/RENDU), PAS de bennes,
|
||||
* PAS de triage_provider (champs specifiques fournisseur). Champs : country /
|
||||
* postal_code / city / street / street_complement + M2M sites / contacts /
|
||||
* categories.
|
||||
* postal_code / city / street / street_complement + M2M sites / contacts.
|
||||
*
|
||||
* Relations M2M :
|
||||
* - sites : SiteInterface (module Sites) via resolve_target_entities — au moins
|
||||
* un site obligatoire (RG-3.05, Assert\Count). Site n'a pas de `code`.
|
||||
* - contacts : ProviderContact (meme module).
|
||||
* - categories : CategoryInterface (module Catalog) via resolve_target_entities —
|
||||
* type PRESTATAIRE attendu (RG-3.09, Assert\Callback validateCategoryType).
|
||||
*
|
||||
* Embarquee sous `provider.addresses` au detail (groupe provider:item:read,
|
||||
* maillon (a)).
|
||||
@@ -49,7 +45,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* - GET /api/provider_addresses/{id} : lecture unitaire (security view) — la lecture
|
||||
* courante reste via le parent. Pas de GET collection autonome.
|
||||
* Tout passe par le ProviderAddressProcessor (rattachement parent + cloisonnement
|
||||
* d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06/3.09 sont portees par les
|
||||
* d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06 sont portees par les
|
||||
* contraintes de l'entite (jouees avant le processor).
|
||||
*
|
||||
* Audite (#[Auditable]) + Timestampable / Blamable.
|
||||
@@ -58,9 +54,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('technique.providers.view')",
|
||||
// site:read + category:read : embarquent les Site / Category lies
|
||||
// (maillon (c)) plutot que des IRI nus dans le retour.
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
// site:read : embarque les Site lies (maillon (c)) plutot que des IRI
|
||||
// nus dans le retour.
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'default:read']],
|
||||
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
),
|
||||
@@ -75,13 +71,13 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
// manuellement par ProviderAddressProcessor::linkParent (404 si absent).
|
||||
read: false,
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
||||
processor: ProviderAddressProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('technique.providers.manage')",
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
|
||||
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'default:read']],
|
||||
denormalizationContext: ['groups' => ['provider:write:addresses']],
|
||||
provider: ProviderSubResourceItemProvider::class,
|
||||
processor: ProviderAddressProcessor::class,
|
||||
@@ -101,13 +97,6 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
/**
|
||||
* RG-3.09 : seules les categories PORTANT ce type sont autorisees sur une
|
||||
* adresse prestataire. S'appuie sur CategoryInterface::getCategoryTypeCodes()
|
||||
* (pas d'import du module Catalog — regle ABSOLUE n°1).
|
||||
*/
|
||||
private const string REQUIRED_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -135,17 +124,20 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
|
||||
#[ORM\Column(length: 120)]
|
||||
#[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\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[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\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private ?string $streetComplement = null;
|
||||
|
||||
@@ -171,46 +163,10 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private Collection $contacts;
|
||||
|
||||
// RG-3.09 : au moins une categorie de type PRESTATAIRE par adresse (le type est
|
||||
// controle par validateCategoryType ; le minimum par Assert\Count, miroir sites).
|
||||
/** @var Collection<int, CategoryInterface> */
|
||||
#[ORM\ManyToMany(targetEntity: CategoryInterface::class)]
|
||||
#[ORM\JoinTable(name: 'provider_address_category')]
|
||||
#[ORM\JoinColumn(name: 'provider_address_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'category_id', referencedColumnName: 'id', onDelete: 'RESTRICT')]
|
||||
#[Assert\Count(min: 1, minMessage: 'Au moins une catégorie est obligatoire.')]
|
||||
#[Groups(['provider:item:read', 'provider:write:addresses'])]
|
||||
private Collection $categories;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
$this->categories = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-3.09 : toute categorie posee sur une adresse prestataire doit etre de
|
||||
* type PRESTATAIRE -> sinon 422 avec violation sur le champ `categories`
|
||||
* (propertyPath aligne ERP-101, message FR ERP-107). S'appuie sur
|
||||
* CategoryInterface::getCategoryTypeCodes() (multi-type — la categorie est
|
||||
* acceptee des qu'elle PORTE le type PRESTATAIRE ; pas d'import du module
|
||||
* Catalog, regle ABSOLUE n°1). Joue avant la base via la validation API Platform.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validateCategoryType(ExecutionContextInterface $context): void
|
||||
{
|
||||
foreach ($this->categories as $category) {
|
||||
if ($category instanceof CategoryInterface
|
||||
&& !in_array(self::REQUIRED_CATEGORY_TYPE_CODE, $category->getCategoryTypeCodes(), true)) {
|
||||
$context->buildViolation('Type de catégorie non autorisé (PRESTATAIRE attendu).')
|
||||
->atPath('categories')
|
||||
->addViolation()
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
$this->sites = new ArrayCollection();
|
||||
$this->contacts = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -345,26 +301,4 @@ class ProviderAddress implements TimestampableInterface, BlamableInterface, Prov
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, CategoryInterface> */
|
||||
public function getCategories(): Collection
|
||||
{
|
||||
return $this->categories;
|
||||
}
|
||||
|
||||
public function addCategory(CategoryInterface $category): static
|
||||
{
|
||||
if (!$this->categories->contains($category)) {
|
||||
$this->categories->add($category);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCategory(CategoryInterface $category): static
|
||||
{
|
||||
$this->categories->removeElement($category);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -102,16 +103,19 @@ class ProviderContact implements TimestampableInterface, BlamableInterface, Prov
|
||||
// champs restent nullable au niveau ORM.
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[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'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[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'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[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'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
|
||||
+1
-2
@@ -33,8 +33,7 @@ use Symfony\Component\Validator\ConstraintViolationList;
|
||||
* sites de l'adresse (RG-3.05 / § 2.13). Les regles de l'onglet Adresse sont
|
||||
* garanties en amont par des contraintes sur l'entite, jouees par API Platform
|
||||
* avant ce processor : RG-3.06 (code postal, Assert\Regex), RG-3.05 (>= 1 site,
|
||||
* Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback
|
||||
* ProviderAddress::validateCategoryType).
|
||||
* Assert\Count).
|
||||
* - DELETE : aucune regle metier specifique (suppression physique directe).
|
||||
*
|
||||
* La security de l'operation (technique.providers.manage) est appliquee par API
|
||||
|
||||
@@ -63,7 +63,7 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
/**
|
||||
* Type de categorie exige pour un prestataire et ses adresses (RG-3.09).
|
||||
* Type de categorie exige pour un prestataire (RG-3.09).
|
||||
* Miroir de Provider::REQUIRED_CATEGORY_TYPE_CODE (non importable — regle n°1).
|
||||
*/
|
||||
private const string PROVIDER_CATEGORY_TYPE_CODE = 'PRESTATAIRE';
|
||||
@@ -117,7 +117,7 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
$maintenance->setPaymentType($this->paymentType($manager, 'VIREMENT'));
|
||||
$maintenance->setBank($this->bank($manager, 'SG'));
|
||||
$this->addContact($maintenance, 'Marie', 'Martin', 'Responsable', '05 49 00 00 01', null, 'marie.martin@maintenance-pro.fr');
|
||||
$this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias', categoryNames: ['Maintenance industrielle']);
|
||||
$this->addAddress($maintenance, ['Chatellerault', 'Saint-Jean'], '86000', 'Poitiers', '12 rue des Acacias');
|
||||
$this->addRib($maintenance, 'Compte principal', 'BNPAFRPPXXX', 'FR1420041010050500013M02606', 0);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
$transport->setPaymentDelay($this->paymentDelay($manager, 'A_RECEPTION'));
|
||||
$transport->setPaymentType($this->paymentType($manager, 'CHEQUE'));
|
||||
$this->addContact($transport, 'Thomas', 'Petit', 'Responsable logistique', '05 56 31 32 33', null, 'thomas.petit@transport-express.fr');
|
||||
$this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs', categoryNames: ['Transport']);
|
||||
$this->addAddress($transport, ['Saint-Jean'], '17400', 'Fontenet', '4 zone des Transporteurs');
|
||||
}
|
||||
|
||||
// === Prestataire minimal — contact par le seul nom (RG-3.04), site 86 ===
|
||||
@@ -237,8 +237,7 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
* moins un site est rattache (RG-3.05) ; categories d'adresse de type
|
||||
* PRESTATAIRE (RG-3.09).
|
||||
*
|
||||
* @param list<string> $siteNames au moins un site (RG-3.05)
|
||||
* @param list<string> $categoryNames categories de type PRESTATAIRE (RG-3.09)
|
||||
* @param list<string> $siteNames au moins un site (RG-3.05)
|
||||
*/
|
||||
private function addAddress(
|
||||
Provider $provider,
|
||||
@@ -247,7 +246,6 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
string $city,
|
||||
string $street,
|
||||
?string $streetComplement = null,
|
||||
array $categoryNames = [],
|
||||
int $position = 0,
|
||||
): void {
|
||||
$address = new ProviderAddress();
|
||||
@@ -262,9 +260,6 @@ class ProviderFixtures extends Fixture implements DependentFixtureInterface
|
||||
foreach ($siteNames as $siteName) {
|
||||
$address->addSite($this->site($siteName));
|
||||
}
|
||||
foreach ($categoryNames as $categoryName) {
|
||||
$address->addCategory($this->category($this->manager, $categoryName));
|
||||
}
|
||||
|
||||
$provider->addAddress($address);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Entity\UploadedDocument;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
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.',
|
||||
normalizer: 'trim',
|
||||
)]
|
||||
#[Assert\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['carrier:read', 'carrier:write:main'])]
|
||||
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\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -123,16 +124,19 @@ class CarrierAddress implements TimestampableInterface, BlamableInterface
|
||||
|
||||
#[ORM\Column(length: 120, nullable: true)]
|
||||
#[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'])]
|
||||
private ?string $city = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[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'])]
|
||||
private ?string $street = null;
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::ADDRESS, message: TextInputPattern::ADDRESS_MESSAGE)]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:addresses'])]
|
||||
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\TimestampableInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use App\Shared\Domain\Validation\TextInputPattern;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Contact d'un transporteur (1:n) — onglet Contact (M4). Jumeau de
|
||||
* SupplierContact (M2) : au moins le prenom OU le nom (RG-4.08, garanti par le
|
||||
* CHECK chk_carrier_contact_name + le Processor), max 2 telephones.
|
||||
* Contact d'un transporteur (1:n) — onglet Contact (M4). ERP-193 (retour metier) :
|
||||
* le bloc Contact est OPTIONNEL — la garde « prenom OU nom » (ex RG-4.08) est
|
||||
* retiree. Reste applicable : max 2 telephones.
|
||||
*
|
||||
* Lecture : proprietes en `carrier:item:read` (embarquees au detail du
|
||||
* 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.
|
||||
#[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\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::PERSON_NAME, message: TextInputPattern::PERSON_NAME_MESSAGE)]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[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\Regex(pattern: TextInputPattern::FREE_TEXT, message: TextInputPattern::FREE_TEXT_MESSAGE)]
|
||||
#[Groups(['carrier:item:read', 'carrier:write:contacts'])]
|
||||
private ?string $jobTitle = null;
|
||||
|
||||
|
||||
+8
-38
@@ -23,23 +23,19 @@ use function is_string;
|
||||
/**
|
||||
* Processor d'ecriture de la sous-ressource Contact d'un transporteur (M4,
|
||||
* 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
|
||||
* au moins le PRENOM OU le NOM (la fonction / le telephone / l'email seuls ne
|
||||
* suffisent pas), porte a la fois par le CHECK BDD chk_carrier_contact_name et par
|
||||
* ce Processor ; le « max 2 telephones » reste une specificite M4.
|
||||
* perimetre ERP-160. ERP-193 (retour metier) : l'onglet Contact n'est plus
|
||||
* obligatoire — la garde « prenom OU nom » (ex RG-4.08) est retiree, un contact
|
||||
* peut donc etre cree sans nom. Le « max 2 telephones » reste une specificite M4.
|
||||
*
|
||||
* Sequence :
|
||||
* - POST / PATCH : rattachement au transporteur parent (linkParent),
|
||||
* normalisation serveur RG-4.13 (prenom/nom Title Case, email lowercase),
|
||||
* 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).
|
||||
*
|
||||
* La garde « prenom OU nom » vit ICI (double du CHECK BDD) pour transformer une
|
||||
* violation SQL (500 generique) en 422 propre rattachee au champ `firstName`
|
||||
* (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).
|
||||
* 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
|
||||
* 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->normalize($data);
|
||||
$this->applyPhones($data);
|
||||
$this->validateName($data);
|
||||
|
||||
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
|
||||
* normalizer sont null-safe : une chaine vide apres trim devient null (donc la
|
||||
* garde RG-4.08 detecte bien « champ non rempli »). Les telephones sont
|
||||
* traites a part (applyPhones).
|
||||
* normalizer sont null-safe : une chaine vide apres trim devient null. Les
|
||||
* telephones sont traites a part (applyPhones).
|
||||
*/
|
||||
private function normalize(CarrierContact $contact): void
|
||||
{
|
||||
@@ -186,30 +180,6 @@ final class CarrierContactProcessor implements ProcessorInterface
|
||||
$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,
|
||||
* 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
|
||||
* 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(
|
||||
Carrier $carrier,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user