Files
admin-web/src/modules/appointment-calendar/AppointmentCreatePanel.vue
T

146 lines
10 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 { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { FbaAppointmentFields, createAppointmentForm, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
import { listPlanningCargo, listPlanningDestinations, resolvePlanningSource, type PlanningCargo } from './planningApi'
import { useWorkDraft, type LeaveGuard } from './useWorkDraft'
import type { SourceReference } from './workApi'
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
const props = defineProps<{ canWrite: boolean; canLeave: LeaveGuard }>()
const emit = defineEmits<{ created: [appointment: FbaAppointmentDto] }>()
const query = ref<QueryModel>({ keyword: '', destination: '' }), keyword = ref(''), destination = ref(''), page = ref(1), total = ref(0)
const destinations = ref<string[]>([]), destinationsLoading = ref(false), destinationsLoaded = ref(false), destinationError = ref<ApiErrorDetails | null>(null)
let destinationsRequest = 0, destinationsController: AbortController | null = null
const fields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 },
{ key: 'destination', type: 'select', label: '目的FBA仓', placeholder: '搜索或选择目的FBA仓', width: 220,
options: destinations.value.map(code => ({ label: code, value: code })),
props: { filterable: true, loading: destinationsLoading.value, onVisibleChange: (visible: boolean) => {
if (visible && !destinationsLoaded.value) void loadDestinations()
} } },
])
const rows = ref<PlanningCargo[]>([]), selected = ref<Record<string, PlanningCargo>>({})
const selectedRows = computed(() => Object.values(selected.value))
const loading = ref(false), resolving = ref(false), error = ref<ApiErrorDetails | null>(null), selectionError = ref<ApiErrorDetails | null>(null)
const references = ref<SourceReference[] | null>(null), created = ref<FbaAppointmentDto | null>(null)
const createdForm = computed(() => createAppointmentForm(created.value || undefined))
useWorkDraft(computed(() => !references.value && !created.value && selectedRows.value.length > 0), computed(() => false))
let generation = 0, selectionGeneration = 0, controller: AbortController | null = null
const columns: DataTableColumn[] = [
{ key: 'select', label: '选择', width: 60, slotName: 'select' },
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
{ prop: 'customerName', label: '客户', minWidth: 140 },
{ prop: 'containerNo', label: '柜号', minWidth: 145 },
{ prop: 'originWarehouseName', label: '起运仓', minWidth: 140 },
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
{ prop: 'packages', label: '箱数', width: 80 },
{ key: 'eligibility', label: '预约资格', minWidth: 130, slotName: 'eligibility' },
]
function key(row: PlanningCargo) { return JSON.stringify([row.customerId, row.sourceKey]) }
function reason(row: PlanningCargo) {
if (row.detained) return '货物暂扣中'
if (row.mixedDestinations) return '多个目的地,需先核对'
if (row.hasActiveAppointment) return '已有有效预约'
const anchor = selectedRows.value[0]
if (anchor && (anchor.originWarehouseId !== row.originWarehouseId || anchor.destinationWarehouseCode !== row.destinationWarehouseCode)) return '请选择同起运仓、同目的仓'
if (selectedRows.value.length >= 100 && !selected.value[key(row)]) return '每次最多选择100票'
return ''
}
function toggle(row: PlanningCargo, checked: boolean | string | number) {
if (!props.canWrite || resolving.value) return
if (!checked) delete selected.value[key(row)]
else if (!reason(row)) selected.value[key(row)] = row
selectionError.value = null
}
async function load(nextPage = page.value) {
if (!props.canWrite) return
page.value = nextPage
const current = ++generation; controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null
try {
const result = await listPlanningCargo({ page: nextPage, size: 20, keyword: keyword.value || undefined,
...(destination.value ? { destination: destination.value } : {}) }, controller.signal)
if (current === generation) { rows.value = result.records; total.value = result.total }
} catch (cause) {
if (current === generation) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '可预约货物读取失败') }
} finally { if (current === generation) loading.value = false }
}
function search() {
keyword.value = String(query.value.keyword || '').trim()
destination.value = String(query.value.destination || '').trim()
void load(1)
}
async function loadDestinations() {
if (!props.canWrite || destinationsLoading.value) return
const current = ++destinationsRequest
destinationsController?.abort(); destinationsController = new AbortController()
destinationsLoading.value = true; destinationError.value = null
try {
const result = await listPlanningDestinations(destinationsController.signal)
if (current === destinationsRequest) { destinations.value = result; destinationsLoaded.value = true }
} catch (cause) {
if (current === destinationsRequest) destinationError.value = getApiErrorDetails(cause, '目的FBA仓选项读取失败')
} finally { if (current === destinationsRequest) destinationsLoading.value = false }
}
async function prepare() {
if (!props.canWrite || !selectedRows.value.length || loading.value || resolving.value || error.value) return
const current = ++selectionGeneration, snapshot = [...selectedRows.value]
resolving.value = true; selectionError.value = null
try {
const sources: SourceReference[] = []
// 仅对明确选中的货物读取最新来源,不按展示行逐票请求,也不猜目的地键。
for (const row of snapshot) {
if (current !== selectionGeneration) return
const { source } = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId })
if (current !== selectionGeneration) return
if (!source.canFollowup) { selectionError.value = { message: source.blockReasons.join(';') || '所选货物当前不能新增预约,请重新核对。' }; return }
sources.push({ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey })
}
if (current === selectionGeneration) references.value = sources
} catch (cause) { if (current === selectionGeneration) selectionError.value = getApiErrorDetails(cause, '所选货物核对失败') }
finally { if (current === selectionGeneration) resolving.value = false }
}
async function back() { if (await props.canLeave()) references.value = null }
function saved(appointment: FbaAppointmentDto) {
created.value = appointment; references.value = null; selected.value = {}
emit('created', appointment)
}
async function again() {
if (!await props.canLeave()) return
created.value = null; references.value = null; selected.value = {}; selectionError.value = null
await load()
}
onMounted(() => { void load() })
onBeforeUnmount(() => { ++generation; ++selectionGeneration; ++destinationsRequest; controller?.abort(); destinationsController?.abort() })
</script>
<template>
<section aria-label="新增FBA送仓预约">
<template v-if="created">
<p role="status">预约已保存:<strong>{{ created.appointmentNo || '预约编号未返回' }}</strong></p>
<p>预约保存后仍需在原业务安排车辆,日历中的排车以真实车次为准。</p>
<el-button v-if="canWrite" @click="again">继续新增预约</el-button>
<FbaAppointmentFields :model="createdForm" :appointment-no="created.appointmentNo" :timezone="created.timezone" :disabled="true" />
</template>
<template v-else-if="references">
<el-button @click="back">返回选择货物</el-button>
<WorkCreateAppointmentForm :sources="references" :can-write="canWrite" @saved="saved" />
</template>
<template v-else>
<p>选择需要送往FBA的已有货物;同一起运仓、同一目的仓可合并多个客户的货物。跨页选择会保留。</p>
<DataTable :data="rows" :columns="columns" :page="page" :page-size="20" :page-sizes="[20]" :total="total" :loading="loading" :results-visible="!error" :row-key="key" empty-text="暂无可预约货物" @update:page="load($event)">
<template #filters><QueryToolbar v-model="query" ariaLabel="新增预约货物查询" :fields="fields" :loading="loading || resolving" @search="search" /></template>
<template #toolbar-actions><el-button :disabled="!selectedRows.length || resolving" @click="selected = {}; selectionError = null">清空选择</el-button><el-button type="primary" :disabled="!canWrite || !selectedRows.length || loading || !!error" :loading="resolving" @click="prepare">下一步:填写预约({{ selectedRows.length }})</el-button></template>
<template #pagination-left>已选 {{ selectedRows.length }}/100 票</template>
<template #notices><LoadErrorState v-if="destinationError" title="目的FBA仓选项读取失败" :message="destinationError.message" :loading="destinationsLoading" @retry="loadDestinations"><template #details><ApiErrorMeta :details="destinationError" /></template></LoadErrorState><LoadErrorState v-if="error" title="可预约货物读取失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><LoadErrorState v-if="selectionError" title="所选货物核对失败" :message="selectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="selectionError" /></template></LoadErrorState></template>
<template #select="{ row }"><el-checkbox :model-value="!!selected[key(row as PlanningCargo)]" :aria-label="`选择 ${row.inboundCode || row.containerNo || '未提供业务编号的货物'}`" :disabled="!canWrite || resolving || (!selected[key(row as PlanningCargo)] && !!reason(row as PlanningCargo))" @change="toggle(row as PlanningCargo, $event)" /></template>
<template #eligibility="{ row }">{{ reason(row as PlanningCargo) || '可选择' }}</template>
</DataTable>
</template>
</section>
</template>