Files
admin-web/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue
T

287 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import { useDispatchCandidates } from '../useDispatchCandidates'
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, DraftSelection, VersionCommand } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups } from '../dispatchContracts'
import { dispatchActionPermissions } from '../dispatchPresentation'
import { useDispatchCommandRecovery, validateCurrentDispatch } from '../dispatchCommandRecovery'
import type { InboundOrderListItem } from '../types'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
const router = useRouter(), auth = useAuthStore(), access = useAccessPreviewStore()
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const owner = auth.user?.id || '', storageKey = `dispatch-draft-command:${owner}`
type DraftCommand = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'UNLOCK' | 'DELETE' | 'RELEASE' | 'CONVERT'; body: Record<string, unknown>; containerId?: string; priorContainerIds?: string[] }
const pending = ref<DraftCommand | null>(null)
const settingsExpanded = ref(false)
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
const draft = ref<DispatchDraft | null>(null), drafts = ref<DispatchDraft[]>([]), activeContainerId = ref(''), capacity = ref(30)
const working = ref(false), saving = ref(false), preparing = ref(false), error = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error, api.listDispatchCandidates, true)
const { candidates, page, size, total } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
const publishLooseboardDemand = ref(false)
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || recovery.busy.value)
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
const planIds = computed(() => [...new Set(props.orders.map(row => row.planId).filter(Boolean))])
const currentContainer = computed(() => draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value))
const populatedGroupCount = computed(() => draft.value?.containers.filter(row => row.sourceKeys.length).length || 0)
const confirmDisabled = computed(() => preparing.value || loading.value || !canCreate.value || (!pending.value && !recovery.pending.value && (mode.value === 'empty' ? !warehouseId.value : !populatedGroupCount.value)))
function assignedGroup(row: Candidate) {
return draft.value?.containers.find(group => group.sourceKeys.some(key => row.selectionGroup.sources.some(source => source.sourceKey === key)))
}
async function ensureFirstVehicle(sequence: number): Promise<string | null> {
if (!draft.value && !await draftAction('CREATE')) return null
if (sequence !== generation || !props.modelValue || !draft.value) return null
if (!draft.value.containers.length && !await draftAction('CONTAINER')) return null
if (sequence !== generation || !props.modelValue) return null
return currentContainer.value?.draftContainerId || null
}
async function addCargo(source: Candidate, targetContainerId = activeContainerId.value) {
if (frozen.value || loading.value || assignedGroup(source)) return
const sequence = generation, target = targetContainerId
preparing.value = true
try {
requireSelectionGroup(source); candidatePage.assertCurrent([source])
if (draft.value && source.warehouseId !== draft.value.warehouseId) throw new DispatchInputError('同一次排车安排只能选择同一起运仓的货物')
if (!draft.value) warehouseId.value = source.warehouseId
const containerId = target || await ensureFirstVehicle(sequence)
if (!containerId || sequence !== generation || !props.modelValue) return
if (!draft.value?.containers.some(row => row.draftContainerId === containerId)) throw new DispatchInputError('所选车辆已变化,请重新选择')
activeContainerId.value = containerId
await draftAction('LOCK', source, containerId)
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '加入货物失败,请核对后重试') }
finally { preparing.value = false }
}
async function addVehicle() {
if (frozen.value || loading.value || !vehicleWarehouse.value) return
if (!draft.value) warehouseId.value = vehicleWarehouse.value
const sequence = generation
preparing.value = true
try {
if (!await ensureFirstVehicle(sequence)) return
if (sequence === generation && props.modelValue) await draftAction('CONTAINER')
} finally { preparing.value = false }
}
function changeWarehouse() { candidatePage.reset(); warehouseTab.value = 'ALL'; void search() }
function changeTab(tab: SmartDispatchWarehouseTab) { if (tabDisabled(tab)) return; warehouseTab.value = tab; void search() }
function tabDisabled(tab: SmartDispatchWarehouseTab) {
if (!draft.value || tab === 'ALL') return false
const warehouse = warehouses.value.find(row => String(row.id) === draft.value!.warehouseId)
return !warehouse || !matchesSmartDispatchWarehouseTab(warehouse, tab)
}
const vehicleWarehouse = computed(() => {
if (draft.value) return draft.value.warehouseId
const matches = warehouses.value.filter(row => matchesSmartDispatchWarehouseTab(row, warehouseTab.value))
return matches.length === 1 ? String(matches[0]!.id) : ''
})
function changePage(value: number) { page.value = value; void loadCandidates() }
function changeSize(value: number) { size.value = value; void search() }
let generation = 0
let candidateFilterKey = ''
async function init() {
const sequence = ++generation
working.value = true; error.value = null
try {
if (!canCreate.value) return
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
if (sequence !== generation) return
warehouses.value = directory; drafts.value = saved
const resumeToken = draft.value?.draftToken
if (resumeToken) draft.value = saved.find(row => row.draftToken === resumeToken) || null
if (draft.value) warehouseId.value = draft.value.warehouseId
if (!warehouseId.value) {
const selectedWarehouses = new Set(props.orders.map(row => row.originWarehouseId).filter(Boolean))
if (selectedWarehouses.size === 1 && directory.some(row => String(row.id) === [...selectedWarehouses][0])) warehouseId.value = [...selectedWarehouses][0]!
else if (directory[0]) warehouseId.value = String(directory[0].id)
}
await loadCandidates(sequence)
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
finally { if (sequence === generation) working.value = false }
}
async function loadCandidates(sequence = generation) {
if (sequence !== generation) return
const candidateWarehouse = draft.value?.warehouseId
const filterKey = JSON.stringify([candidateWarehouse, query, warehouseTab.value, planIds.value, draft.value?.draftToken])
if (filterKey !== candidateFilterKey) { page.value = 1; candidateFilterKey = filterKey }
await candidatePage.load({ warehouseId: candidateWarehouse, page: page.value, size: size.value, warehouseTab: warehouseTab.value, orderNo: query.orderNo || undefined, destinationWarehouseCode: query.destinationWarehouseCode || undefined, sourceStage: query.sourceStage || undefined, ...(query.sourceStage !== 'RELABEL' && planIds.value.length ? { planIds: planIds.value } : {}), draftToken: draft.value?.draftToken })
}
function resetSearch() { if (frozen.value) return; query.orderNo = ''; query.destinationWarehouseCode = ''; query.sourceStage = ''; warehouseTab.value = 'ALL'; void search() }
async function search() { if (frozen.value) return; page.value = 1; await loadCandidates() }
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; warehouseTab.value = 'ALL'; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
function creation(cargo: Candidate[]): CreateDispatch {
candidatePage.assertCurrent(cargo)
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
return { publishLooseboardDemand: publishLooseboardDemand.value, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
}
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise<boolean> {
if (!canCreate.value || loading.value || saving.value || pending.value) return false
error.value = null
try {
if (!pending.value) {
if (!warehouseId.value) throw new DispatchInputError('请选择真实起运仓')
const key = crypto.randomUUID(), current = draft.value
let body: object
if (action === 'CREATE') body = { warehouseId: warehouseId.value, idempotencyKey: key }
else {
if (!current) throw new DispatchInputError('请先新建或恢复草稿')
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') { requireSelectionGroup(source); candidatePage.assertCurrent([source]) } body = { ...common, draftContainerId: containerId, source: source.selection } }
else if (action === 'DELETE') {
const container = current.containers.find(row => row.draftContainerId === containerId)
if (!container || container.sourceKeys.length) throw new DispatchInputError('请先移回车内托盘,再删除空车辆')
body = { ...common, reason: '移除本草稿装车容器' }
}
else if (action === 'RELEASE') body = common
else {
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
const cargo = row.sourceKeys.map(keyValue => current.sources.find(value => value.sourceKey === keyValue))
if (cargo.some(rowValue => !rowValue)) throw new DispatchInputError('草稿来源尚未恢复完整,请重新读取')
return { draftContainerId: row.draftContainerId, dispatch: creation(cargo as Candidate[]) }
})
if (!groups.length) throw new DispatchInputError('请至少选择一组真实货物;空单请使用先建空单')
body = { ...common, groups } satisfies ConvertDraft
}
}
if (!owner || owner !== auth.user?.id) throw new DispatchInputError('登录身份已变化,请重新进入排车列表')
pending.value = { action, body: JSON.parse(JSON.stringify(body)) as Record<string, unknown>, containerId, ...(action === 'CONTAINER' ? { priorContainerIds: current!.containers.map(row => row.draftContainerId) } : {}) }
}
await retryDraft()
return true
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次排车操作失败,请重试'); return false }
}
async function retryDraft() {
if (!pending.value || saving.value) return
const command = pending.value
saving.value = true
try {
const body = command.body
let result: DispatchDraft | ConvertedDraft
switch (command.action) {
case 'CREATE': result = await api.createDispatchDraft(body as unknown as { warehouseId: string; idempotencyKey: string }); break
case 'CONTAINER': result = await api.createDispatchDraftContainer(body as unknown as VersionCommand & { draftToken: string; capacity: number }); break
case 'LOCK': result = await api.lockDispatchDraftPallet(body as unknown as DraftSelection); break
case 'UNLOCK': result = await api.unlockDispatchDraftPallet(body as unknown as DraftSelection); break
case 'RELEASE': result = await api.releaseDispatchDraft(body as unknown as VersionCommand & { draftToken: string }); break
case 'DELETE': result = await api.deleteDispatchDraftContainer(String(body.draftToken), command.containerId!, body as unknown as VersionCommand & { reason: string }); break
case 'CONVERT': result = await api.convertDispatchDraft(body as unknown as ConvertDraft); break
}
if (body.draftToken && result.draftToken !== body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致')
if (command.action === 'CONVERT') {
const converted = result as ConvertedDraft, expected = (body as unknown as ConvertDraft).groups
if (converted.vehicles.length !== expected.length || new Set(converted.vehicles.map(row => row.dispatch.dispatchNo)).size !== expected.length || expected.some(group => !converted.vehicles.some(row => row.draftContainerId === group.draftContainerId && row.dispatch.action === 'CREATE' && row.dispatch.idempotencyKey === group.dispatch.idempotencyKey && Boolean(row.dispatch.dispatchNo)))) throw new DispatchInputError('整车转换结果与本次草稿容器不一致,请刷新当前排车')
await Promise.all(converted.vehicles.map(async row => validateCurrentDispatch(await api.getDispatch(row.dispatch.dispatchNo), row.dispatch)))
emit('created'); emit('update:modelValue', false); draft.value = null; ElMessage.success('整车排车单已创建'); await router.push({ name: 'overseas-dispatches' }); return
}
requireVersion((result as DispatchDraft).recordVersion)
let addedContainerId: string | undefined
if (command.action === 'CONTAINER') {
const updated = result as DispatchDraft, previous = command.priorContainerIds
const added = previous && updated.containers.filter(row => !previous.includes(row.draftContainerId))
if (!previous || !added || added.length !== 1 || added[0]!.capacity !== body.capacity || updated.containers.length !== previous.length + 1 || previous.some(id => !updated.containers.some(row => row.draftContainerId === id))) throw new DispatchInputError('新增车辆结果异常,请刷新后重试')
addedContainerId = added[0]!.draftContainerId
}
draft.value = command.action === 'RELEASE' ? null : result as DispatchDraft
if (draft.value) warehouseId.value = draft.value.warehouseId
activeContainerId.value = addedContainerId || draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
} finally { pending.value = null; saving.value = false }
}
async function submit() {
if (!canCreate.value || preparing.value || saving.value || loading.value) return
error.value = null
try {
if (mode.value === 'cargo') { await draftAction('CONVERT'); return }
if (!warehouseId.value) throw new DispatchInputError('请选择真实起运仓')
recovery.begin('', 'CREATE', creation([]))
const result = await recovery.execute(command => api.createDispatch(command.body as unknown as CreateDispatch), api.getDispatch)
if (result) { emit('created'); emit('update:modelValue', false); ElMessage.success(`空排车单 ${result.dispatchNo} 已创建`) }
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单失败,请重试') }
}
watch(() => props.modelValue, value => { if (value) { publishLooseboardDemand.value = false; void init() } else { generation++; candidatePage.reset() } }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="confirmDisabled" :confirm-text="mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-dialog-sections dispatch-workspace">
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query">
<template v-if="mode === 'cargo'"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button type="primary" :disabled="frozen || loading" @click="search">查询</el-button><el-button :disabled="frozen || loading" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button><el-checkbox v-model="publishLooseboardDemand" :disabled="frozen || loading">发布公共需求</el-checkbox></div>
</section>
<section v-show="settingsExpanded" id="smart-dispatch-settings" class="dispatch-context-toolbar" aria-label="排车设置">
<el-form inline label-position="left" label-width="70px" class="density-form dispatch-setup-fields" :disabled="frozen || loading">
<el-form-item v-if="mode === 'empty'" label="起运仓"><el-select class="dispatch-warehouse-picker" aria-label="起运仓" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item>
<el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">先选货再建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item>
</el-form>
<div v-if="mode === 'cargo'" class="dispatch-draft-tools density-actions" aria-label="车辆选货安排">
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续待完成排车" aria-label="继续待完成排车" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '辆车 · ' + saved.sources.length + '条货物'" /></el-select>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">放弃本次安排</el-button>
<InlineHelp title="选货与草稿说明"><p>点击左侧订单查看托盘,将托盘拖入右侧车辆,或选择车辆后点击托盘号。添加车辆会保留已选货物;每辆有货的车生成一张排车单,同仓可混装多个客户和目的地。未生成的安排暂存并占用货物,可恢复或放弃。车辆和司机在建单后维护。</p></InlineHelp>
</div>
</section>
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
<template v-if="mode === 'cargo'">
<SmartDispatchCargoBoard :aria-busy="candidatePage.loading.value || saving || preparing" :candidates="candidates" :draft="draft" :active-container-id="activeContainerId" :capacity="capacity" :disabled="frozen || loading" :warehouse-selected="true" :add-vehicle-disabled="!vehicleWarehouse" :page="page" :size="size" :total="total"
@add="addCargo" @select-container="activeContainerId = $event" @add-vehicle="addVehicle" @remove-cargo="(source, id) => draftAction('UNLOCK', source, id)" @remove-container="id => draftAction('DELETE', undefined, id)" @change-page="changePage" @change-size="changeSize">
<template #order-controls>
<div class="warehouse-tabs density-actions" role="tablist" aria-label="候选仓库展示"><el-button v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :class="{ active: warehouseTab === tab.value }" :type="warehouseTab === tab.value ? 'primary' : undefined" :disabled="frozen || loading || tabDisabled(tab.value)" @click="changeTab(tab.value)">{{ tab.label }}</el-button></div>
</template>
<template #vehicle-controls>
<label class="dispatch-capacity-field"><span>托位</span><el-select aria-label="下一辆车计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
</template>
</SmartDispatchCargoBoard>
</template>
</div>
</AppDialog>
</template>
<style scoped>
/* In fullscreen, allocate the remaining body space to cargo instead of capping it at 390px. */
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace > .el-dialog__body) { display: flex; flex-direction: column; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace > .el-dialog__body > .app-dialog__body) { display: flex; flex-direction: column; flex: 1; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .dispatch-workspace) { display: flex; flex-direction: column; flex: 1; min-height: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .dispatch-workspace > :not(.dispatch-cargo-board)) { flex-shrink: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .dispatch-cargo-board) { flex: 1; min-height: 280px; grid-template-rows: auto minmax(0, 1fr); }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-panel) { min-height: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-order-content) { align-self: stretch; display: flex; flex-direction: column; min-height: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-table-wrap) { flex: 1; min-height: 0; max-height: none; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-pagination) { flex-shrink: 0; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-pallet-list),
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-vehicle-content) { min-height: 0; max-height: 100%; overflow-y: auto; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-vehicle-list) { max-height: none; overflow: visible; }
.dispatch-context-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); }
.dispatch-setup-fields { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4) var(--admin-space-6); }
.dispatch-setup-fields :deep(.el-form-item) { margin: 0; }
.dispatch-draft-tools { flex-wrap: wrap; align-items: center; }
.dispatch-draft-picker { width: 240px; }
.dispatch-capacity-field { display: inline-flex; align-items: center; gap: var(--admin-space-3); margin: 0; }
.dispatch-capacity-picker { width: 92px; }
.dispatch-candidate-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.dispatch-candidate-query > .el-input { width: 180px; }
.dispatch-candidate-query > .el-select { width: 170px; }
.dispatch-candidate-query > .dispatch-warehouse-picker { width: 200px; }
.dispatch-candidate-query > .el-button { margin-left: 0; }
.dispatch-workspace { margin-top: var(--admin-space-5); }
</style>