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

117 lines
13 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 } 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 type { LeaveGuard } from './useWorkDraft'
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 actionNotice = ref('')
const creatingFromCargo = ref(false)
const batchKey = ref<string | null>(null), openedAppointmentNo = ref<string | null>(null)
let controller: AbortController | null = null, request = 0
async function load() {
const generation = ++request; controller?.abort(); controller = new AbortController(); loading.value = true; error.value = null
try { const result = await getWorkDetail(props.itemKey, controller.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)
async function choose(next: WorkAction) {
if (!detail.value || loading.value || error.value || !await props.canLeave()) return
actionNotice.value = ''
if (!actionAllowed(detail.value.item, next)) { actionNotice.value = '操作资格已变化,请刷新事项后查看当前下一步。'; return }
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; 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 = '尚未关联可进入的车次或可排批次,请核对运输来源。'
}
function saved(result?: WorkCommandResult, command?: WorkCommand['command']) { emit('saved', result, command) }
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 } }
async function openAppointment(number: string) { if (await props.canLeave()) { openedAppointmentNo.value = number; batchKey.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">正在读取当前事项与操作资格…</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>
<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">
<el-button v-if="detail.item.nextAction" 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>
<AppointmentBatchForm v-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="saved()" @batch="openBatch" />
<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="saved()" @batch="openBatch" />
<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)" />
<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="detail.item.allowedActions.length"><summary>更多操作</summary><div class="more-actions"><el-button v-for="candidate in detail.item.allowedActions.filter(candidate => candidate.code !== action?.code)" :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>