102 lines
8.0 KiB
Vue
102 lines
8.0 KiB
Vue
<script setup lang="ts">
|
||
import { onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { getFollowups, saveFollowup, type PlanningSource, type AppointmentCoverage, type FollowupRecord, type FollowupStage, type SaveFollowup } from './planningApi'
|
||
|
||
const props = defineProps<{ source: PlanningSource; coverage: AppointmentCoverage; canWrite: boolean }>()
|
||
const emit = defineEmits<{ close: [] }>()
|
||
const stages: Record<FollowupStage, string> = { DRAYAGE_CONTACT: '拖车联系', TERMINAL_APPOINTMENT: '码头约号', WAREHOUSE_RECEIVING: '接仓跟进', FBA_RECEIVING: 'FBA收货跟进' }
|
||
const coverageNeeds: Record<string, string> = {
|
||
DESTINATION: '目的仓与原安排不一致,请核对。',
|
||
CARRIER_CONFIRMATION: '尚未确认承运,请先在原登记完成确认。',
|
||
REAL_CARGO_MATERIALS: '尚未关联实际货物资料,可先记录联系结果。',
|
||
CARGO_QUANTITY_REVIEW: '货量或重复关联需要核对。',
|
||
RECEIVING_APPOINTMENT: '尚未安排收货预约。',
|
||
UNCOVERED_CARGO: '部分货物尚未纳入收货预约。',
|
||
RECEIVING_CONFIRMATION: '尚未取得全部货物的收货确认。',
|
||
APPOINTMENT_TIMEZONE_UNVERIFIED: '原预约未记录时区,请核实收货地点当地时间。',
|
||
FBA_SHIPMENT_REFERENCES: 'FBA货件资料尚未齐全,请补充货件号或追踪号。',
|
||
SOURCE_CHANGED_REVIEW: '货源资料更新晚于预约,请核对是否需要改约。',
|
||
}
|
||
const form = reactive({ stage: (props.source.bookingNo ? 'FBA_RECEIVING' : 'DRAYAGE_CONTACT') as FollowupStage, contactedParty: '', result: '', nextAction: '', followupDate: '' })
|
||
const rows = ref<FollowupRecord[]>([])
|
||
const versions = ref<Record<FollowupStage, number>>({ DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 })
|
||
const page = ref(1), size = ref(10), total = ref(0)
|
||
const loading = ref(false), saving = ref(false), ready = ref(false)
|
||
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
||
const pending = ref<SaveFollowup | null>(null)
|
||
let generation = 0
|
||
const columns: DataTableColumn[] = [
|
||
{ key: 'stage', label: '事项', slot: 'stage', width: 120 },
|
||
{ prop: 'contactedParty', label: '联系对象', minWidth: 130 },
|
||
{ prop: 'result', label: '联系结果', minWidth: 200 },
|
||
{ prop: 'nextAction', label: '下一步', minWidth: 150 },
|
||
{ prop: 'followupDate', label: '下次跟进日', width: 120 },
|
||
{ prop: 'operatorName', label: '跟进人', minWidth: 110 },
|
||
]
|
||
function rowKey(row: object) { const value = row as FollowupRecord; return `${value.stage}:${value.version}` }
|
||
async function load() {
|
||
const request = ++generation
|
||
loading.value = true; error.value = null; ready.value = false
|
||
try {
|
||
const result = await getFollowups(props.source.sourceKey, props.source.customerId, page.value, size.value)
|
||
if (request !== generation) return
|
||
rows.value = result.history.records; total.value = result.history.total; versions.value = result.versions; ready.value = true
|
||
} catch (cause) { if (request === generation) error.value = getApiErrorDetails(cause, '跟进记录加载失败') }
|
||
finally { if (request === generation) loading.value = false }
|
||
}
|
||
async function save() {
|
||
if (!props.canWrite || !props.source.canFollowup || saving.value || !ready.value) return
|
||
if (!pending.value && (!form.result.trim() || (form.followupDate && !form.nextAction.trim()))) {
|
||
ElMessage.warning('请填写联系结果;设置下次跟进日时需填写下一步安排'); return
|
||
}
|
||
pending.value ||= { stage: form.stage, expectedVersion: versions.value[form.stage], idempotencyKey: crypto.randomUUID(),
|
||
contactedParty: form.contactedParty.trim() || undefined, result: form.result.trim(), nextAction: form.nextAction.trim() || undefined, followupDate: form.followupDate || null }
|
||
saving.value = true; saveError.value = null
|
||
try {
|
||
await saveFollowup(props.source.sourceKey, props.source.customerId, pending.value)
|
||
pending.value = null; form.result = ''; form.nextAction = ''; form.followupDate = ''; page.value = 1
|
||
ElMessage.success('跟进记录已保存'); await load()
|
||
} catch (cause) {
|
||
saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存')
|
||
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(saveError.value.code || '')) { pending.value = null; await load() }
|
||
} finally { saving.value = false }
|
||
}
|
||
onMounted(load)
|
||
onBeforeUnmount(() => { ++generation })
|
||
</script>
|
||
|
||
<template>
|
||
<AppDialog :model-value="true" class="admin-density-dialog" :title="`预约跟进 · ${source.inboundCode || source.bookingNo || '未关联入仓码'}`" width="min(1080px, calc(100vw - 48px))" :confirm-text="pending ? '重试本次保存' : '保存跟进'" :confirm-loading="saving" :confirm-disabled="!canWrite || !source.canFollowup || !ready || loading" @confirm="save" @update:model-value="emit('close')">
|
||
<p>{{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}</p>
|
||
<LoadErrorState v-if="error" title="跟进记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
<LoadErrorState v-if="saveError" title="跟进保存未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||
<p v-for="reason in source.blockReasons" :key="reason">{{ reason }}</p>
|
||
<p v-if="coverage.changeReviewRequired">来源资料有变化,请核对原预约与运输安排。</p>
|
||
<p v-if="coverage.appointments.length">已关联预约:{{ coverage.appointments.map(item => item.appointmentNo).join('、') }}</p>
|
||
<p v-else>尚未关联收货预约;联系记录保存后仍需登记对方正式确认。</p>
|
||
<ul v-if="coverage.missingFields.length" aria-label="预约待核实事项"><li v-for="item in coverage.missingFields" :key="item">{{ coverageNeeds[item] || '存在待核实事项,请返回来源详情核对。' }}</li></ul>
|
||
<el-form v-if="canWrite && source.canFollowup" label-position="top" class="followup-form density-form" :disabled="saving || !!pending">
|
||
<el-form-item label="跟进事项"><el-select v-model="form.stage"><el-option v-for="(label,value) in stages" :key="value" :value="value" :label="label" /></el-select></el-form-item>
|
||
<el-form-item label="联系对象"><el-input v-model="form.contactedParty" maxlength="200" placeholder="本次联系的车队、码头或仓库" /></el-form-item>
|
||
<el-form-item label="下次跟进日期"><el-date-picker v-model="form.followupDate" type="date" value-format="YYYY-MM-DD" placeholder="业务地点当地日期" /></el-form-item>
|
||
<el-form-item label="联系结果" required class="wide"><el-input v-model="form.result" type="textarea" maxlength="1000" placeholder="记录对方反馈,不会自动确认预约" /></el-form-item>
|
||
<el-form-item label="下一步安排" class="wide"><el-input v-model="form.nextAction" maxlength="500" placeholder="填写待补资料或下次联系事项" /></el-form-item>
|
||
</el-form>
|
||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="rowKey" @update:page="load" @update:page-size="page = 1; load()">
|
||
<template #stage="{ row }">{{ stages[row.stage as FollowupStage] }}</template>
|
||
</DataTable>
|
||
</AppDialog>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.followup-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-6); }
|
||
.wide { grid-column: 1 / -1; }
|
||
.followup-form :deep(.el-select), .followup-form :deep(.el-date-editor) { width: 100%; }
|
||
</style>
|