fix : retours métier ERP-193 (4 répertoires) (#139)
Auto Tag Develop / tag (push) Successful in 11s

Lot de retours métier **ERP-193** (« Fix tous les retours starseed »), transverse aux 4 répertoires (clients, fournisseurs, prestataires, transporteurs).

## Contenu

- **Pagination** : défaut à 25 items/page sur les 4 répertoires.
- **Libellé** : colonne « Dernière activité » → « Dernière modification ».
- **Consultation** : masquage des onglets vides (coquilles « à venir » + onglets de données sans donnée).
- **Chiffre d'affaires** : plafonné à 999 999 999 999,99 (clamp front + `Assert\LessThanOrEqual` back).
- **Date de création** : interdiction des dates futures (`:max` MalioDate + `Assert\LessThanOrEqual('today')` back).
- **Caractères spéciaux** : blocage des caractères parasites (`²³§~#|…`) dans les champs texte via une allow-list par profil (nom de personne / texte libre / adresse / code alphanumérique) — filtrage front à la frappe + `Assert\Regex` back autoritaire. Email/IBAN/BIC/TVA conservent leurs validateurs de format.
- **UI** : champs en consultation et onglets validés grisés (`readonly` → `disabled`).
- **UI** : boutons « Archiver » en rouge (variant `danger`).

## Tests

- Back : nouveaux tests RG (plafond CA, dates futures, caractères spéciaux) + garde-fou contraintes — suite complète verte (813 tests).
- Front : nouveaux tests unitaires (sanitizers, helpers date/montant) — 615 tests verts, eslint clean.

---------

Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #139
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #139.
This commit is contained in:
2026-06-22 09:40:40 +00:00
committed by Autin
parent 6c938756cc
commit 5e15c1f69f
93 changed files with 2791 additions and 803 deletions
@@ -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, ''),
}