Merge pull request 'feat(transport) : consultation + modification transporteur (ERP-170)' (#129) from feat/erp-170-carrier-view-edit into develop
Auto Tag Develop / tag (push) Successful in 7s

This commit was merged in pull request #129.
This commit is contained in:
2026-06-18 08:50:07 +00:00
13 changed files with 1553 additions and 36 deletions
+42 -1
View File
@@ -530,7 +530,48 @@
"integrateSuccess": "Transporteur QUALIMAT intégré",
"addressSaved": "Adresse enregistrée",
"contactSaved": "Contact enregistré",
"priceSaved": "Prix enregistré"
"priceSaved": "Prix enregistré",
"updateSuccess": "Transporteur mis à jour avec succès",
"archiveSuccess": "Transporteur archivé avec succès",
"restoreSuccess": "Transporteur restauré avec succès"
},
"action": {
"edit": "Modifier",
"archive": "Archiver",
"restore": "Restaurer"
},
"consultation": {
"title": "Consultation transporteur",
"back": "Retour au répertoire",
"loading": "Chargement du transporteur…",
"notFound": "Transporteur introuvable.",
"confirmArchive": {
"title": "Archiver le transporteur",
"message": "Ce transporteur n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?"
},
"confirmRestore": {
"title": "Restaurer le transporteur",
"message": "Ce transporteur réapparaîtra dans le répertoire actif. Confirmer la restauration ?"
},
"price": {
"group": "Contenant",
"carrier": "Transporteurs",
"aproOrSite": "Adresse sites",
"delivery": "Adresse livraisons",
"forfait": "Forfait €",
"tonne": "Tonne €",
"indexation": "Indexation",
"state": "État du prix",
"export": "Exporter",
"empty": "Aucun prix pour ce transporteur."
}
},
"edit": {
"title": "Modifier le transporteur",
"back": "Retour à la consultation",
"loading": "Chargement du transporteur…",
"notFound": "Transporteur introuvable.",
"save": "Enregistrer"
},
"containerType": {
"BENNE": "Benne",
@@ -17,7 +17,7 @@
<div class="flex h-12 items-center gap-6">
<MalioRadioButton
:model-value="model.direction"
name="price-direction"
:name="`price-direction-${uid}`"
value="CLIENT"
:label="t('transport.carriers.form.price.directionClient')"
:disabled="readonly"
@@ -26,7 +26,7 @@
/>
<MalioRadioButton
:model-value="model.direction"
name="price-direction"
:name="`price-direction-${uid}`"
value="FOURNISSEUR"
:label="t('transport.carriers.form.price.directionSupplier')"
:disabled="readonly"
@@ -112,7 +112,7 @@
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="model.containerType"
name="price-container"
:name="`price-container-${uid}`"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
:disabled="readonly"
@@ -121,7 +121,7 @@
/>
<MalioRadioButton
:model-value="model.containerType"
name="price-container"
:name="`price-container-${uid}`"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
:disabled="readonly"
@@ -137,7 +137,7 @@
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="model.pricingUnit"
name="price-unit"
:name="`price-unit-${uid}`"
value="FORFAIT"
:label="t('transport.carriers.form.price.pricingForfait')"
:disabled="readonly"
@@ -146,7 +146,7 @@
/>
<MalioRadioButton
:model-value="model.pricingUnit"
name="price-unit"
:name="`price-unit-${uid}`"
value="TONNE"
:label="t('transport.carriers.form.price.pricingTonne')"
:disabled="readonly"
@@ -181,7 +181,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, ref, useId, watch } from 'vue'
import type { CarrierPriceFormDraft } from '~/modules/transport/types/carrierForm'
interface SelectOption {
@@ -212,6 +212,11 @@ const emit = defineEmits<{
const { t } = useI18n()
const api = useApi()
// Identifiant unique par instance : les groupes de radios (sens / contenant / tarif)
// doivent avoir un `name` PROPRE à chaque bloc prix, sinon plusieurs blocs partagent
// le même groupe HTML et leurs radios se désélectionnent mutuellement.
const uid = useId()
const model = computed(() => props.modelValue)
const priceStateOptions = computed<SelectOption[]>(() => [
@@ -111,6 +111,8 @@ describe('useCarrierForm', () => {
form.main.name = 'Acme'
form.main.certificationType = 'GMP_PLUS'
form.main.isChartered = true
// Annule le défaut « BENNE » pour vérifier la garde « contenant obligatoire ».
form.main.containerType = null
const created = await form.submitMain()
@@ -323,16 +325,18 @@ describe('useCarrierForm — champs conditionnels (ERP-166)', () => {
})
})
it('RG-4.03 affrété mais champs vides : omis du payload (422 NotBlank back)', () => {
it('RG-4.03 affrété, indexation/volume vides : omis du payload (containerType garde son défaut BENNE)', () => {
const form = useCarrierForm()
form.main.name = 'Acme'
form.main.certificationType = 'GMP_PLUS'
form.main.isChartered = true
// indexation / volume vides → omis (422 NotBlank back) ; containerType défaut « BENNE » envoyé.
expect(form.buildMainPayload()).toEqual({
name: 'Acme',
certificationType: 'GMP_PLUS',
isChartered: true,
containerType: 'BENNE',
})
})
@@ -912,3 +916,49 @@ describe('useCarrierForm — onglet Prix (ERP-169)', () => {
expect(form.prices.value).toHaveLength(1)
})
})
describe('useCarrierForm — édition (ERP-170)', () => {
beforeEach(() => {
mockPost.mockReset()
mockPatch.mockReset()
})
it('prefillFrom : peuple carrierId + principal + sous-collections, passe en editMode', () => {
const form = useCarrierForm()
form.prefillFrom({
'@id': '/api/carriers/7',
id: 7,
name: 'TRANSPORTS ACME',
certificationType: 'GMP_PLUS',
addresses: [{ '@id': '/api/carrier_addresses/3', id: 3, city: 'Poitiers' }],
contacts: [{ '@id': '/api/carrier_contacts/9', id: 9, lastName: 'Doe', phonePrimary: '0102030405' }],
prices: [{ '@id': '/api/carrier_prices/5', id: 5, direction: 'CLIENT', client: { '@id': '/api/clients/3' }, containerType: 'BENNE', pricingUnit: 'FORFAIT', price: '120', priceState: 'EN_COURS' }],
})
expect(form.carrierId.value).toBe(7)
expect(form.editMode.value).toBe(true)
expect(form.main.name).toBe('TRANSPORTS ACME')
expect(form.main.certificationType).toBe('GMP_PLUS')
expect(form.addresses.value).toHaveLength(1)
expect(form.addresses.value[0]?.id).toBe(3)
expect(form.contacts.value[0]?.id).toBe(9)
expect(form.prices.value[0]?.clientIri).toBe('/api/clients/3')
})
it('updateMain : PATCH /carriers/{id} (pas de POST), réaffiche le nom normalisé', async () => {
mockPatch.mockResolvedValueOnce({ id: 7, name: 'TRANSPORTS ACME', certificationType: 'GMP_PLUS' })
const form = useCarrierForm()
form.prefillFrom({ '@id': '/api/carriers/7', id: 7, name: 'Transports Acme', certificationType: 'GMP_PLUS' })
const ok = await form.updateMain()
expect(ok).toBe(true)
expect(mockPost).not.toHaveBeenCalled()
expect(mockPatch).toHaveBeenCalledWith(
'/carriers/7',
expect.objectContaining({ name: 'Transports Acme', certificationType: 'GMP_PLUS' }),
{ toast: false },
)
expect(form.main.name).toBe('TRANSPORTS ACME')
})
})
@@ -0,0 +1,68 @@
import { ref } from 'vue'
import type { CarrierDetail } from '~/modules/transport/utils/forms/carrierMappers'
/**
* Chargement et actions d'archivage d'un transporteur unique (écrans Consultation /
* Modification, ERP-170). Miroir de `useProvider` (M3) / `useSupplier` (M2). Lit le
* détail embarqué via `GET /api/carriers/{id}` (qualimatCarrier + addresses /
* contacts / prices sous `carrier:item:read`, relations cross-module via leurs
* read-groups) — une SEULE requête peuple les deux écrans (embed borné, pas de N+1).
*
* L'en-tête `Accept: application/ld+json` est imposé pour obtenir le payload Hydra
* complet (avec les `@id` des relations embarquées, indispensables au préremplissage).
*
* État 100 % local à l'instance (refs). Les erreurs d'archivage / restauration
* (notamment le 409 d'homonyme actif à la restauration) sont PROPAGÉES à l'appelant.
*/
export function useCarrier(id: number | string) {
const api = useApi()
const carrier = ref<CarrierDetail | null>(null)
const loading = ref(false)
const error = ref(false)
/** Récupère le détail complet (embed qualimatCarrier + addresses / contacts / prices). */
function fetchDetail(): Promise<CarrierDetail> {
return api.get<CarrierDetail>(
`/carriers/${id}`,
{},
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
}
/** Charge le détail du transporteur. En cas d'échec : `error = true`, `carrier = null`. */
async function load(): Promise<void> {
loading.value = true
error.value = false
try {
carrier.value = await fetchDetail()
}
catch {
error.value = true
carrier.value = null
}
finally {
loading.value = false
}
}
/**
* Bascule l'archivage (PATCH `isArchived` SEUL — groupe carrier:write:archive ;
* tout autre champ → 422, security archive = Admin seul), puis RECHARGE le détail
* complet (la réponse du PATCH ne porte pas l'embed des sous-collections). Toute
* erreur est propagée à l'appelant AVANT le rechargement.
*/
async function setArchived(isArchived: boolean): Promise<void> {
await api.patch(`/carriers/${id}`, { isArchived }, { toast: false })
carrier.value = await fetchDetail()
}
return {
carrier,
loading,
error,
load,
archive: () => setArchived(true),
restore: () => setArchived(false),
}
}
@@ -18,6 +18,13 @@ import {
import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress'
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
import {
mapAddressToDraft,
mapContactToDraft,
mapMainToDraft,
mapPriceToDraft,
type CarrierDetail,
} from '~/modules/transport/utils/forms/carrierMappers'
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
@@ -257,6 +264,68 @@ export function useCarrierForm() {
}
}
/**
* MODIFICATION du formulaire principal (ERP-170) : PATCH /api/carriers/{id} sur le
* groupe carrier:write:main (PAS de re-POST). Pré-check front + 409 doublon / 422
* inline comme `submitMain`. Ne verrouille rien et ne bascule pas d'onglet (édition
* = navigation libre). Retourne true si le PATCH a réussi.
*/
async function updateMain(): Promise<boolean> {
if (carrierId.value === null || mainSubmitting.value) return false
mainErrors.clearErrors()
if (!validateMainFront()) return false
mainSubmitting.value = true
try {
const updated = await api.patch<CarrierMainResponse>(
`/carriers/${carrierId.value}`,
buildMainPayload(),
{ toast: false },
)
main.name = updated.name ?? main.name
main.certificationType = updated.certificationType ?? main.certificationType
return true
}
catch (error) {
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
const message = t('transport.carriers.form.duplicateName')
mainErrors.setError('name', message)
toast.error({ title: t('transport.carriers.toast.error'), message })
}
else {
mainErrors.handleApiError(error, { fallbackMessage: t('transport.carriers.toast.error') })
}
return false
}
finally {
mainSubmitting.value = false
}
}
/**
* Pré-remplit le formulaire depuis le détail `GET /api/carriers/{id}` (écran
* Modification) : peuple carrierId + principal + adresses / contacts / prix via les
* mappers, passe en `editMode` (navigation libre, tous onglets accessibles, bloc
* principal éditable). Au moins un bloc Adresse / Contact affiché même sans donnée.
*/
function prefillFrom(detail: CarrierDetail): void {
carrierId.value = detail.id
editMode.value = true
mainLocked.value = false
unlockedIndex.value = tabKeys.value.length - 1
Object.assign(main, mapMainToDraft(detail))
const mappedAddresses = (detail.addresses ?? []).map(mapAddressToDraft)
addresses.value = mappedAddresses.length > 0 ? mappedAddresses : [emptyCarrierAddress()]
const mappedContacts = (detail.contacts ?? []).map(mapContactToDraft)
contacts.value = mappedContacts.length > 0 ? mappedContacts : [emptyCarrierContact()]
prices.value = (detail.prices ?? []).map(mapPriceToDraft)
}
/**
* PATCH partiel du transporteur (mode strict : un seul groupe de sérialisation
* par appel — spec-back § 2.9). Servira les onglets à champs scalaires des
@@ -702,6 +771,8 @@ export function useCarrierForm() {
validateMainFront,
buildMainPayload,
submitMain,
updateMain,
prefillFrom,
patchCarrier,
applyQualimatSelection,
completeTab,
@@ -0,0 +1,384 @@
<template>
<div>
<!-- En-tête : retour consultation + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('transport.carriers.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('transport.carriers.edit.title') }}</h1>
</div>
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('transport.carriers.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('transport.carriers.edit.notFound') }}</p>
<template v-else>
<!-- Formulaire principal (éditable, PATCH partiel) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.name"
: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"
/>
<template v-if="!isLiot">
<MalioSelect
:model-value="main.certificationType"
:options="certificationOptions"
:label="t('transport.carriers.form.main.certificationType')"
empty-option-label=""
:required="true"
:readonly="certificationReadonly"
:error="mainErrors.errors.certificationType"
@update:model-value="(v: string | number | null) => main.certificationType = v === null ? null : String(v)"
/>
<MalioInputUpload
v-if="showDischarge"
:label="t('transport.carriers.form.main.discharge')"
accept="application/pdf,image/*"
:required="true"
:clearable="true"
:error="mainErrors.errors.dischargeDocument"
@clear="main.dischargeDocumentIri = null"
/>
<div v-else class="hidden xl:block"></div>
<div class="flex h-12 items-center">
<MalioCheckbox
id="carrier-edit-chartered"
:label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered"
:reserve-message-space="false"
@update:model-value="(val: boolean) => main.isChartered = val"
/>
</div>
<template v-if="showCharteredFields">
<MalioInputAmount
:key="indexationKey"
:model-value="main.indexationRate"
:label="t('transport.carriers.form.main.indexationRate')"
icon-name="mdi:percent"
icon-position="right"
:required="true"
:error="mainErrors.errors.indexationRate"
@update:model-value="onIndexationInput"
/>
<!-- Contenant : Benne / Fond mouvant en radios, centrés (h-12) comme
à l'onglet Prix (Benne par défaut). -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="main.containerType"
name="carrier-main-container"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
<MalioRadioButton
:model-value="main.containerType"
name="carrier-main-container"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
</div>
<p v-if="mainErrors.errors.containerType" class="ml-[2px] text-xs text-m-danger">{{ mainErrors.errors.containerType }}</p>
</div>
<MalioInputText
:model-value="main.volumeM3"
:label="t('transport.carriers.form.main.volumeM3')"
:required="true"
:error="mainErrors.errors.volumeM3"
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
/>
</template>
</template>
</div>
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('transport.carriers.edit.save')"
:disabled="mainSubmitting"
@click="onUpdateMain"
/>
</div>
<!-- ── Onglets éditables (navigation libre, PATCH partiel par onglet) ── -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<CarrierAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@degraded="onAddressDegraded"
/>
<div class="flex justify-center gap-6">
<MalioButton variant="secondary" icon-name="mdi:add-bold" icon-position="left" :label="t('transport.carriers.form.address.add')" :disabled="!canAddAddress" @click="addAddress" />
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitAddresses" />
</div>
</div>
</template>
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<CarrierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
:removable="isRowRemovable(contacts, index)"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
/>
<div class="flex justify-center gap-6">
<MalioButton variant="secondary" icon-name="mdi:add-bold" icon-position="left" :label="t('transport.carriers.form.contact.add')" :disabled="!canAddContact" @click="addContact" />
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitContacts" />
</div>
</div>
</template>
<template #prices>
<div class="mt-12 flex flex-col gap-6">
<CarrierPriceBlock
v-for="(price, index) in prices"
:key="index"
:model-value="price"
:client-options="clientOptions"
:supplier-options="supplierOptions"
:site-options="siteOptions"
removable
:errors="priceErrors[index]"
@update:model-value="(v) => prices[index] = v"
@remove="askRemovePrice(index)"
/>
<div class="flex justify-center gap-6">
<MalioButton variant="secondary" icon-name="mdi:add-bold" icon-position="left" :label="t('transport.carriers.form.price.add')" :disabled="!canAddPrice" @click="addPrice" />
<MalioButton variant="primary" :label="t('transport.carriers.edit.save')" :disabled="tabSubmitting" @click="onSubmitPrices" />
</div>
</div>
</template>
</MalioTabList>
</template>
<!-- Modal de confirmation de suppression de bloc. -->
<MalioModal v-model="deleteConfirm.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ t('transport.carriers.form.confirmDelete.title') }}</h2>
</template>
<p>{{ t('transport.carriers.form.confirmDelete.message') }}</p>
<template #footer>
<MalioButton variant="secondary" button-class="flex-1" :label="t('transport.carriers.form.confirmDelete.cancel')" @click="deleteConfirm.open = false" />
<MalioButton variant="danger" button-class="flex-1" :label="t('transport.carriers.form.confirmDelete.confirm')" @click="runDeleteConfirm" />
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import CarrierAddressBlock from '~/modules/transport/components/CarrierAddressBlock.vue'
import CarrierContactBlock from '~/modules/transport/components/CarrierContactBlock.vue'
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
import { useCarrier } from '~/modules/transport/composables/useCarrier'
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
interface SelectOption {
value: string
label: string
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const api = useApi()
const { can } = usePermissions()
const carrierId = route.params.id as string
useHead({ title: t('transport.carriers.edit.title') })
// Gating route : l'édition est réservée à `manage` ; sinon retour consultation.
if (!can('transport.carriers.manage')) {
await navigateTo(`/carriers/${carrierId}`)
}
const { carrier, loading, error, load } = useCarrier(carrierId)
const {
main,
mainSubmitting,
tabSubmitting,
mainErrors,
isLiot,
certificationReadonly,
showCharteredFields,
showDischarge,
addresses,
addressErrors,
canAddAddress,
addAddress,
removeAddress,
submitAddresses,
contacts,
contactErrors,
canAddContact,
addContact,
removeContact,
submitContacts,
prices,
priceErrors,
canAddPrice,
addPrice,
removePrice,
submitPrices,
updateMain,
prefillFrom,
} = useCarrierForm()
const SELECTABLE_CERTIFICATIONS = ['GMP_PLUS', 'OVOCOM', 'COMPTE_PROPRE', 'AUTRE'] as const
const certificationOptions = computed<SelectOption[]>(() => {
const codes: string[] = [...SELECTABLE_CERTIFICATIONS]
if (main.certificationType === 'QUALIMAT') codes.unshift('QUALIMAT')
return codes.map(code => ({ value: code, label: t(`transport.carriers.certification.${code}`) }))
})
const TAB_ICONS: Record<string, string> = {
addresses: 'mdi:map-marker-outline',
contacts: 'mdi:account-box-plus-outline',
prices: 'mdi:payment',
}
const activeTab = ref('addresses')
const tabs = computed(() => ['addresses', 'contacts', 'prices'].map(key => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// ── Référentiels (pays + clients / fournisseurs / sites pour l'onglet Prix) ───
const countryOptions = ref<SelectOption[]>([{ value: 'France', label: 'France' }])
const clientOptions = ref<SelectOption[]>([])
const supplierOptions = ref<SelectOption[]>([])
const siteOptions = ref<SelectOption[]>([])
async function loadOptions(url: string, target: typeof clientOptions, labelOf: (m: Record<string, unknown>) => string): Promise<void> {
try {
const data = await api.get<{ member?: Record<string, unknown>[] }>(url, { pagination: 'false' }, { headers: { Accept: 'application/ld+json' }, toast: false })
target.value = (data.member ?? []).map(m => ({ value: String(m['@id']), label: labelOf(m) }))
}
catch {
target.value = []
}
}
async function loadCountries(): Promise<void> {
try {
const data = await api.get<{ member?: { name: string }[] }>('/countries', { pagination: 'false' }, { headers: { Accept: 'application/ld+json' }, toast: false })
const list = (data.member ?? []).map(c => ({ value: c.name, label: c.name }))
countryOptions.value = list.some(c => c.value === 'France') ? list : [{ value: 'France', label: 'France' }, ...list]
}
catch { /* fallback France */ }
}
// ── Chargement + préremplissage ──────────────────────────────────────────────
onMounted(async () => {
await load()
if (carrier.value) {
prefillFrom(carrier.value)
}
loadCountries().catch(() => {})
void loadOptions('/clients', clientOptions, m => String(m.companyName ?? m['@id']))
void loadOptions('/suppliers', supplierOptions, m => String(m.companyName ?? m['@id']))
void loadOptions('/sites', siteOptions, m => String(m.name ?? m['@id']))
})
function apiErrorMessage(err: unknown): string {
const data = (err as { response?: { _data?: unknown } })?.response?._data
return extractApiErrorMessage(data) || t('transport.carriers.toast.error')
}
// 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
}
}
function goBack(): void {
router.push(`/carriers/${carrierId}`)
}
/** PATCH du formulaire principal (pas de re-POST). */
async function onUpdateMain(): Promise<void> {
const ok = await updateMain()
if (ok) {
toast.success({ title: t('transport.carriers.toast.updateSuccess') })
}
}
async function onSubmitAddresses(): Promise<void> {
const ok = await submitAddresses(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
if (ok) toast.success({ title: t('transport.carriers.toast.addressSaved') })
}
async function onSubmitContacts(): Promise<void> {
const ok = await submitContacts(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
if (ok) toast.success({ title: t('transport.carriers.toast.contactSaved') })
}
async function onSubmitPrices(): Promise<void> {
const ok = await submitPrices(err => toast.error({ title: t('transport.carriers.toast.error'), message: apiErrorMessage(err) }))
if (ok) toast.success({ title: t('transport.carriers.toast.priceSaved') })
}
// ── Suppression de bloc (modal de confirmation générique) ────────────────────
const deleteConfirm = reactive({ open: false, action: null as null | (() => void) })
function askRemoveAddress(index: number): void {
deleteConfirm.action = () => { void removeAddress(index) }
deleteConfirm.open = true
}
function askRemoveContact(index: number): void {
deleteConfirm.action = () => { void removeContact(index) }
deleteConfirm.open = true
}
function askRemovePrice(index: number): void {
deleteConfirm.action = () => { void removePrice(index) }
deleteConfirm.open = true
}
function runDeleteConfirm(): void {
deleteConfirm.action?.()
deleteConfirm.action = null
deleteConfirm.open = false
}
function onAddressDegraded(): void {
toast.warning({ title: t('transport.carriers.toast.error'), message: t('transport.carriers.form.address.degraded') })
}
</script>
@@ -0,0 +1,497 @@
<template>
<div>
<!-- En-tête : retour répertoire + nom + actions. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
v-bind="{ ariaLabel: t('transport.carriers.consultation.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
<div class="ml-auto flex items-center gap-12">
<MalioButton
v-if="canEdit"
variant="secondary"
icon-name="mdi:pencil-outline"
icon-position="left"
:label="t('transport.carriers.action.edit')"
@click="goEdit"
/>
<MalioButton
v-if="showArchive"
variant="secondary"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('transport.carriers.action.archive')"
@click="askToggleArchive"
/>
<MalioButton
v-if="showRestore"
variant="secondary"
icon-name="mdi:archive-arrow-up-outline"
icon-position="left"
:label="t('transport.carriers.action.restore')"
@click="askToggleArchive"
/>
</div>
</div>
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('transport.carriers.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('transport.carriers.consultation.notFound') }}</p>
<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 />
<!-- Cas LIOT : seul le champ immatriculations. -->
<MalioInputText
v-if="isLiot"
:model-value="main.liotPlates"
:label="t('transport.carriers.form.main.liotPlates')"
readonly
/>
<!-- Cas standard : certification + décharge (col 3 réservée) + affrètement (col 4). -->
<template v-if="!isLiot">
<MalioInputText
:model-value="certificationLabel"
:label="t('transport.carriers.form.main.certificationType')"
readonly
/>
<!-- Colonne 3 réservée à la décharge (si AUTRE), sinon vide (xl). -->
<MalioInputText
v-if="main.certificationType === 'AUTRE'"
:model-value="dischargeLabel"
:label="t('transport.carriers.form.main.discharge')"
readonly
/>
<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">
<MalioCheckbox
id="carrier-view-chartered"
:label="t('transport.carriers.form.main.isChartered')"
:model-value="main.isChartered"
readonly
: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 />
<!-- Contenant : radios désactivés (lecture seule), aligné sur l'ajout / la modif. -->
<div>
<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
group-class="mt-0"
/>
<MalioRadioButton
:model-value="main.containerType"
name="carrier-view-container"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
readonly
group-class="mt-0"
/>
</div>
</div>
<MalioInputText :model-value="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" readonly />
</template>
</template>
</div>
<!-- ── Onglets (Adresses · Contacts · Prix) — ouvre sur Adresses ──── -->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #addresses>
<div class="mt-12 flex flex-col gap-6">
<CarrierAddressBlock
v-for="(address, index) in addresses"
:key="index"
:model-value="address"
:country-options="countryOptionsFor(address.country)"
readonly
/>
</div>
</template>
<template #contacts>
<div class="mt-12 flex flex-col gap-6">
<CarrierContactBlock
v-for="(contact, index) in contacts"
:key="index"
:model-value="contact"
readonly
/>
</div>
</template>
<!-- Prix : tableau présentationnel regroupé par contenant + export. -->
<template #prices>
<div class="mt-12 flex flex-col gap-6">
<!-- Police / bordures / radius alignés sur MalioDataTable (header
16px, corps 14px). 1re colonne « Contenant » : libellé du
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) : « Contenant » étroite ; Transporteurs
et Adresse livraisons larges ; Forfait / Tonne / Indexation / État
réduits. -->
<colgroup>
<col class="w-[110px]" />
<col class="w-[20%]" />
<col class="w-[11%]" />
<col class="w-[24%]" />
<col class="w-[9%]" />
<col class="w-[9%]" />
<col class="w-[9%]" />
<col class="w-[9%]" />
</colgroup>
<thead>
<tr>
<th class="border-b border-r border-black bg-m-surface px-3 py-3 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.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>
<th class="border-b border-black bg-m-surface px-3 py-3 align-middle text-[16px] font-semibold">{{ t('transport.carriers.consultation.price.state') }}</th>
</tr>
</thead>
<tbody>
<template v-for="(group, gi) in priceGroups" :key="group.label">
<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>
</tr>
</template>
<tr v-if="!hasPrices">
<td colspan="8" class="px-3 py-4 text-center text-[14px] text-m-muted">
{{ t('transport.carriers.consultation.price.empty') }}
</td>
</tr>
</tbody>
</table>
<div v-if="hasPrices" class="flex justify-center">
<MalioButton
variant="primary"
:label="t('transport.carriers.consultation.price.export')"
:disabled="exporting"
@click="exportPrices"
/>
</div>
</div>
</template>
</MalioTabList>
</template>
<!-- Modal de confirmation archivage / restauration. -->
<MalioModal v-model="confirmArchive.open" modal-class="max-w-md">
<template #header>
<h2 class="text-[24px] font-bold">{{ confirmArchive.title }}</h2>
</template>
<p>{{ confirmArchive.message }}</p>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="t('transport.carriers.form.confirmDelete.cancel')"
@click="confirmArchive.open = false"
/>
<MalioButton
variant="danger"
button-class="flex-1"
:label="confirmArchive.confirmLabel"
@click="runToggleArchive"
/>
</template>
</MalioModal>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } 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,
labelOfRelation,
mapAddressToDraft,
mapContactToDraft,
mapMainToDraft,
showArchiveAction,
showRestoreAction,
type CarrierPriceRead,
} from '~/modules/transport/utils/forms/carrierMappers'
import { extractApiErrorMessage } from '~/shared/utils/api'
interface SelectOption {
value: string
label: string
}
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const api = useApi()
const { can } = usePermissions()
const carrierId = route.params.id as string
const { carrier, loading, error, load, archive, restore } = useCarrier(carrierId)
const isArchived = computed(() => carrier.value?.isArchived ?? false)
const canEdit = computed(() => canEditCarrier(can))
const showArchive = computed(() => showArchiveAction(can, isArchived.value))
const showRestore = computed(() => showRestoreAction(can, isArchived.value))
const headerTitle = computed(() => carrier.value?.name || t('transport.carriers.consultation.title'))
useHead({ title: t('transport.carriers.consultation.title') })
// ── Bloc principal mappé (lecture seule) ─────────────────────────────────────
const main = computed(() => mapMainToDraft(carrier.value ?? { id: 0, '@id': '' }))
const isLiot = computed(() => main.value.name.trim().toUpperCase() === 'LIOT')
const certificationLabel = computed(() => main.value.certificationType
? t(`transport.carriers.certification.${main.value.certificationType}`)
: '')
// Indexation affichée avec le « % » (comme l'icône du champ amount de l'ajout).
const indexationDisplay = computed(() => main.value.indexationRate ? `${main.value.indexationRate} %` : '')
// Décharge : nom du fichier embarqué si présent (sinon vide ; la colonne reste réservée).
const dischargeLabel = computed(() => {
const doc = carrier.value?.dischargeDocument
if (doc && typeof doc !== 'string') {
const meta = doc as Record<string, unknown>
return String(meta.originalFilename ?? meta.name ?? '')
}
return ''
})
// ── Onglets : Adresses · Contacts · Prix (ouvre sur Adresses, pas de Qualimat) ──
const activeTab = ref('addresses')
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 => ({
key,
label: t(`transport.carriers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Au moins un bloc affiché même sans donnée (bloc vide en lecture seule).
const addresses = computed(() => {
const list = (carrier.value?.addresses ?? []).map(mapAddressToDraft)
return list.length > 0 ? list : [mapAddressToDraft({ id: 0, '@id': '' })]
})
const contacts = computed(() => {
const list = (carrier.value?.contacts ?? []).map(mapContactToDraft)
return list.length > 0 ? list : [mapContactToDraft({ id: 0, '@id': '' })]
})
/** Pays : une seule option (valeur courante), suffisant pour l'affichage readonly. */
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
interface PriceRowView {
apro: string
delivery: string
forfait: string
tonne: string
indexation: string
state: string
}
/** Groupe de prix d'un même contenant (Fond Mouvant / Benne) — cellule fusionnée. */
interface PriceGroupView {
label: string
rows: PriceRowView[]
}
/** Formate un montant décimal en « 1 000,00 € » (chaîne vide si absent). */
function formatAmount(value: string | null | undefined): string {
if (!value) {
return ''
}
const n = Number(value)
if (Number.isNaN(n)) {
return ''
}
return `${n.toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`
}
/**
* Construit une ligne d'affichage depuis un prix embarqué (maquette Prix) :
* - « Adresse sites » = le site (Châtellerault / Saint-Jean / Pommevic…) ;
* - « 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'
return {
apro: isClient ? labelOfRelation(price.departureSite) : labelOfRelation(price.deliverySite),
delivery: isClient ? labelOfRelation(price.clientDeliveryAddress) : labelOfRelation(price.supplierSupplyAddress),
forfait: price.pricingUnit === 'FORFAIT' ? formatAmount(price.price) : '',
tonne: price.pricingUnit === 'TONNE' ? formatAmount(price.price) : '',
// CarrierPrice n'a pas de taux d'indexation propre → on affiche celui du
// transporteur (formulaire principal). À faire évoluer si un taux par prix
// est requis (gap back).
indexation: main.value.indexationRate ? `${main.value.indexationRate} %` : '',
state: price.priceState ? t(`transport.carriers.form.price.state${stateSuffix(price.priceState)}`) : '',
}
}
/** EN_COURS → EnCours, VALIDE → Valide, NON_VALIDE → NonValide (clés i18n existantes). */
function stateSuffix(state: string): string {
const map: Record<string, string> = { EN_COURS: 'EnCours', VALIDE: 'Valide', NON_VALIDE: 'NonValide' }
return map[state] ?? ''
}
// Prix regroupés par contenant (Fond Mouvant puis Benne) — une cellule fusionnée
// par groupe (rowspan) à gauche, conformément à la maquette.
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),
}))
.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) ;
* - 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 {
const isLastRow = i === group.rows.length - 1
const isLastGroup = gi === priceGroups.value.length - 1
if (!isLastRow) {
return 'border-b border-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'
}
// ── Export XLSX des prix ─────────────────────────────────────────────────────
const exporting = ref(false)
async function exportPrices(): Promise<void> {
if (exporting.value) return
exporting.value = true
try {
const blob = await api.get<Blob>(`/carriers/${carrierId}/prices/export.xlsx`, {}, {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, `transporteur-${carrierId}-prix.xlsx`)
}
catch {
toast.error({ title: t('transport.carriers.toast.error'), message: t('transport.carriers.toast.exportError') })
}
finally {
exporting.value = false
}
}
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)
}
// ── Navigation / archivage ───────────────────────────────────────────────────
function goBack(): void {
router.push('/carriers')
}
function goEdit(): void {
router.push(`/carriers/${carrierId}/edit`)
}
const confirmArchive = reactive({ open: false, title: '', message: '', confirmLabel: '' })
function askToggleArchive(): void {
const archiving = !isArchived.value
confirmArchive.title = archiving ? t('transport.carriers.action.archive') : t('transport.carriers.action.restore')
confirmArchive.message = archiving
? t('transport.carriers.consultation.confirmArchive.message')
: t('transport.carriers.consultation.confirmRestore.message')
confirmArchive.confirmLabel = archiving ? t('transport.carriers.action.archive') : t('transport.carriers.action.restore')
confirmArchive.open = true
}
async function runToggleArchive(): Promise<void> {
const archiving = !isArchived.value
confirmArchive.open = false
try {
await (archiving ? archive() : restore())
toast.success({
title: archiving
? t('transport.carriers.toast.archiveSuccess')
: t('transport.carriers.toast.restoreSuccess'),
})
}
catch (err) {
// Surface le message back (ex. 409 « homonyme actif » à la restauration),
// propagé exprès par useCarrier ; fallback générique sinon.
const data = (err as { response?: { _data?: unknown } })?.response?._data
toast.error({
title: t('transport.carriers.toast.error'),
message: extractApiErrorMessage(data) || undefined,
})
}
}
onMounted(load)
</script>
@@ -86,32 +86,55 @@
« Affreter ». La ligne 1 étant pleine (4 colonnes), ils démarrent
naturellement en colonne 1 de la ligne 2. -->
<template v-if="showCharteredFields">
<MalioInputNumber
v-model="main.indexationRate"
<!-- Indexation : montant en % (icône à droite), plafonné à 100. La
:key force le ré-affichage du champ contrôlé quand on plafonne
(sinon le modelValue inchangé n'est pas re-synchronisé par Vue). -->
<MalioInputAmount
:key="indexationKey"
:model-value="main.indexationRate"
:label="t('transport.carriers.form.main.indexationRate')"
icon-name="mdi:percent"
icon-position="right"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.indexationRate"
@update:model-value="onIndexationInput"
/>
<!-- Contenant : Benne / Fond mouvant (RG-4.03). -->
<MalioSelect
:model-value="main.containerType"
:options="containerOptions"
:label="t('transport.carriers.form.main.containerType')"
empty-option-label=""
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.containerType"
@update:model-value="(v: string | number | null) => main.containerType = v === null ? null : String(v)"
/>
<!-- Contenant : Benne / Fond mouvant en radios, centrés (h-12) comme
à l'onglet Prix (Benne par défaut). -->
<div>
<div class="flex h-12 items-center gap-4">
<MalioRadioButton
:model-value="main.containerType"
name="carrier-main-container"
value="BENNE"
:label="t('transport.carriers.containerType.BENNE')"
:disabled="mainLocked"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
<MalioRadioButton
:model-value="main.containerType"
name="carrier-main-container"
value="FOND_MOUVANT"
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
:disabled="mainLocked"
group-class="mt-0"
@update:model-value="(v: string | number | boolean | null) => main.containerType = v === null ? null : String(v)"
/>
</div>
<p v-if="mainErrors.errors.containerType" class="ml-[2px] text-xs text-m-danger">{{ mainErrors.errors.containerType }}</p>
</div>
<MalioInputNumber
v-model="main.volumeM3"
<!-- Volume m³ : champ texte restreint aux nombres à décimales (point). -->
<MalioInputText
:model-value="main.volumeM3"
:label="t('transport.carriers.form.main.volumeM3')"
:required="true"
:readonly="mainLocked"
:error="mainErrors.errors.volumeM3"
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
/>
</template>
</template>
@@ -135,7 +158,10 @@
(pas de champ de recherche dédié — RG-4.01 / 4.04). -->
<template #qualimat>
<div class="mt-12 flex flex-col gap-6">
<!-- table-fixed : 1re colonne (radio) étroite, les 3 autres à parts égales. -->
<MalioDataTable
class="qualimat-table"
table-class="table-fixed"
:columns="qualimatColumns"
:items="qualimatRows"
:total-items="qualimatTotalDisplay"
@@ -344,6 +370,7 @@ import CarrierContactBlock from '~/modules/transport/components/CarrierContactBl
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
interface SelectOption {
value: string
@@ -464,22 +491,13 @@ const qualimatEmptyMessage = computed(() => 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<SelectOption[]>(() =>
CONTAINER_TYPES.map(code => ({
value: code,
label: t(`transport.carriers.containerType.${code}`),
})),
)
// Icone (Iconify) affichee dans chaque onglet, par cle.
const TAB_ICONS: Record<string, string> = {
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<void> {
}
}
// 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<void> {
}
}
</script>
<style scoped>
/* Datatable QUALIMAT en table-fixed : la colonne radio (1re) reste étroite,
les 3 autres (nom / adresse / validité) se partagent l'espace à parts égales. */
.qualimat-table :deep(th:first-child),
.qualimat-table :deep(td:first-child) {
width: 56px;
}
</style>
@@ -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,
@@ -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)
})
})
@@ -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('')
})
})
@@ -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
}
@@ -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
}