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

148 lines
17 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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getWorkDetail, type WorkAction, type WorkDetail, type WorkCommand, type WorkCommandResult } from './workApi'
import { actionAllowed, actionRoute, referenceRoute, commandName, flowLabels, typeLabels, workDate, workStatus, workReason, sameAction } from './workPresentation'
import CargoVerificationForm from './CargoVerificationForm.vue'
import WorkCommandForm from './WorkCommandForm.vue'
import WorkAppointmentForm from './WorkAppointmentForm.vue'
import WorkCreateAppointmentForm from './WorkCreateAppointmentForm.vue'
import WorkDispatchForm from './WorkDispatchForm.vue'
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
import WorkReceiptPlans from './WorkReceiptPlans.vue'
import AppointmentBatchForm from './AppointmentBatchForm.vue'
import WorkReceivingPanel from './WorkReceivingPanel.vue'
import WorkBatchContinuation from './WorkBatchContinuation.vue'
import type { FbaAppointmentDto } from '@/modules/overseas-warehouse'
import type { LeaveGuard } from './useWorkDraft'
import { useWorkSyncRead } from './useWorkSyncRead'
const props = defineProps<{ itemKey: string; initialAction?: WorkAction | null; canLeave: LeaveGuard }>()
const emit = defineEmits<{ open: [key: string, action?: WorkAction]; external: [path: string]; saved: [result?: WorkCommandResult, command?: WorkCommand['command']]; updated: []; container: [id: string, label: string] }>()
const detail = ref<WorkDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), action = ref<WorkAction | null>(null)
const receivingOwners = computed(() => [detail.value?.item.owner, ...(detail.value?.relatedItems.map(item => item.owner) || [])].filter((person): person is { id: string; name: string } => !!person?.id))
const actionNotice = ref('')
const saveCompleted = ref(false)
const creatingFromCargo = ref(false)
const batchKey = ref<string | null>(null), openedAppointmentNo = ref<string | null>(null)
const receivingKey = ref<string | null>(null)
const continuation = ref<{ batchKey: string; customerId?: string; labels?: Array<{ customerId: string; label: string }> } | null>(null)
let controller: AbortController | null = null, request = 0
const syncRead = useWorkSyncRead(), syncing = syncRead.syncing
async function load() {
const generation = ++request; controller?.abort(); controller = new AbortController(); loading.value = true; error.value = null
const signal = controller.signal
try { const result = await syncRead.read(() => getWorkDetail(props.itemKey, signal), signal); if (generation === request) detail.value = result }
catch (cause) { if (generation === request) error.value = getApiErrorDetails(cause, '工作事项读取失败') }
finally { if (generation === request) loading.value = false }
}
const fbaNumber = computed(() => {
const target = action.value?.target
if (target?.kind !== 'FBA_APPOINTMENT' || !target.appointmentId) return null
return detail.value?.references.find(ref => ref.kind === 'FBA_APPOINTMENT' && ref.key === target.batchKey)?.businessNo || null
})
const cargoSource = computed(() => {
const target = action.value?.target
return target?.kind === 'CARGO_VERIFICATION' ? { ...target.source, destinationKey: target.source.destinationKey || undefined } : null
})
const createSources = computed(() => creatingFromCargo.value && action.value?.target.kind === 'CARGO_VERIFICATION' ? [action.value.target.source] : action.value?.target.kind === 'FBA_APPOINTMENT' && !action.value.target.appointmentId && ['SAVE_PLAN', 'REQUEST_BOOKING', 'RECORD_CONFIRMATION'].includes(action.value.code) ? action.value.target.sources : null)
const dispatchBatchKeys = computed(() => {
const target = action.value?.target
if (target?.kind !== 'FLEET_TRIP' || target.tripId || action.value?.code !== 'ARRANGE_TRANSPORT') return null
return target.batchKeys.length && target.batchKeys.every(key => /^FBA_APPOINTMENT:\d+$/.test(key)) ? target.batchKeys : null
})
const existingDispatches = computed(() => {
const target = action.value?.target
if (target?.kind !== 'FLEET_TRIP' || target.tripId || action.value?.code !== 'ARRANGE_TRANSPORT' || target.batchKeys.length !== 1) return []
const batchKey = target.batchKeys[0]!, item = detail.value?.item
return /^DISPATCH:\d+$/.test(batchKey) && item?.batchKey === batchKey && item.batchNo ? [{ batchKey, dispatchNo: item.batchNo }] : []
})
const canAct = computed(() => !!detail.value && !!action.value && actionAllowed(detail.value.item, action.value) && !error.value && !loading.value)
const secondaryActions = computed(() => detail.value?.item.allowedActions.filter(candidate => candidate.code !== action.value?.code && (action.value || !detail.value?.item.nextAction || !sameAction(candidate, detail.value.item.nextAction))) || [])
async function choose(next: WorkAction) {
if (!detail.value || loading.value || error.value || !await props.canLeave()) return
actionNotice.value = ''
// 跨事项的主要下一步只用于导航,目标面板会重新读取并校验自己的写入资格。
const linkedNext = next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey && detail.value.item.nextAction && sameAction(next, detail.value.item.nextAction)
if (!linkedNext && !actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
saveCompleted.value = false
if (next.targetWorkItemKey && next.targetWorkItemKey !== props.itemKey) { emit('open', next.targetWorkItemKey, next); return }
const path = actionRoute(next, detail.value.references, detail.value.item)
if (path) { emit('external', path); return }
action.value = next; batchKey.value = null; openedAppointmentNo.value = null; receivingKey.value = null; continuation.value = null; creatingFromCargo.value = false
if (next.code === 'EDIT_BATCH' && next.target.kind === 'FBA_APPOINTMENT' && next.target.batchKey) { batchKey.value = next.target.batchKey; return }
if (next.target.kind === 'WORK_COMMAND' && !commandName(next)) actionNotice.value = '当前操作尚未提供可用入口,请联系负责人核对。'
if (next.target.kind === 'FBA_APPOINTMENT' && !fbaNumber.value && !createSources.value) actionNotice.value = '尚未关联可操作的预约记录,请先核实批次与预约关系。'
if (next.target.kind === 'FLEET_TRIP' && !dispatchBatchKeys.value?.length && !existingDispatches.value.length) actionNotice.value = '尚未关联可进入的车次或可排批次,请核对运输来源。'
}
async function saved(result?: WorkCommandResult, command?: WorkCommand['command']) {
action.value = null; batchKey.value = null; openedAppointmentNo.value = null; receivingKey.value = null; creatingFromCargo.value = false; actionNotice.value = ''; saveCompleted.value = true
emit('saved', result, command)
await load()
}
async function savedAppointment(appointment: FbaAppointmentDto) {
if (!appointment.batchKey) { await saved(); return }
action.value = null; batchKey.value = null; openedAppointmentNo.value = null; receivingKey.value = null; saveCompleted.value = true
continuation.value = { batchKey: appointment.batchKey, labels: [...new Set(appointment.items.map(item => item.customerId))].map(customerId => ({ customerId, label: appointment.items.filter(item => item.customerId === customerId).map(item => item.inboundCode || '—').join('、') })) }
emit('updated')
}
async function receivingUpdated() { emit('updated'); await load() }
async function cargoSaved() { emit('updated'); await load() }
async function createFromCargo() { if (canAct.value && await props.canLeave()) creatingFromCargo.value = true }
async function openBatch(key: string) { if (await props.canLeave()) { batchKey.value = key; openedAppointmentNo.value = null; receivingKey.value = null } }
async function openAppointment(number: string) { if (await props.canLeave()) { openedAppointmentNo.value = number; batchKey.value = null; receivingKey.value = null } }
async function openReceiving(key: string) { if (await props.canLeave()) { receivingKey.value = key; batchKey.value = null; openedAppointmentNo.value = null } }
onMounted(async () => {
await load()
// A predecessor's action is only a hint: use the newly fetched item's own qualification.
if (props.initialAction && detail.value) {
const own = detail.value.item.allowedActions.find(candidate => candidate.code === props.initialAction?.code && candidate.target.kind === props.initialAction?.target.kind)
if (own) await choose(own)
else if (detail.value.item.nextAction) await choose(detail.value.item.nextAction)
}
})
onBeforeUnmount(() => { ++request; controller?.abort() })
</script>
<template>
<div :aria-busy="loading">
<LoadErrorState v-if="error" title="工作事项读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading" role="status">{{ syncing ? '预约工作清单同步中,正在自动读取最新事项;已保存内容无需重复提交。' : '正在读取当前事项与操作资格…' }}</p>
<template v-if="detail">
<h3>{{ detail.item.title }}</h3><p>{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }} · {{ workStatus(detail.item) }}</p>
<p>{{ workDate(detail.item) }} · {{ detail.item.timezone || '地点时区待核实' }}</p><p>{{ detail.item.destinationName || detail.item.originName || '地点待核实' }} · {{ detail.item.owner?.name || '待分派' }}</p>
<p class="followup-summary">下次跟进:{{ detail.item.followupDate || '尚未安排' }}<span v-if="detail.item.dateBasis === 'NONE'"> · 作业日期未定,跟进日不作为作业日期</span></p>
<p v-if="detail.item.executionStatus !== 'COMPLETED' && (!detail.item.owner || !detail.item.followupDate)" class="work-warning">{{ !detail.item.owner ? '负责人待分派。' : '' }}{{ !detail.item.followupDate ? '请补充下一次跟进安排。' : '' }}请记录跟进;若无操作权限,请联系负责人。</p>
<div class="container-links"><button v-for="container in detail.item.containers" :key="container.containerBookingId" type="button" @click="emit('container', container.containerBookingId, container.containerNo || container.bookingNo || '本柜')">{{ container.containerNo || container.bookingNo || '—' }} · 本柜待办</button></div>
<p v-for="reason in [...detail.item.blockReasons, ...detail.item.missingFields, ...detail.item.overdueReasons]" :key="reason" class="work-warning">{{ workReason(reason) }}</p>
<p v-if="detail.returnHandoff">还空责任:{{ detail.returnHandoff.from.name }} → {{ detail.returnHandoff.to.name }} · {{ detail.returnHandoff.status === 'PENDING_ACCEPTANCE' ? '接手方待确认,持续跟进' : '已确认接手' }}</p>
<template v-if="!action && !continuation">
<p v-if="saveCompleted" role="status" class="save-result">本次保存已完成。{{ loading ? '正在读取当前下一步…' : error ? '下一步读取失败,请重试读取事项,无需重复提交已保存内容。' : detail.item.executionStatus === 'COMPLETED' ? '当前事项已完成,可返回查看其他待办。' : '请按当前下一步继续处理。' }}</p>
<el-button v-if="detail.item.nextAction" class="primary-next-action" type="primary" :disabled="loading || !!error" @click="choose(detail.item.nextAction)">{{ detail.item.nextAction.label }}</el-button>
<p v-else-if="detail.item.executionStatus !== 'COMPLETED'">当前没有可执行的下一步,请根据缺项或前置说明联系负责人。</p>
</template>
<p v-if="actionNotice" role="status" class="work-warning">{{ actionNotice }}</p>
<WorkBatchContinuation v-if="continuation" :key="JSON.stringify(continuation)" v-bind="continuation" @open="key => emit('open', key)" />
<WorkReceivingPanel v-else-if="receivingKey" :key="receivingKey" :batch-key="receivingKey" :owners="receivingOwners" :can-write="!loading && !error" @updated="receivingUpdated" @appointment="openAppointment" @batch="openBatch" @external="path => emit('external', path)" />
<AppointmentBatchForm v-else-if="batchKey" :key="batchKey" :batch-key="batchKey" @updated="emit('updated')" @appointment="openAppointment" />
<WorkAppointmentForm v-else-if="openedAppointmentNo" :key="openedAppointmentNo" :appointment-no="openedAppointmentNo" :cancel="false" :can-write="true" @saved="savedAppointment" @batch="openBatch" @receiving="openReceiving" />
<WorkCommandForm v-else-if="action && commandName(action)" :key="`${itemKey}:${action.code}`" :detail="detail" :action="action" :refreshing="loading || !!error" @refresh="load" @saved="saved" />
<template v-else-if="cargoSource && !creatingFromCargo"><CargoVerificationForm :key="`${cargoSource.customerId}:${cargoSource.sourceKey}:${cargoSource.destinationKey}`" :source="cargoSource" @saved="cargoSaved" /><el-button :disabled="!canAct" @click="createFromCargo">核对货物并新建原预约</el-button></template>
<WorkAppointmentForm v-else-if="fbaNumber && action" :key="`${fbaNumber}:${action.code}`" :appointment-no="fbaNumber" :cancel="action.code === 'CANCEL_BOOKING'" :can-write="canAct" @saved="savedAppointment" @batch="openBatch" @receiving="openReceiving" />
<WorkCreateAppointmentForm v-else-if="createSources" :key="JSON.stringify(createSources)" :sources="createSources" :can-write="canAct" @saved="saved()" />
<WorkDispatchForm v-else-if="dispatchBatchKeys?.length" :key="JSON.stringify(dispatchBatchKeys)" :batch-keys="dispatchBatchKeys" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" @batch="openBatch" />
<WorkExistingDispatchForm v-else-if="existingDispatches.length" :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
<WorkReceiptPlans v-else-if="action?.target.kind === 'WAREHOUSE_RECEIPT'" :key="action.target.containerBookingId" :container-booking-id="action.target.containerBookingId" :can-write="canAct" @updated="emit('updated')" @external="path => emit('external', path)" />
<section v-else-if="action?.target.kind === 'READ_ONLY'" aria-label="原业务进度"><h4>原业务进度</h4><p v-for="reference in action.target.references" :key="`${reference.kind}:${reference.key}`"><el-button v-if="referenceRoute(reference)" :disabled="!canAct" @click="emit('external', referenceRoute(reference)!)">{{ reference.label }} · {{ reference.businessNo }}</el-button><span v-else>{{ reference.label }} · {{ reference.businessNo || '—' }}</span></p></section>
<details v-if="secondaryActions.length"><summary>更多操作</summary><div class="more-actions"><el-button v-for="candidate in secondaryActions" :key="`${candidate.code}:${candidate.targetWorkItemKey}`" :disabled="loading || !!error" @click="choose(candidate)">{{ candidate.label }}</el-button></div></details>
<details><summary>关联工作 · {{ detail.relatedItems.length }} 项</summary><div v-for="related in detail.relatedItems" :key="related.workItemKey" class="related-row"><div>{{ related.title }}<p>{{ workStatus(related) }} · {{ workDate(related) }}</p></div><el-button @click="emit('open', related.workItemKey)">查看</el-button></div></details>
<details><summary>原业务记录 · {{ detail.references.length }} 项</summary><p v-for="reference in detail.references" :key="`${reference.kind}:${reference.key}`">{{ reference.label }} · {{ reference.businessNo || '—' }}</p></details>
<details :open="action?.code === 'VIEW_HISTORY'"><summary>操作记录</summary><div v-for="(entry, index) in detail.history" :key="index" class="history-entry"><b>{{ entry.action }}</b><p>{{ entry.summary }}</p><small>{{ entry.operatorName || '—' }} · {{ entry.occurredAt }}</small></div><p v-if="!detail.history.length">暂无记录</p></details>
</template>
</div>
</template>
<style scoped>
.container-links, .more-actions { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }.container-links button { background: transparent; border: 0; color: var(--el-color-primary); cursor: pointer; font: inherit; }
.related-row { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); }.related-row, .history-entry { border-bottom: 1px solid var(--cc-border); padding-block: var(--admin-space-3); }details { margin-top: var(--admin-space-5); }summary { cursor: pointer; padding-block: var(--admin-space-3); }.work-warning { color: var(--el-color-warning-dark-2); }
</style>