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

102 lines
8.0 KiB
Vue
Raw Normal View History

<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>