346 lines
16 KiB
Vue
346 lines
16 KiB
Vue
<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">
|
|
<MalioInputNumber v-model="main.indexationRate" :label="t('transport.carriers.form.main.indexationRate')" :required="true" :error="mainErrors.errors.indexationRate" />
|
|
<MalioSelect
|
|
:model-value="main.containerType"
|
|
:options="containerOptions"
|
|
:label="t('transport.carriers.form.main.containerType')"
|
|
empty-option-label=""
|
|
:required="true"
|
|
:error="mainErrors.errors.containerType"
|
|
@update:model-value="(v: string | number | null) => main.containerType = v === null ? null : String(v)"
|
|
/>
|
|
<MalioInputNumber v-model="main.volumeM3" :label="t('transport.carriers.form.main.volumeM3')" :required="true" :error="mainErrors.errors.volumeM3" />
|
|
</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'
|
|
|
|
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 CONTAINER_TYPES = ['BENNE', 'FOND_MOUVANT'] as const
|
|
const containerOptions = computed<SelectOption[]>(() =>
|
|
CONTAINER_TYPES.map(code => ({ value: code, label: t(`transport.carriers.containerType.${code}`) })),
|
|
)
|
|
|
|
const TAB_ICONS: Record<string, string> = {
|
|
addresses: 'mdi:map-marker-outline',
|
|
contacts: 'mdi:account-box-plus-outline',
|
|
prices: 'mdi:currency-eur',
|
|
}
|
|
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')
|
|
}
|
|
|
|
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>
|