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

362 lines
22 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/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { resolvePlanningSource, type ResolvedPlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import {
createFbaAppointment, getFbaAppointment, listFbaAppointmentCalendar, listInboundOrders, updateFbaAppointment,
overseasWarehousePermissions as permission,
type FbaAppointmentDto, type BatchFbaAppointmentPayload, type InboundOrderSummaryDto,
} from '@/modules/overseas-warehouse'
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue'
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import { calendarRange, dateKey } from './calendar'
import { deleteAppointment, listArrivalCalendar, type ArrivalCalendarEntry } from './api'
const access = useAccessPreviewStore()
const route = useRoute()
const linkedSource = ref<ResolvedPlanningSource | null>(null)
const linkError = ref<ApiErrorDetails | null>(null)
const linkLoading = ref(false)
let linkRequest = 0
const workView = ref<'calendar' | 'cargo'>('calendar')
const appointments = ref<FbaAppointmentDto[]>([])
const arrivals = ref<ArrivalCalendarEntry[]>([])
const range = ref(calendarRange(dateKey(new Date()), 'month'))
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const sourceErrors = ref<Array<{ title: string; details: ApiErrorDetails }>>([])
const errorTitle = ref('预约日历加载失败')
const dialogError = ref<ApiErrorDetails | null>(null)
const dialogOpen = ref(false)
const saving = ref(false)
const detailLoading = ref(false)
const detailReady = ref(false)
const editing = ref<FbaAppointmentDto | null>(null)
const warehouse = ref('')
const warehouseOptions = ref<PlatformWarehouseSummaryResource[]>([])
const warehouseLoading = ref(false)
const warehouseError = ref<ApiErrorDetails | null>(null)
const warehouseKeyword = ref('')
const warehouseTotal = ref(0)
let warehouseRequest = 0
const candidates = ref<InboundOrderSummaryDto[]>([])
const selected = ref<InboundOrderSummaryDto[]>([])
const candidateLoading = ref(false)
const candidateError = ref<ApiErrorDetails | null>(null)
const candidatePage = ref(1)
const candidateSize = ref(10)
const candidateTotal = ref(0)
const searchedWarehouse = ref('')
const selectionEpoch = ref(0)
let calendarController: AbortController | undefined
let calendarRequest = 0
let candidateRequest = 0
let detailRequest = 0
const form = reactive({ appointmentEndAt: '', appointmentNo: '', appointmentAt: '', expectedLoadingAt: '', appointmentStatus: 'PENDING' as BatchFbaAppointmentPayload['appointmentStatus'], appointmentRemark: '' })
const statusLabels: Record<string, string> = { PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' }
const columns: DataTableColumn[] = [
{ key: 'selection', type: 'selection', reserveSelection: true, width: 46, selectable: (row: InboundOrderSummaryDto) => row.fbaWarehouseCode === searchedWarehouse.value && (!row.appointmentStatus || ['FAILED', 'CANCELLED'].includes(row.appointmentStatus)) },
{ prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ prop: 'customerName', label: '客户', slotName: 'customer', minWidth: 150 },
{ prop: 'fbaWarehouseCode', label: '目的仓', width: 110 },
{ prop: 'overseasWarehouseName', label: '海外仓', minWidth: 150 },
{ prop: 'packageCount', label: '箱数', width: 80 },
{ prop: 'palletCount', label: '托数', width: 80 },
{ prop: 'appointmentNo', label: '已有预约号', minWidth: 160 },
]
const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival => ({
key: `arrival-${arrival.containerBookingId}`, type: 'arrival' as const,
date: arrival.arrivalAt.slice(0, 10), time: arrival.arrivalAt.slice(11, 16),
title: `${arrival.voyageNo || arrival.bookingNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`,
fields: [
{ label: '船名 / 航次', value: [arrival.vesselName, arrival.voyageNo].filter(Boolean).join(' / ') },
{ label: '目的港', value: arrival.destinationPortName },
{ label: '已装货柜号', value: arrival.containerNo },
{ label: '柜次号', value: arrival.bookingNo },
{ label: '货物去向', value: arrival.destinationWarehouseCode },
],
})), ...appointments.value.map(appointment => ({
key: appointment.appointmentNo, type: 'delivery' as const, date: appointment.appointmentAt.slice(0, 10),
time: appointment.appointmentAt.slice(11, 16) + (appointment.appointmentEndAt ? `–${appointment.appointmentEndAt.slice(11, 16)}` : ''), title: `${appointment.fbaWarehouseCode} 预约送仓`,
fields: [
{ label: '预约仓库', value: appointment.fbaWarehouseCode },
{ label: '预约编号', value: appointment.appointmentNo },
{ label: '预约状态', value: statusLabels[appointment.appointmentStatus] || '—' },
{ label: '货物信息', value: `${appointment.selectedCount} 票 / ${new Set(appointment.items.map(item => item.customerId)).size} 个客户` },
{ label: '预约备注', value: appointment.appointmentRemark },
],
inboundCodes: appointment.items.map(item => item.inboundCode),
canEdit: access.can(permission.inboundOrderAppointment),
canDelete: access.can('platform:appointment:delete'),
}))])
async function load(nextRange = range.value) {
range.value = nextRange
const request = ++calendarRequest
calendarController?.abort()
calendarController = new AbortController()
loading.value = true
loadError.value = null
sourceErrors.value = []
errorTitle.value = '预约日历加载失败'
appointments.value = []
arrivals.value = []
try {
const [result, arrivalResult] = await Promise.allSettled([
listFbaAppointmentCalendar(nextRange, calendarController.signal),
listArrivalCalendar(nextRange, calendarController.signal),
])
if (request !== calendarRequest) return
if (result.status === 'fulfilled') appointments.value = result.value
else sourceErrors.value.push({ title: '送仓预约加载失败', details: getApiErrorDetails(result.reason, '送仓预约加载失败') })
if (arrivalResult.status === 'fulfilled') arrivals.value = arrivalResult.value
else sourceErrors.value.push({ title: '船舶到港加载失败', details: getApiErrorDetails(arrivalResult.reason, '船舶到港加载失败') })
} catch (error) {
if (request === calendarRequest) loadError.value = getApiErrorDetails(error, '预约日历加载失败')
} finally { if (request === calendarRequest) loading.value = false }
}
function openCreate(date: string) {
++candidateRequest
++detailRequest
detailLoading.value = false
candidateLoading.value = false
candidatePage.value = 1
editing.value = null
warehouse.value = ''
searchedWarehouse.value = ''
candidates.value = []
selected.value = []
selectionEpoch.value++
candidateTotal.value = 0
candidateError.value = null
dialogError.value = null
Object.assign(form, { appointmentEndAt: '', appointmentNo: '', appointmentAt: `${date}T09:00:00`, expectedLoadingAt: '', appointmentStatus: 'PENDING', appointmentRemark: '' })
dialogOpen.value = true
detailReady.value = true
void searchWarehouses('')
}
async function openEdit(event: CalendarEvent) {
const request = ++detailRequest
dialogError.value = null
editing.value = appointments.value.find(item => item.appointmentNo === event.key) || null
if (!editing.value) return
dialogOpen.value = true
detailLoading.value = true
detailReady.value = false
try {
const detail = await getFbaAppointment(event.key)
if (request !== detailRequest || !dialogOpen.value) return
editing.value = detail
detailReady.value = true
warehouse.value = detail.fbaWarehouseCode
Object.assign(form, { appointmentEndAt: detail.appointmentEndAt || '', appointmentNo: detail.appointmentNo, appointmentAt: detail.appointmentAt,
expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: detail.appointmentStatus,
appointmentRemark: detail.appointmentRemark || '' })
} catch (error) {
if (request === detailRequest) dialogError.value = getApiErrorDetails(error, '预约详情加载失败')
} finally { if (request === detailRequest) detailLoading.value = false }
}
async function searchWarehouses(keyword: string) {
warehouseKeyword.value = keyword
const request = ++warehouseRequest
warehouseLoading.value = true
warehouseError.value = null
warehouseOptions.value = []
warehouseTotal.value = 0
try {
const result = await listPlatformWarehouses({ page: 1, size: 50, keyword: keyword.trim() || undefined, warehouseType: 'FBA', status: 'ACTIVE' })
if (request !== warehouseRequest || !dialogOpen.value) return
warehouseOptions.value = result.records.filter(item => item.warehouseType === 'FBA' && item.status === 'ACTIVE')
warehouseTotal.value = result.total
} catch (error) {
if (request === warehouseRequest) warehouseError.value = getApiErrorDetails(error, '仓库搜索失败,请重试')
} finally { if (request === warehouseRequest) warehouseLoading.value = false }
}
function changeWarehouse(value: string) {
++candidateRequest
candidateLoading.value = false
candidates.value = []
selected.value = []
candidateTotal.value = 0
candidateError.value = null
candidatePage.value = 1
selectionEpoch.value++
searchedWarehouse.value = value
if (value) void loadCandidates()
}
async function loadCandidates() {
if (!searchedWarehouse.value) return
candidateLoading.value = true
candidateError.value = null
const request = ++candidateRequest
try {
const result = await listInboundOrders({ page: candidatePage.value, size: candidateSize.value, fbaWarehouseCode: searchedWarehouse.value })
if (request !== candidateRequest || !dialogOpen.value) return
candidates.value = result.records
candidateTotal.value = result.total
} catch (error) {
if (request === candidateRequest) {
candidates.value = []
candidateError.value = getApiErrorDetails(error, '可预约货物加载失败')
}
} finally { if (request === candidateRequest) candidateLoading.value = false }
}
async function save() {
if (saving.value || detailLoading.value || !detailReady.value) return
if (!form.appointmentNo.trim() || !form.appointmentAt || !form.expectedLoadingAt) {
ElMessage.warning('请填写预约号、预约送仓时间和预计装车时间'); return
}
if (!editing.value) {
if (!warehouse.value || warehouse.value !== searchedWarehouse.value || candidateLoading.value || candidateError.value) { ElMessage.warning('请先选择仓库并等待货物加载完成'); return }
if (selected.value.some(item => item.fbaWarehouseCode !== warehouse.value)) { ElMessage.warning('请重新选择当前仓库的货物'); return }
if (!selected.value.length) { ElMessage.warning('请选择需要预约的货物'); return }
if (new Set(selected.value.map(item => item.fbaWarehouseCode)).size !== 1) {
ElMessage.warning('同一预约的货物必须送往同一目的仓'); return
}
}
saving.value = true
dialogError.value = null
try {
const common = { appointmentEndAt: form.appointmentEndAt || null, appointmentAt: form.appointmentAt, expectedLoadingAt: form.expectedLoadingAt,
appointmentStatus: form.appointmentStatus, appointmentRemark: form.appointmentRemark.trim() || undefined }
if (editing.value) await updateFbaAppointment(editing.value.appointmentNo, { ...common, version: editing.value.version })
else await createFbaAppointment({ ...common, appointmentNo: form.appointmentNo.trim(), lineIds: selected.value.map(item => item.lineId) })
dialogOpen.value = false
ElMessage.success('预约已保存')
await load()
} catch (error) { dialogError.value = getApiErrorDetails(error, '预约保存失败,请核对后重试') }
finally { saving.value = false }
}
function selectCandidates(rows: object[]) {
selected.value = rows as InboundOrderSummaryDto[]
}
async function remove(event: CalendarEvent) {
const appointment = appointments.value.find(item => item.appointmentNo === event.key)
if (!appointment || loading.value) return
try {
await ElMessageBox.confirm(`确认删除预约 ${appointment.appointmentNo}?该操作会解除全部 ${appointment.selectedCount} 票货物的预约关联。`, '删除预约',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消', customClass: 'admin-density-confirm' })
} catch { return }
loading.value = true
try {
await deleteAppointment(appointment.appointmentNo, appointment.version)
ElMessage.success('预约已删除')
await load()
} catch (error) {
errorTitle.value = '预约删除失败'
loadError.value = getApiErrorDetails(error, '预约删除失败')
} finally { loading.value = false }
}
watch(dialogOpen, open => {
if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false }
})
async function resolveLink() {
const request = ++linkRequest
linkedSource.value = null; linkError.value = null; linkLoading.value = false
const { sourceKey, customerId, destinationKey } = route.query
if (!sourceKey && !customerId && !destinationKey) return
if (typeof sourceKey !== 'string' || typeof customerId !== 'string' || typeof destinationKey !== 'string') {
linkError.value = { message: '货物链接不完整,请返回原登记重新打开' }; return
}
if (!access.can(permission.lastMileOrderList)) { linkError.value = { message: '当前账号没有货源读取权限,请联系预约负责人处理' }; return }
linkLoading.value = true
try {
const result = await resolvePlanningSource({ sourceKey, customerId, destinationKey })
if (request === linkRequest) linkedSource.value = result
} catch (cause) { if (request === linkRequest) linkError.value = getApiErrorDetails(cause, '预约货源核对失败') }
finally { if (request === linkRequest) linkLoading.value = false }
}
watch(() => [route.query.sourceKey, route.query.customerId, route.query.destinationKey], resolveLink, { immediate: true })
onMounted(() => load())
onBeforeUnmount(() => { ++calendarRequest; ++candidateRequest; ++detailRequest; ++warehouseRequest; ++linkRequest; calendarController?.abort() })
</script>
<template>
<AdminPage class="admin-density">
<p v-if="linkLoading">正在核对预约货源…</p>
<LoadErrorState v-if="linkError" title="预约货源核对失败" :message="linkError.message" :loading="linkLoading" @retry="resolveLink" />
<AppointmentFollowupDialog v-if="linkedSource" :source="linkedSource.source" :coverage="linkedSource.coverage" :can-write="access.can('platform:appointment:followup')" @close="linkedSource = null" />
<div class="density-status-tabs" role="group" aria-label="预约工作视图">
<button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="workView = 'calendar'">预约日历</button>
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="workView = 'cargo'">已开船 FBA 货物</button>
</div>
<AppointmentCargoPanel v-if="workView === 'cargo'" />
<div v-show="workView === 'calendar'">
<LoadErrorState v-if="loadError" :title="errorTitle" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<LoadErrorState v-for="error in sourceErrors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="sourceErrors.length > 0" :can-create="access.can(permission.inboundOrderAppointment)" @range="load" @create="openCreate" @edit="openEdit" @delete="remove" />
</div>
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
<LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<div v-loading="detailLoading">
<el-form label-position="top" class="calendar-form density-form">
<el-form-item label="预约仓库" required>
<el-input v-if="editing" :model-value="warehouse" readonly />
<el-select v-else v-model="warehouse" aria-label="预约仓库" filterable remote clearable :remote-method="searchWarehouses" :loading="warehouseLoading" placeholder="搜索仓库编码或名称" @change="changeWarehouse">
<el-option v-for="item in warehouseOptions" :key="String(item.id)" :value="item.warehouseCode" :label="item.warehouseName === item.warehouseCode ? item.warehouseCode : `${item.warehouseCode} · ${item.warehouseName}`" />
</el-select>
</el-form-item>
<el-form-item label="预约送仓时间" required><el-date-picker v-model="form.appointmentAt" type="datetime" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item label="预约编号" required><el-input v-model="form.appointmentNo" :readonly="!!editing" maxlength="64" placeholder="填写仓库提供的预约编号" /></el-form-item>
<el-form-item label="预计装车时间" required><el-date-picker v-model="form.expectedLoadingAt" type="datetime" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item v-if="editing" label="预约状态"><el-select v-model="form.appointmentStatus"><el-option v-for="(label, value) in statusLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item>
</el-form>
<LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" />
<p v-if="!editing && warehouseTotal > warehouseOptions.length && !warehouseError" class="appointment-hint">输入更完整的仓库编码或名称,缩小搜索范围。</p>
<details :key="editing?.appointmentNo || 'create'" class="appointment-extra">
<summary>补充信息(结束时间、备注{{ editing ? '' : '、状态' }})</summary>
<el-form label-position="top" class="calendar-form density-form">
<el-form-item label="预约结束时间"><el-date-picker v-model="form.appointmentEndAt" type="datetime" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<el-form-item v-if="!editing" label="预约状态"><el-select v-model="form.appointmentStatus"><el-option v-for="(label, value) in statusLabels" :key="value" :value="value" :label="label" /></el-select></el-form-item>
<el-form-item label="备注"><el-input v-model="form.appointmentRemark" maxlength="1000" placeholder="有特殊要求时填写" /></el-form-item>
</el-form>
</details>
<template v-if="!editing && warehouse">
<DataTable :key="selectionEpoch" v-model:page="candidatePage" v-model:page-size="candidateSize" :data="candidates" :columns="columns" :total="candidateTotal" :loading="candidateLoading" row-key="lineId" :results-visible="!candidateError" @selection-change="selectCandidates" @update:page="loadCandidates()" @update:page-size="candidatePage = 1; loadCandidates()">
<template #customer="{ row }">{{ row.customerName?.trim() || '客户名称缺失' }}</template>
<template #notices><LoadErrorState v-if="candidateError" title="货物加载失败" :message="candidateError.message" @retry="loadCandidates()" /></template>
<template #pagination-left>已选 {{ selected.length }} 票,可跨客户选择同一目的仓货物</template>
</DataTable>
</template>
<p v-else-if="!editing" class="appointment-hint">先选择预约仓库,即可勾选同仓货物,支持多个客户一起预约。</p>
<div v-else class="appointment-cargo">
<span>{{ editing.selectedCount }} 票货物</span>
<AppointmentInboundCodes :key="editing.appointmentNo" :codes="editing.items.map(item => item.inboundCode)" />
</div>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.calendar-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
.calendar-form :deep(.el-select), .calendar-form :deep(.el-date-editor) { width: 100%; }
.appointment-cargo { padding: 12px 0; line-height: 1.7; overflow-wrap: anywhere; font-size: 13px; }
.appointment-extra { margin-bottom: var(--admin-space-5); }
.appointment-extra summary { cursor: pointer; margin-bottom: var(--admin-space-3); font-size: var(--admin-font-body); }
.appointment-hint { color: var(--el-text-color-secondary); font-size: var(--admin-font-body); margin: var(--admin-space-5) 0; }
</style>