+
hasQualimatSearch.value
? t('transport.carriers.form.qualimat.empty')
: t('transport.carriers.form.qualimat.searchHint'))
-// Contenant (RG-4.03) : Benne / Fond mouvant — select simple.
-const CONTAINER_TYPES = ['BENNE', 'FOND_MOUVANT'] as const
-
-const containerOptions = computed(() =>
- CONTAINER_TYPES.map(code => ({
- value: code,
- label: t(`transport.carriers.containerType.${code}`),
- })),
-)
// Icone (Iconify) affichee dans chaque onglet, par cle.
const TAB_ICONS: Record = {
- qualimat: 'mdi:truck-check-outline',
+ qualimat: 'mdi:truck-fast-outline',
addresses: 'mdi:map-marker-outline',
contacts: 'mdi:account-box-plus-outline',
- prices: 'mdi:currency-eur',
+ prices: 'mdi:payment',
}
// Onglets desactives tant que le formulaire principal n'est pas valide
@@ -706,6 +724,19 @@ async function confirmIntegrate(): Promise {
}
}
+// Indexation plafonnée à 100 % : la clé force le ré-affichage du MalioInputAmount
+// (contrôlé) quand le plafonnement laisse le modelValue inchangé.
+const indexationKey = ref(0)
+
+/** Saisie de l'indexation : plafonne à 100 et re-synchronise le champ si plafonné. */
+function onIndexationInput(value: string): void {
+ const clamped = clampPercent(value)
+ main.indexationRate = clamped
+ if (clamped !== value) {
+ indexationKey.value += 1
+ }
+}
+
/** Retour vers le repertoire transporteurs (fleche d'en-tete). */
function goBack(): void {
router.push('/carriers')
@@ -726,3 +757,12 @@ async function onSubmitMain(): Promise {
}
}
+
+
diff --git a/frontend/modules/transport/types/carrierForm.ts b/frontend/modules/transport/types/carrierForm.ts
index b31db2d..d1420d2 100644
--- a/frontend/modules/transport/types/carrierForm.ts
+++ b/frontend/modules/transport/types/carrierForm.ts
@@ -40,7 +40,8 @@ export function emptyCarrierMain(): CarrierMainDraft {
certificationType: null,
isChartered: false,
indexationRate: '',
- containerType: null,
+ // Défaut métier : Benne pré-sélectionné (radio du formulaire principal).
+ containerType: 'BENNE',
volumeM3: '',
liotPlates: '',
dischargeDocumentIri: null,
diff --git a/frontend/modules/transport/utils/forms/__tests__/carrierMappers.test.ts b/frontend/modules/transport/utils/forms/__tests__/carrierMappers.test.ts
new file mode 100644
index 0000000..ecafb91
--- /dev/null
+++ b/frontend/modules/transport/utils/forms/__tests__/carrierMappers.test.ts
@@ -0,0 +1,120 @@
+import { describe, it, expect } from 'vitest'
+import {
+ canEditCarrier,
+ iriOf,
+ labelOfRelation,
+ mapAddressToDraft,
+ mapContactToDraft,
+ mapMainToDraft,
+ mapPriceToDraft,
+ showArchiveAction,
+ showRestoreAction,
+ type CarrierDetail,
+} from '../carrierMappers'
+
+/**
+ * Tests des mappers détail → brouillons (M4 Transport, ERP-170) : peuplent les écrans
+ * Consultation / Modification depuis la SEULE réponse `GET /api/carriers/{id}`, et
+ * helpers de visibilité des boutons (Modifier / Archiver / Restaurer) selon la permission.
+ */
+describe('carrierMappers', () => {
+ it('iriOf : objet embarqué, IRI nu, ou null', () => {
+ expect(iriOf({ '@id': '/api/clients/3' })).toBe('/api/clients/3')
+ expect(iriOf('/api/sites/1')).toBe('/api/sites/1')
+ expect(iriOf(null)).toBeNull()
+ expect(iriOf(undefined)).toBeNull()
+ })
+
+ 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')
+ expect(labelOfRelation('/api/sites/1')).toBe('')
+ expect(labelOfRelation(null)).toBe('')
+ })
+
+ it('mapMainToDraft : scalaires + IRI décharge / qualimat', () => {
+ const detail: CarrierDetail = {
+ '@id': '/api/carriers/7',
+ id: 7,
+ name: 'TRANSPORTS ACME',
+ certificationType: 'QUALIMAT',
+ isChartered: true,
+ indexationRate: '5.00',
+ containerType: 'BENNE',
+ volumeM3: '30.00',
+ dischargeDocument: { '@id': '/api/uploaded_documents/4' },
+ qualimatCarrier: { '@id': '/api/qualimat_carriers/42' },
+ }
+ expect(mapMainToDraft(detail)).toEqual({
+ name: 'TRANSPORTS ACME',
+ certificationType: 'QUALIMAT',
+ isChartered: true,
+ indexationRate: '5.00',
+ containerType: 'BENNE',
+ volumeM3: '30.00',
+ liotPlates: '',
+ dischargeDocumentIri: '/api/uploaded_documents/4',
+ qualimatCarrierIri: '/api/qualimat_carriers/42',
+ })
+ })
+
+ it('mapAddressToDraft : pays par défaut France si absent', () => {
+ expect(mapAddressToDraft({ '@id': '/api/carrier_addresses/3', id: 3, postalCode: '86000', city: 'Poitiers' }))
+ .toEqual({ id: 3, country: 'France', postalCode: '86000', city: 'Poitiers', street: null, streetComplement: null })
+ })
+
+ it('mapContactToDraft : hasSecondaryPhone vrai seulement si 2e numéro présent', () => {
+ const one = mapContactToDraft({ '@id': '/api/carrier_contacts/1', id: 1, firstName: 'Jean', phonePrimary: '0102030405' })
+ expect(one.hasSecondaryPhone).toBe(false)
+ expect(one.firstName).toBe('Jean')
+
+ const two = mapContactToDraft({ '@id': '/api/carrier_contacts/2', id: 2, phonePrimary: '0102030405', phoneSecondary: '0605040302' })
+ expect(two.hasSecondaryPhone).toBe(true)
+ expect(two.phoneSecondary).toBeTruthy()
+ })
+
+ it('mapPriceToDraft : direction + IRIs des relations de branche', () => {
+ const draft = mapPriceToDraft({
+ '@id': '/api/carrier_prices/5',
+ id: 5,
+ direction: 'CLIENT',
+ client: { '@id': '/api/clients/3' },
+ clientDeliveryAddress: { '@id': '/api/client_addresses/8' },
+ departureSite: '/api/sites/1',
+ containerType: 'BENNE',
+ pricingUnit: 'FORFAIT',
+ price: '120.00',
+ priceState: 'EN_COURS',
+ })
+ expect(draft).toMatchObject({
+ id: 5,
+ direction: 'CLIENT',
+ clientIri: '/api/clients/3',
+ clientDeliveryAddressIri: '/api/client_addresses/8',
+ departureSiteIri: '/api/sites/1',
+ supplierIri: null,
+ containerType: 'BENNE',
+ pricingUnit: 'FORFAIT',
+ price: '120.00',
+ priceState: 'EN_COURS',
+ })
+ })
+
+ it('visibilité des boutons selon la permission', () => {
+ const can = (granted: string[]) => (code: string) => granted.includes(code)
+
+ // Modifier : seulement avec manage.
+ expect(canEditCarrier(can(['transport.carriers.manage']))).toBe(true)
+ expect(canEditCarrier(can(['transport.carriers.view']))).toBe(false)
+
+ // Archiver : permission archive ET actif ; Restaurer : archive ET archivé.
+ const withArchive = can(['transport.carriers.archive'])
+ const noArchive = can(['transport.carriers.manage'])
+ expect(showArchiveAction(withArchive, false)).toBe(true)
+ expect(showArchiveAction(withArchive, true)).toBe(false)
+ expect(showRestoreAction(withArchive, true)).toBe(true)
+ expect(showRestoreAction(withArchive, false)).toBe(false)
+ expect(showArchiveAction(noArchive, false)).toBe(false)
+ expect(showRestoreAction(noArchive, true)).toBe(false)
+ })
+})
diff --git a/frontend/modules/transport/utils/forms/__tests__/numberInput.test.ts b/frontend/modules/transport/utils/forms/__tests__/numberInput.test.ts
new file mode 100644
index 0000000..fff0a56
--- /dev/null
+++ b/frontend/modules/transport/utils/forms/__tests__/numberInput.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { clampPercent, sanitizeDecimal } from '../numberInput'
+
+describe('numberInput — saisie volume / indexation (ERP-170)', () => {
+ it('sanitizeDecimal : ne garde que chiffres + un seul point', () => {
+ expect(sanitizeDecimal('30')).toBe('30')
+ expect(sanitizeDecimal('30.5')).toBe('30.5')
+ expect(sanitizeDecimal('30,5 kg')).toBe('30.5') // virgule FR → point ; espace + lettres retirés
+ expect(sanitizeDecimal('1.2.3')).toBe('1.23') // un seul point conservé
+ expect(sanitizeDecimal('abc12.3x')).toBe('12.3')
+ expect(sanitizeDecimal('')).toBe('')
+ })
+
+ it('clampPercent : plafonne à 100, laisse le reste tel quel', () => {
+ expect(clampPercent('50')).toBe('50')
+ expect(clampPercent('100')).toBe('100')
+ expect(clampPercent('150')).toBe('100')
+ expect(clampPercent('100.01')).toBe('100')
+ expect(clampPercent('12,5')).toBe('12,5') // ≤ 100 → inchangé
+ expect(clampPercent('')).toBe('')
+ })
+})
diff --git a/frontend/modules/transport/utils/forms/carrierMappers.ts b/frontend/modules/transport/utils/forms/carrierMappers.ts
new file mode 100644
index 0000000..7ddd01e
--- /dev/null
+++ b/frontend/modules/transport/utils/forms/carrierMappers.ts
@@ -0,0 +1,190 @@
+/**
+ * Helpers purs des écrans Consultation / Modification transporteur (M4, ERP-170) —
+ * miroir de `providerDetail.ts` (M3). Mappent le payload `GET /api/carriers/{id}`
+ * (relations embarquées via les groupes `carrier:item:read` + `qualimat:read` +
+ * read-groups cross-module client/supplier/site/adresses) vers les brouillons
+ * « plats » partagés avec les blocs Adresse / Contact / Prix.
+ *
+ * Ne touchent ni à l'API ni à l'état réactif (testables unitairement). Les champs
+ * nuls peuvent être OMIS (skip_null_values) → toujours lire avec `?? null`.
+ */
+
+import { formatPhoneFR } from '~/shared/utils/phone'
+import type {
+ CarrierAddressFormDraft,
+ CarrierContactFormDraft,
+ CarrierMainDraft,
+ CarrierPriceFormDraft,
+} from '~/modules/transport/types/carrierForm'
+
+/** Référence Hydra embarquée minimale (@id toujours présent). */
+export interface HydraRef {
+ '@id': string
+ [key: string]: unknown
+}
+
+/** Une relation peut être embarquée (objet), un IRI nu (chaîne) ou absente. */
+export type Relation = HydraRef | string | null | undefined
+
+/** Adresse embarquée (groupe carrier:item:read). */
+export interface CarrierAddressRead extends HydraRef {
+ id: number
+ country?: string | null
+ postalCode?: string | null
+ city?: string | null
+ street?: string | null
+ streetComplement?: string | null
+}
+
+/** Contact embarqué (groupe carrier:item:read). */
+export interface CarrierContactRead extends HydraRef {
+ id: number
+ firstName?: string | null
+ lastName?: string | null
+ jobTitle?: string | null
+ phonePrimary?: string | null
+ phoneSecondary?: string | null
+ email?: string | null
+}
+
+/** Prix embarqué (groupe carrier:item:read + relations cross-module). */
+export interface CarrierPriceRead extends HydraRef {
+ id: number
+ direction?: string | null
+ client?: Relation
+ clientDeliveryAddress?: Relation
+ departureSite?: Relation
+ supplier?: Relation
+ supplierSupplyAddress?: Relation
+ deliverySite?: Relation
+ containerType?: string | null
+ pricingUnit?: string | null
+ price?: string | null
+ priceState?: string | null
+}
+
+/**
+ * Détail d'un transporteur (`GET /api/carriers/{id}`). Tous les champs optionnels :
+ * skip_null_values peut omettre n'importe quelle clé.
+ */
+export interface CarrierDetail extends HydraRef {
+ id: number
+ name?: string | null
+ certificationType?: string | null
+ isChartered?: boolean
+ indexationRate?: string | null
+ containerType?: string | null
+ volumeM3?: string | null
+ liotPlates?: string | null
+ dischargeDocument?: Relation
+ qualimatCarrier?: Relation
+ isArchived?: boolean
+ addresses?: CarrierAddressRead[]
+ contacts?: CarrierContactRead[]
+ prices?: CarrierPriceRead[]
+}
+
+/** Extrait l'IRI d'une relation (objet embarqué, IRI nu, ou null si absente). */
+export function iriOf(relation: Relation): string | null {
+ if (relation === null || relation === undefined) {
+ return null
+ }
+ if (typeof relation === 'string') {
+ return relation
+ }
+ return relation['@id'] ?? 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.
+ */
+export function labelOfRelation(relation: Relation): string {
+ if (!relation || typeof relation === 'string') {
+ return ''
+ }
+ const name = relation.name as string | undefined
+ if (name) {
+ return name
+ }
+ const parts = [relation.street, relation.postalCode, relation.city].filter(Boolean)
+ return parts.join(' · ')
+}
+
+/** Mappe le détail vers le brouillon du formulaire principal. */
+export function mapMainToDraft(detail: CarrierDetail): CarrierMainDraft {
+ return {
+ name: detail.name ?? '',
+ certificationType: detail.certificationType ?? null,
+ isChartered: detail.isChartered ?? false,
+ indexationRate: detail.indexationRate ?? '',
+ containerType: detail.containerType ?? null,
+ volumeM3: detail.volumeM3 ?? '',
+ liotPlates: detail.liotPlates ?? '',
+ dischargeDocumentIri: iriOf(detail.dischargeDocument),
+ qualimatCarrierIri: iriOf(detail.qualimatCarrier),
+ }
+}
+
+/** Mappe une adresse embarquée vers un brouillon. */
+export function mapAddressToDraft(address: CarrierAddressRead): CarrierAddressFormDraft {
+ return {
+ id: address.id,
+ country: address.country ?? 'France',
+ postalCode: address.postalCode ?? null,
+ city: address.city ?? null,
+ street: address.street ?? null,
+ streetComplement: address.streetComplement ?? null,
+ }
+}
+
+/** Mappe un contact embarqué vers un brouillon (téléphones formatés XX XX XX XX XX). */
+export function mapContactToDraft(contact: CarrierContactRead): CarrierContactFormDraft {
+ const secondary = contact.phoneSecondary ?? null
+ return {
+ id: contact.id,
+ firstName: contact.firstName ?? null,
+ lastName: contact.lastName ?? null,
+ jobTitle: contact.jobTitle ?? null,
+ phonePrimary: contact.phonePrimary ? formatPhoneFR(contact.phonePrimary) : null,
+ phoneSecondary: secondary ? formatPhoneFR(secondary) : null,
+ email: contact.email ?? null,
+ hasSecondaryPhone: secondary !== null && secondary !== '',
+ }
+}
+
+/** Mappe un prix embarqué vers un brouillon (relations en IRI). */
+export function mapPriceToDraft(price: CarrierPriceRead): CarrierPriceFormDraft {
+ const direction = price.direction === 'CLIENT' || price.direction === 'FOURNISSEUR'
+ ? price.direction
+ : null
+ return {
+ id: price.id,
+ direction,
+ clientIri: iriOf(price.client),
+ clientDeliveryAddressIri: iriOf(price.clientDeliveryAddress),
+ departureSiteIri: iriOf(price.departureSite),
+ supplierIri: iriOf(price.supplier),
+ supplierSupplyAddressIri: iriOf(price.supplierSupplyAddress),
+ deliverySiteIri: iriOf(price.deliverySite),
+ containerType: price.containerType ?? null,
+ pricingUnit: price.pricingUnit ?? null,
+ price: price.price ?? null,
+ priceState: price.priceState ?? null,
+ }
+}
+
+/** Bouton « Modifier » : visible avec la permission `manage` (Admin / Bureau). */
+export function canEditCarrier(can: (code: string) => boolean): boolean {
+ return can('transport.carriers.manage')
+}
+
+/** Bouton « Archiver » : permission archive ET transporteur encore actif (Admin seul). */
+export function showArchiveAction(can: (code: string) => boolean, isArchived: boolean): boolean {
+ return can('transport.carriers.archive') && !isArchived
+}
+
+/** Bouton « Restaurer » : permission archive ET transporteur déjà archivé (Admin seul). */
+export function showRestoreAction(can: (code: string) => boolean, isArchived: boolean): boolean {
+ return can('transport.carriers.archive') && isArchived
+}
diff --git a/frontend/modules/transport/utils/forms/numberInput.ts b/frontend/modules/transport/utils/forms/numberInput.ts
new file mode 100644
index 0000000..29f9f8e
--- /dev/null
+++ b/frontend/modules/transport/utils/forms/numberInput.ts
@@ -0,0 +1,28 @@
+/**
+ * Helpers de saisie numérique du formulaire principal transporteur (ERP-170).
+ * Champs texte restreints (volume m³ décimal, indexation plafonnée). Purs / testables.
+ */
+
+/**
+ * Restreint une saisie à un nombre décimal : chiffres + UN seul point (RG volume m³,
+ * « nombres avec des points » comme les autres modules). La virgule décimale FR est
+ * convertie en point (« 30,5 » → « 30.5 ») ; tout autre caractère est supprimé.
+ */
+export function sanitizeDecimal(value: string): string {
+ let cleaned = (value ?? '').replace(/,/g, '.').replace(/[^0-9.]/g, '')
+ const dot = cleaned.indexOf('.')
+ if (dot !== -1) {
+ // Conserve le 1er point, retire les suivants.
+ cleaned = cleaned.slice(0, dot + 1) + cleaned.slice(dot + 1).replace(/\./g, '')
+ }
+ return cleaned
+}
+
+/**
+ * Plafonne un pourcentage à 100 (contrainte FRONT : l'indexation n'a pas de max back).
+ * Renvoie « 100 » si la valeur saisie dépasse 100, sinon la valeur telle quelle.
+ */
+export function clampPercent(value: string): string {
+ const n = Number(String(value ?? '').replace(',', '.').replace(/\s/g, ''))
+ return (!Number.isNaN(n) && n > 100) ? '100' : value
+}