Files
admin-web/src/modules/appointment-calendar/WorkAppointmentForm.vue
T
2026-10-21 14:30:33 +08:00

86 lines
7.5 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 './appointmentNativeForms.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getFbaAppointment, updateFbaAppointment, FbaAppointmentFields, createAppointmentForm, appointmentFormError, appointmentPayload, type FbaAppointmentDto } from '@/modules/overseas-warehouse'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { deleteAppointment } from './api'
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
const props = defineProps<{ appointmentNo: string; cancel: boolean; canWrite: boolean }>()
const emit = defineEmits<{ saved: [appointment: FbaAppointmentDto]; batch: [key: string]; receiving: [key: string] }>()
const access = useAccessPreviewStore(), detail = ref<FbaAppointmentDto | null>(null)
const confirmationReady = ref(false)
const appointmentFields = ref<InstanceType<typeof FbaAppointmentFields> | null>(null)
const form = reactive(createAppointmentForm()), reason = ref(''), baseline = ref(JSON.stringify(appointmentPayload(form)))
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
let initialized = false
type SaveRequest = { kind: 'update'; payload: { details: ReturnType<typeof appointmentPayload>; version: number; idempotencyKey: string } } | { kind: 'cancel'; version: number; reason: string }
const pending = ref<SaveRequest | null>(null)
const changed = computed(() => JSON.stringify(appointmentPayload(form)) !== baseline.value)
useWorkDraft(computed(() => changed.value || !!reason.value), computed(() => saving.value || !!pending.value))
const replaced = computed(() => !!detail.value?.replacementAppointmentNos?.length || !!detail.value?.replacementBatchKeys?.length)
const rebooking = computed(() => !props.cancel && detail.value?.appointmentStatus === 'CANCELLED' && !replaced.value)
const writable = computed(() => props.canWrite && !replaced.value && (!props.cancel || detail.value?.appointmentStatus !== 'CANCELLED') && access.can(props.cancel ? 'platform:appointment:delete' : 'platform:overseas-warehouse:inbound-order:fba-appointment'))
async function load(preserve = false) {
loading.value = true; error.value = null
try {
detail.value = await getFbaAppointment(props.appointmentNo)
if (!preserve || !initialized) {
Object.assign(form, createAppointmentForm(detail.value)); if (rebooking.value) form.appointmentStatus = 'PENDING'
baseline.value = JSON.stringify(appointmentPayload(form)); initialized = true
}
}
catch (cause) { error.value = getApiErrorDetails(cause, '预约详情读取失败') }
finally { loading.value = false }
}
async function save() {
if (!detail.value || !writable.value || loading.value || error.value || saving.value) return
if (!props.cancel && !pending.value && !changed.value && !rebooking.value) return
if (!pending.value) {
if (props.cancel) {
if (!reason.value.trim()) { ElMessage.warning('请填写取消原因'); return }
pending.value = { kind: 'cancel', version: detail.value.version, reason: reason.value.trim() }
} else {
const invalid = appointmentFormError(form); if (invalid) { ElMessage.warning(invalid); return }
if (form.appointmentStatus === 'SUCCESS' && !confirmationReady.value) { ElMessage.warning('请先在“正式确认前”区域补齐并保存每票货物资料'); return }
if (appointmentFields.value?.canLeave && !await appointmentFields.value.canLeave()) return
if (saving.value || !writable.value) return
pending.value = { kind: 'update', payload: { details: appointmentPayload(form), version: detail.value.version, idempotencyKey: crypto.randomUUID() } }
}
}
saving.value = true; saveError.value = null
try {
if (pending.value.kind === 'cancel') await deleteAppointment(props.appointmentNo, pending.value.version, pending.value.reason)
else detail.value = await updateFbaAppointment(props.appointmentNo, pending.value.payload)
pending.value = null; reason.value = ''; baseline.value = JSON.stringify(appointmentPayload(form))
ElMessage.success(props.cancel ? '原预约已取消,工作需求继续跟进' : '预约安排已保存'); emit('saved', detail.value); await load()
} catch (cause) { saveError.value = getApiErrorDetails(cause, '保存结果待确认,请重试本次保存'); if (rejectedCommand(cause)) { pending.value = null; await load(true) } }
finally { saving.value = false }
}
onMounted(() => load())
</script>
<template>
<section class="work-form appointment-native-form" aria-label="预约安排">
<h3>{{ cancel ? '取消本次预约' : rebooking ? '沿原批次重新预约' : '登记预约安排' }}</h3>
<LoadErrorState v-if="error" title="预约详情读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="loading">正在读取原预约…</p>
<template v-if="detail">
<p>{{ detail.fbaWarehouseCode }} · {{ detail.appointmentNo }}</p>
<p v-if="rebooking">原预约已取消,货物分配仍保留。重新预约将更新本批安排,保留取消历史;请重新填写目标日期或仓库确认,日期未定也可先保存待预约。</p>
<p v-if="replaced">原预约已被替代,保留历史只读。当前批次:{{ detail.replacementAppointmentNos?.join('、') || '—' }}。{{ detail.repartitionReason }}</p>
<el-button v-if="detail.batchKey && !saving && !pending" @click="emit('batch', detail.batchKey)">{{ replaced ? '查看替代批次' : '分批 / 合并与货物来源' }}</el-button>
<el-button v-if="detail.batchKey && !replaced && !saving && !pending" :disabled="loading || !!error" @click="emit('receiving', detail.batchKey)">客户调整 / 共同预约范围</el-button>
<template v-if="cancel"><p>取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。</p><label>取消原因<textarea v-model="reason" :disabled="!writable || saving || !!pending" maxlength="1000" class="workbench-input" /></label></template>
<FbaAppointmentFields v-else ref="appointmentFields" :sources="detail.items.map(item => ({ sourceKey: item.sourceKey, customerId: item.customerId, destinationKey: item.destinationKey || `FBA:${item.fbaWarehouseCode}` }))" @confirmation-ready="confirmationReady = $event" :model="form" :appointment-no="detail.appointmentNo" :timezone="detail.timezone" :disabled="!writable || loading || !!error || saving || !!pending" />
<p v-if="!writable">当前预约仅可查看。</p>
<LoadErrorState v-if="saveError" title="预约未保存" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="!cancel && !rebooking && writable && !changed && !pending">预约内容未修改,无需重复保存。</p>
<el-button :type="cancel ? 'danger' : 'primary'" :disabled="!writable || loading || !!error || (!cancel && !rebooking && !pending && !changed)" :loading="saving" @click="save">{{ pending ? '重试本次保存' : cancel ? '确认取消并继续跟进' : rebooking ? '保存本批重新预约' : '保存预约安排' }}</el-button>
</template>
</section>
</template>