108 lines
8.6 KiB
Vue
108 lines
8.6 KiB
Vue
<script setup lang="ts">
|
|
import { onBeforeUnmount, onMounted, ref, computed } 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 { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
import { listPlanningContainers, resolvePlanningContainer, type PlanningContainer, type ContainerFollowupContext } from './planningApi'
|
|
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
|
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
|
|
import { listImpacts, refreshImpacts, impactStatus } from './impactApi'
|
|
|
|
const impactReady = ref(false), impactError = ref<ApiErrorDetails | null>(null)
|
|
const impactId = ref<string | null>(null), refreshing = ref(false), onlyChanges = ref(false)
|
|
const access = useAccessPreviewStore()
|
|
const query = ref<QueryModel>({ keyword: '' })
|
|
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '整柜', placeholder: '柜号或入仓码', width: 300 }]
|
|
const rows = ref<PlanningContainer[]>([]), page = ref(1), size = ref(20), total = ref(0)
|
|
const loading = ref(false), opening = ref(false)
|
|
const error = ref<ApiErrorDetails | null>(null), contextError = ref<ApiErrorDetails | null>(null)
|
|
const context = ref<ContainerFollowupContext | null>(null)
|
|
const changedCount = computed(() => rows.value.filter(row => row.impact?.status === 'OPEN').length)
|
|
const displayedRows = computed(() => onlyChanges.value ? rows.value.filter(row => row.impact?.status === 'OPEN') : rows.value)
|
|
async function refreshForecasts() {
|
|
refreshing.value = true; contextError.value = null
|
|
try { await refreshImpacts({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }); await load() }
|
|
catch (cause) { contextError.value = getApiErrorDetails(cause, '船期核对未完成,原记录保留') }
|
|
finally { refreshing.value = false }
|
|
}
|
|
let request = 0, contextRequest = 0, controller: AbortController | undefined
|
|
const columns: DataTableColumn[] = [
|
|
{ key: 'actions', label: '操作', slotName: 'actions', width: 200, fixed: 'left' },
|
|
{ prop: 'containerNo', label: '集装箱号', minWidth: 170 },
|
|
{ prop: 'orderCount', label: '美国范围票数', width: 120 },
|
|
{ prop: 'customerCount', label: '客户数', width: 90 },
|
|
{ key: 'packages', label: '预报箱数', slotName: 'packages', width: 130 },
|
|
{ key: 'arrival', label: '来源预计到港', slotName: 'arrival', minWidth: 200 },
|
|
{ key: 'receiving', label: '接仓点及跟进', slotName: 'receiving', minWidth: 280, singleLine: false },
|
|
{ key: 'impact', label: '船期跟进', slotName: 'impact', minWidth: 160 },
|
|
{ key: 'owner', label: '负责人', slotName: 'owner', minWidth: 110 },
|
|
{ key: 'scope', label: '范围核对', slotName: 'scope', minWidth: 200 },
|
|
]
|
|
async function load(reset = false) {
|
|
if (reset) page.value = 1
|
|
const current = ++request
|
|
controller?.abort(); controller = new AbortController()
|
|
loading.value = true; error.value = null; impactReady.value = false; impactError.value = null
|
|
try {
|
|
const params = { page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }
|
|
const [cargo, impacts] = await Promise.allSettled([listPlanningContainers(params, controller.signal), listImpacts(params, controller.signal)])
|
|
if (current !== request) return
|
|
if (cargo.status === 'rejected') throw cargo.reason
|
|
rows.value = cargo.value.records; total.value = cargo.value.total
|
|
if (impacts.status === 'fulfilled') {
|
|
const states = new Map(impacts.value.records.map(row => [row.container.containerBookingId, row.impact]))
|
|
rows.value = rows.value.map(row => ({ ...row, impact: states.get(row.containerBookingId) || null }))
|
|
impactReady.value = true
|
|
} else { onlyChanges.value = false; impactError.value = getApiErrorDetails(impacts.reason, '船期跟进暂不可用') }
|
|
|
|
} catch (cause) { if (current === request) { error.value = getApiErrorDetails(cause, '整柜货源加载失败'); rows.value = []; total.value = 0 } }
|
|
finally { if (current === request) loading.value = false }
|
|
}
|
|
async function follow(row: PlanningContainer, warehouseId?: string) {
|
|
const current = ++contextRequest
|
|
contextError.value = null; opening.value = true
|
|
try {
|
|
const result = await resolvePlanningContainer(row.containerBookingId, warehouseId)
|
|
if (current === contextRequest) context.value = result
|
|
} catch (cause) { if (current === contextRequest) contextError.value = getApiErrorDetails(cause, '整柜范围核对失败') }
|
|
finally { if (current === contextRequest) opening.value = false }
|
|
}
|
|
const displayTime = (value: string | null) => value?.replace('T', ' ') || '—'
|
|
onMounted(() => load())
|
|
onBeforeUnmount(() => { ++request; ++contextRequest; controller?.abort() })
|
|
</script>
|
|
|
|
<template>
|
|
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="displayedRows" :total="total" :loading="loading || refreshing" :results-visible="!error" row-key="containerBookingId" @update:page="load()" @update:page-size="load(true)">
|
|
<template #filters><QueryToolbar v-model="query" ariaLabel="提柜与到仓查询" :fields="fields" :loading="loading" @search="load(true)">
|
|
<template #actions>
|
|
<el-button v-if="impactReady" :type="onlyChanges ? 'primary' : 'default'" @click="onlyChanges = !onlyChanges">本页待处理 {{ changedCount }}</el-button>
|
|
<el-button v-if="impactReady && access.can('platform:appointment:followup')" :loading="refreshing" :disabled="loading" @click="refreshForecasts">核对船期</el-button>
|
|
</template>
|
|
</QueryToolbar></template>
|
|
<template #notices>
|
|
<LoadErrorState v-if="error" compact title="货柜列表暂时无法加载" :message="error.code === 'A0404' ? '列表服务暂不可用,请稍后重试。' : error.message" @retry="load()"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="error" /></details></template></LoadErrorState>
|
|
<LoadErrorState v-if="contextError" compact title="操作未完成" :message="contextError.message" :show-retry="false"><template #details><details><summary>诊断详情</summary><ApiErrorMeta :details="contextError" /></details></template></LoadErrorState>
|
|
<details v-if="impactError" class="container-help"><summary>船期跟进暂不可用,货柜列表仍可查看</summary><ApiErrorMeta :details="impactError" /></details>
|
|
</template>
|
|
<template #actions="{ row }"><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer)">提柜跟进</el-button><el-button v-if="row.impact" link type="primary" @click="impactId = row.containerBookingId">{{ row.impact.status === 'OPEN' ? '处理船期变化' : '核对记录' }}</el-button></template>
|
|
<template #packages="{ row }">{{ row.packages ?? '待核实' }}</template>
|
|
<template #arrival="{ row }">{{ displayTime(row.earliestArrivalAt) }}<span v-if="row.latestArrivalAt && row.latestArrivalAt !== row.earliestArrivalAt"> 至 {{ displayTime(row.latestArrivalAt) }}</span></template>
|
|
<template #receiving="{ row }"><div v-for="warehouse in row.warehouses" :key="warehouse.warehouseId" class="receiving-row"><span>{{ warehouse.warehouseName }} · {{ warehouse.orderCount }} 票</span><el-button class="density-action" size="small" :disabled="opening" @click="follow(row as PlanningContainer, warehouse.warehouseId)">接仓跟进</el-button></div></template>
|
|
<template #impact="{ row }">{{ row.impact ? impactStatus[row.impact.status] : (impactReady ? '未核对' : '暂不可用') }}</template>
|
|
<template #owner="{ row }">{{ row.impact?.ownerName || '未认领' }}</template>
|
|
<template #scope="{ row }">{{ row.incompleteScope ? '有未明确或非美国接仓点,待核对' : '接仓点已核实' }}</template>
|
|
</DataTable>
|
|
<AppointmentImpactDialog v-if="impactId" :container-id="impactId" :can-write="access.can('platform:appointment:followup')" @close="impactId = null" @saved="load()" />
|
|
<AppointmentFollowupDialog v-if="context" :container="context" :can-write="access.can('platform:appointment:followup')" @close="context = null" />
|
|
</template>
|
|
|
|
<style scoped>
|
|
.container-help { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
|
.container-help summary { cursor: pointer; }
|
|
.receiving-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }
|
|
</style>
|