Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e0e81130b |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.137'
|
app.version: '0.1.136'
|
||||||
|
|||||||
@@ -530,48 +530,7 @@
|
|||||||
"integrateSuccess": "Transporteur QUALIMAT intégré",
|
"integrateSuccess": "Transporteur QUALIMAT intégré",
|
||||||
"addressSaved": "Adresse enregistrée",
|
"addressSaved": "Adresse enregistrée",
|
||||||
"contactSaved": "Contact enregistré",
|
"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": {
|
"containerType": {
|
||||||
"BENNE": "Benne",
|
"BENNE": "Benne",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class="flex h-12 items-center gap-6">
|
<div class="flex h-12 items-center gap-6">
|
||||||
<MalioRadioButton
|
<MalioRadioButton
|
||||||
:model-value="model.direction"
|
:model-value="model.direction"
|
||||||
:name="`price-direction-${uid}`"
|
name="price-direction"
|
||||||
value="CLIENT"
|
value="CLIENT"
|
||||||
:label="t('transport.carriers.form.price.directionClient')"
|
:label="t('transport.carriers.form.price.directionClient')"
|
||||||
:disabled="readonly"
|
:disabled="readonly"
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
/>
|
/>
|
||||||
<MalioRadioButton
|
<MalioRadioButton
|
||||||
:model-value="model.direction"
|
:model-value="model.direction"
|
||||||
:name="`price-direction-${uid}`"
|
name="price-direction"
|
||||||
value="FOURNISSEUR"
|
value="FOURNISSEUR"
|
||||||
:label="t('transport.carriers.form.price.directionSupplier')"
|
:label="t('transport.carriers.form.price.directionSupplier')"
|
||||||
:disabled="readonly"
|
:disabled="readonly"
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
<div class="flex h-12 items-center gap-4">
|
<div class="flex h-12 items-center gap-4">
|
||||||
<MalioRadioButton
|
<MalioRadioButton
|
||||||
:model-value="model.containerType"
|
:model-value="model.containerType"
|
||||||
:name="`price-container-${uid}`"
|
name="price-container"
|
||||||
value="BENNE"
|
value="BENNE"
|
||||||
:label="t('transport.carriers.containerType.BENNE')"
|
:label="t('transport.carriers.containerType.BENNE')"
|
||||||
:disabled="readonly"
|
:disabled="readonly"
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
/>
|
/>
|
||||||
<MalioRadioButton
|
<MalioRadioButton
|
||||||
:model-value="model.containerType"
|
:model-value="model.containerType"
|
||||||
:name="`price-container-${uid}`"
|
name="price-container"
|
||||||
value="FOND_MOUVANT"
|
value="FOND_MOUVANT"
|
||||||
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
:label="t('transport.carriers.containerType.FOND_MOUVANT')"
|
||||||
:disabled="readonly"
|
:disabled="readonly"
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
<div class="flex h-12 items-center gap-4">
|
<div class="flex h-12 items-center gap-4">
|
||||||
<MalioRadioButton
|
<MalioRadioButton
|
||||||
:model-value="model.pricingUnit"
|
:model-value="model.pricingUnit"
|
||||||
:name="`price-unit-${uid}`"
|
name="price-unit"
|
||||||
value="FORFAIT"
|
value="FORFAIT"
|
||||||
:label="t('transport.carriers.form.price.pricingForfait')"
|
:label="t('transport.carriers.form.price.pricingForfait')"
|
||||||
:disabled="readonly"
|
:disabled="readonly"
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
/>
|
/>
|
||||||
<MalioRadioButton
|
<MalioRadioButton
|
||||||
:model-value="model.pricingUnit"
|
:model-value="model.pricingUnit"
|
||||||
:name="`price-unit-${uid}`"
|
name="price-unit"
|
||||||
value="TONNE"
|
value="TONNE"
|
||||||
:label="t('transport.carriers.form.price.pricingTonne')"
|
:label="t('transport.carriers.form.price.pricingTonne')"
|
||||||
:disabled="readonly"
|
:disabled="readonly"
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, useId, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import type { CarrierPriceFormDraft } from '~/modules/transport/types/carrierForm'
|
import type { CarrierPriceFormDraft } from '~/modules/transport/types/carrierForm'
|
||||||
|
|
||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
@@ -212,11 +212,6 @@ const emit = defineEmits<{
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const api = useApi()
|
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 model = computed(() => props.modelValue)
|
||||||
|
|
||||||
const priceStateOptions = computed<SelectOption[]>(() => [
|
const priceStateOptions = computed<SelectOption[]>(() => [
|
||||||
|
|||||||
@@ -111,8 +111,6 @@ describe('useCarrierForm', () => {
|
|||||||
form.main.name = 'Acme'
|
form.main.name = 'Acme'
|
||||||
form.main.certificationType = 'GMP_PLUS'
|
form.main.certificationType = 'GMP_PLUS'
|
||||||
form.main.isChartered = true
|
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()
|
const created = await form.submitMain()
|
||||||
|
|
||||||
@@ -325,18 +323,16 @@ describe('useCarrierForm — champs conditionnels (ERP-166)', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('RG-4.03 affrété, indexation/volume vides : omis du payload (containerType garde son défaut BENNE)', () => {
|
it('RG-4.03 affrété mais champs vides : omis du payload (422 NotBlank back)', () => {
|
||||||
const form = useCarrierForm()
|
const form = useCarrierForm()
|
||||||
form.main.name = 'Acme'
|
form.main.name = 'Acme'
|
||||||
form.main.certificationType = 'GMP_PLUS'
|
form.main.certificationType = 'GMP_PLUS'
|
||||||
form.main.isChartered = true
|
form.main.isChartered = true
|
||||||
|
|
||||||
// indexation / volume vides → omis (422 NotBlank back) ; containerType défaut « BENNE » envoyé.
|
|
||||||
expect(form.buildMainPayload()).toEqual({
|
expect(form.buildMainPayload()).toEqual({
|
||||||
name: 'Acme',
|
name: 'Acme',
|
||||||
certificationType: 'GMP_PLUS',
|
certificationType: 'GMP_PLUS',
|
||||||
isChartered: true,
|
isChartered: true,
|
||||||
containerType: 'BENNE',
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -916,49 +912,3 @@ describe('useCarrierForm — onglet Prix (ERP-169)', () => {
|
|||||||
expect(form.prices.value).toHaveLength(1)
|
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
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,13 +18,6 @@ import {
|
|||||||
import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress'
|
import { buildCarrierAddressPayload, isCarrierAddressValid } from '~/modules/transport/utils/forms/carrierAddress'
|
||||||
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
|
import { buildCarrierContactPayload, isCarrierContactBlank, isCarrierContactNamed } from '~/modules/transport/utils/forms/carrierContact'
|
||||||
import { buildCarrierPricePayload, isCarrierPriceValid } from '~/modules/transport/utils/forms/carrierPrice'
|
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'
|
import type { QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||||
|
|
||||||
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
|
/** Nom du cas spécial « compte-propre » LIOT (comparaison insensible à la casse, RG-4.01). */
|
||||||
@@ -264,68 +257,6 @@ 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
|
* PATCH partiel du transporteur (mode strict : un seul groupe de sérialisation
|
||||||
* par appel — spec-back § 2.9). Servira les onglets à champs scalaires des
|
* par appel — spec-back § 2.9). Servira les onglets à champs scalaires des
|
||||||
@@ -771,8 +702,6 @@ export function useCarrierForm() {
|
|||||||
validateMainFront,
|
validateMainFront,
|
||||||
buildMainPayload,
|
buildMainPayload,
|
||||||
submitMain,
|
submitMain,
|
||||||
updateMain,
|
|
||||||
prefillFrom,
|
|
||||||
patchCarrier,
|
patchCarrier,
|
||||||
applyQualimatSelection,
|
applyQualimatSelection,
|
||||||
completeTab,
|
completeTab,
|
||||||
|
|||||||
@@ -1,384 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,497 +0,0 @@
|
|||||||
<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,55 +86,32 @@
|
|||||||
« Affreter ». La ligne 1 étant pleine (4 colonnes), ils démarrent
|
« Affreter ». La ligne 1 étant pleine (4 colonnes), ils démarrent
|
||||||
naturellement en colonne 1 de la ligne 2. -->
|
naturellement en colonne 1 de la ligne 2. -->
|
||||||
<template v-if="showCharteredFields">
|
<template v-if="showCharteredFields">
|
||||||
<!-- Indexation : montant en % (icône à droite), plafonné à 100. La
|
<MalioInputNumber
|
||||||
:key force le ré-affichage du champ contrôlé quand on plafonne
|
v-model="main.indexationRate"
|
||||||
(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')"
|
:label="t('transport.carriers.form.main.indexationRate')"
|
||||||
icon-name="mdi:percent"
|
|
||||||
icon-position="right"
|
|
||||||
:required="true"
|
:required="true"
|
||||||
:readonly="mainLocked"
|
:readonly="mainLocked"
|
||||||
:error="mainErrors.errors.indexationRate"
|
:error="mainErrors.errors.indexationRate"
|
||||||
@update:model-value="onIndexationInput"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Contenant : Benne / Fond mouvant en radios, centrés (h-12) comme
|
<!-- Contenant : Benne / Fond mouvant (RG-4.03). -->
|
||||||
à l'onglet Prix (Benne par défaut). -->
|
<MalioSelect
|
||||||
<div>
|
:model-value="main.containerType"
|
||||||
<div class="flex h-12 items-center gap-4">
|
:options="containerOptions"
|
||||||
<MalioRadioButton
|
:label="t('transport.carriers.form.main.containerType')"
|
||||||
:model-value="main.containerType"
|
empty-option-label=""
|
||||||
name="carrier-main-container"
|
:required="true"
|
||||||
value="BENNE"
|
:readonly="mainLocked"
|
||||||
:label="t('transport.carriers.containerType.BENNE')"
|
:error="mainErrors.errors.containerType"
|
||||||
:disabled="mainLocked"
|
@update:model-value="(v: string | number | null) => main.containerType = v === null ? null : String(v)"
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Volume m³ : champ texte restreint aux nombres à décimales (point). -->
|
<MalioInputNumber
|
||||||
<MalioInputText
|
v-model="main.volumeM3"
|
||||||
:model-value="main.volumeM3"
|
|
||||||
:label="t('transport.carriers.form.main.volumeM3')"
|
:label="t('transport.carriers.form.main.volumeM3')"
|
||||||
:required="true"
|
:required="true"
|
||||||
:readonly="mainLocked"
|
:readonly="mainLocked"
|
||||||
:error="mainErrors.errors.volumeM3"
|
:error="mainErrors.errors.volumeM3"
|
||||||
@update:model-value="(v: string) => main.volumeM3 = sanitizeDecimal(v)"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -158,10 +135,7 @@
|
|||||||
(pas de champ de recherche dédié — RG-4.01 / 4.04). -->
|
(pas de champ de recherche dédié — RG-4.01 / 4.04). -->
|
||||||
<template #qualimat>
|
<template #qualimat>
|
||||||
<div class="mt-12 flex flex-col gap-6">
|
<div class="mt-12 flex flex-col gap-6">
|
||||||
<!-- table-fixed : 1re colonne (radio) étroite, les 3 autres à parts égales. -->
|
|
||||||
<MalioDataTable
|
<MalioDataTable
|
||||||
class="qualimat-table"
|
|
||||||
table-class="table-fixed"
|
|
||||||
:columns="qualimatColumns"
|
:columns="qualimatColumns"
|
||||||
:items="qualimatRows"
|
:items="qualimatRows"
|
||||||
:total-items="qualimatTotalDisplay"
|
:total-items="qualimatTotalDisplay"
|
||||||
@@ -370,7 +344,6 @@ import CarrierContactBlock from '~/modules/transport/components/CarrierContactBl
|
|||||||
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
import CarrierPriceBlock from '~/modules/transport/components/CarrierPriceBlock.vue'
|
||||||
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
import { useCarrierForm } from '~/modules/transport/composables/useCarrierForm'
|
||||||
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
import { useQualimatSearch, type QualimatCarrierRow } from '~/modules/transport/composables/useQualimatSearch'
|
||||||
import { clampPercent, sanitizeDecimal } from '~/modules/transport/utils/forms/numberInput'
|
|
||||||
|
|
||||||
interface SelectOption {
|
interface SelectOption {
|
||||||
value: string
|
value: string
|
||||||
@@ -491,13 +464,22 @@ const qualimatEmptyMessage = computed(() => hasQualimatSearch.value
|
|||||||
? t('transport.carriers.form.qualimat.empty')
|
? t('transport.carriers.form.qualimat.empty')
|
||||||
: t('transport.carriers.form.qualimat.searchHint'))
|
: 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.
|
// Icone (Iconify) affichee dans chaque onglet, par cle.
|
||||||
const TAB_ICONS: Record<string, string> = {
|
const TAB_ICONS: Record<string, string> = {
|
||||||
qualimat: 'mdi:truck-fast-outline',
|
qualimat: 'mdi:truck-check-outline',
|
||||||
addresses: 'mdi:map-marker-outline',
|
addresses: 'mdi:map-marker-outline',
|
||||||
contacts: 'mdi:account-box-plus-outline',
|
contacts: 'mdi:account-box-plus-outline',
|
||||||
prices: 'mdi:payment',
|
prices: 'mdi:currency-eur',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Onglets desactives tant que le formulaire principal n'est pas valide
|
// Onglets desactives tant que le formulaire principal n'est pas valide
|
||||||
@@ -724,19 +706,6 @@ 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). */
|
/** Retour vers le repertoire transporteurs (fleche d'en-tete). */
|
||||||
function goBack(): void {
|
function goBack(): void {
|
||||||
router.push('/carriers')
|
router.push('/carriers')
|
||||||
@@ -757,12 +726,3 @@ async function onSubmitMain(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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,8 +40,7 @@ export function emptyCarrierMain(): CarrierMainDraft {
|
|||||||
certificationType: null,
|
certificationType: null,
|
||||||
isChartered: false,
|
isChartered: false,
|
||||||
indexationRate: '',
|
indexationRate: '',
|
||||||
// Défaut métier : Benne pré-sélectionné (radio du formulaire principal).
|
containerType: null,
|
||||||
containerType: 'BENNE',
|
|
||||||
volumeM3: '',
|
volumeM3: '',
|
||||||
liotPlates: '',
|
liotPlates: '',
|
||||||
dischargeDocumentIri: null,
|
dischargeDocumentIri: null,
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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('')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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