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
Auto Tag Develop / tag (push) Successful in 7s
This commit was merged in pull request #129.
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user