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

109 lines
8.8 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 { 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>
<details v-if="!error" class="container-help"><summary>说明</summary><p>同柜共用提柜跟进,接仓按仓库分别跟进。预计到港仅供参考。</p></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>