import { computed, reactive, ref, watch } from 'vue' import { useToast } from '../../composables/useToast.js' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import { deleteExpenseClaim, submitExpenseClaim, updateExpenseClaimItem } from '../../services/reimbursements.js' import { normalizeRequestForUi } from '../../utils/requestViewModel.js' const EXPENSE_TYPE_OPTIONS = [ { value: 'travel', label: '差旅费' }, { value: 'entertainment', label: '业务招待费' }, { value: 'office', label: '办公费' }, { value: 'meeting', label: '会务费' }, { value: 'training', label: '培训费' }, { value: 'hotel', label: '住宿费' }, { value: 'transport', label: '交通费' }, { value: 'meal', label: '餐费' }, { value: 'other', label: '其他费用' } ] function parseCurrency(value) { return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0 } function formatCurrency(value) { return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', minimumFractionDigits: 0, maximumFractionDigits: Number.isInteger(value) ? 0 : 2 }).format(value) } function buildFallbackProgressSteps() { return [ { index: 1, label: '保存草稿', time: '已完成', done: true, active: true }, { index: 2, label: '待提交', time: '进行中', active: true, current: true }, { index: 3, label: 'AI验审', time: '待处理' }, { index: 4, label: '直属领导审批', time: '待处理' }, { index: 5, label: '财务审批', time: '待处理' }, { index: 6, label: '归档入账', time: '待处理' } ] } function buildFallbackExpenseItems(request) { return [ { id: 'fallback-1', itemDate: '', itemType: request.typeCode || 'other', itemReason: request.reason, itemLocation: request.sceneTarget, itemAmount: parseCurrency(request.amountDisplay), invoiceId: '', time: '待补充', dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日', name: request.typeLabel, category: request.typeLabel, desc: request.reason, detail: request.sceneTarget, amount: request.amountDisplay, status: '待补充', tone: 'bad', attachmentStatus: '待上传', attachmentHint: '请在此单据中继续补充附件', attachmentTone: 'missing', attachments: [], riskLabel: '待补材料', riskText: request.riskSummary, riskTone: 'medium' } ] } function isPlaceholderValue(value) { const text = String(value || '').trim() if (!text) { return true } return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, '')) } function isValidIsoDate(value) { if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || '').trim())) { return false } const nextDate = new Date(`${value}T00:00:00`) return !Number.isNaN(nextDate.getTime()) && nextDate.toISOString().slice(0, 10) === value } function buildExpenseDraftIssues(item) { const issues = [] if (!isValidIsoDate(item.itemDate)) { issues.push('缺少日期') } if (isPlaceholderValue(item.itemType)) { issues.push('缺少费用项目') } if (isPlaceholderValue(item.itemReason)) { issues.push('缺少说明') } if (isPlaceholderValue(item.itemLocation)) { issues.push('缺少地点') } if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) { issues.push('缺少金额') } if (isPlaceholderValue(item.invoiceId)) { issues.push('缺少票据标识') } return issues } function buildDraftBlockingIssues(request, expenseItems) { const issues = [] if (isPlaceholderValue(request.profileName)) { issues.push('申请人未完善') } if (isPlaceholderValue(request.profileDepartment)) { issues.push('所属部门未完善') } if (isPlaceholderValue(request.typeLabel)) { issues.push('报销类型未完善') } if (isPlaceholderValue(request.reason)) { issues.push('报销事由未完善') } if (isPlaceholderValue(request.location)) { issues.push('业务地点未完善') } if (isPlaceholderValue(request.occurredDisplay)) { issues.push('发生时间未完善') } if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) { issues.push('报销金额未完善') } if (!expenseItems.length) { issues.push('费用明细不能为空') } expenseItems.forEach((item, index) => { buildExpenseDraftIssues(item).forEach((issue) => { issues.push(`费用明细第 ${index + 1} 条${issue}`) }) }) return [...new Set(issues)] } export default { name: 'TravelRequestDetailView', components: { ConfirmDialog }, props: { request: { type: Object, default: () => ({}) } }, emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'], setup(props, { emit }) { const { toast } = useToast() const expandedExpenseId = ref(null) const editingExpenseId = ref('') const savingExpenseId = ref('') const submitBusy = ref(false) const deleteBusy = ref(false) const deleteDialogOpen = ref(false) const expenseEditor = reactive({ itemDate: '', itemType: 'other', itemReason: '', itemLocation: '', itemAmount: '', invoiceId: '' }) const request = computed(() => { const normalized = normalizeRequestForUi(props.request) return ( normalized || { id: 'EXP-202605-000', claimId: '', reason: '待补充报销事由', typeLabel: '其他费用', typeCode: 'other', detailVariant: 'general', sceneTarget: '待补充', location: '待补充', occurredDisplay: '待补充', applyTime: '待补充', amountDisplay: '¥0', amountValue: 0, node: '待提交', approval: '草稿', approvalKey: 'draft', approvalTone: 'draft', secondaryStatusLabel: '票据状态', secondaryStatusValue: '待补充', secondaryStatusTone: 'warning', relatedCustomer: '待补充', attachmentSummary: '待补充', riskSummary: '待补充', note: '', profileName: '当前申请人', profileDepartment: '待补充部门', profileAvatar: '申' } ) }) const isTravelRequest = computed(() => request.value.detailVariant === 'travel') const isDraftRequest = computed(() => request.value.approvalKey === 'draft') const actionBusy = computed(() => Boolean(savingExpenseId.value) || submitBusy.value || deleteBusy.value) const profile = computed(() => ({ name: request.value.profileName, department: request.value.profileDepartment, avatar: request.value.profileAvatar })) const expenseItems = ref([]) watch( request, (nextRequest) => { expenseItems.value = Array.isArray(nextRequest.expenseItems) && nextRequest.expenseItems.length ? nextRequest.expenseItems : buildFallbackExpenseItems(nextRequest) expandedExpenseId.value = null editingExpenseId.value = '' }, { immediate: true } ) const heroStats = computed(() => [ { label: '金额', value: request.value.amountDisplay, kind: 'text' }, { label: '当前节点', value: request.value.node, kind: 'pill', className: 'state-pill', tone: request.value.approvalTone }, { label: '审批状态', value: request.value.approval, kind: 'pill', className: 'approval-pill', tone: request.value.approvalTone }, { label: request.value.secondaryStatusLabel, value: request.value.secondaryStatusValue, kind: 'pill', className: 'risk-pill', tone: request.value.secondaryStatusTone } ]) const heroSummaryItems = computed(() => { const commonItems = [ { label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' }, { label: '报销类型', value: request.value.typeLabel, icon: 'mdi mdi-tag-multiple' } ] if (isTravelRequest.value) { return [ ...commonItems, { label: '出行路线', value: request.value.sceneTarget, icon: 'mdi mdi-map-marker-path' }, { label: '出差区间', value: request.value.occurredDisplay, icon: 'mdi mdi-clock-outline' }, { label: '关联客户', value: request.value.relatedCustomer, icon: 'mdi mdi-domain' }, { label: '票据关联', value: request.value.attachmentSummary, icon: 'mdi mdi-file-document-multiple-outline' }, { label: '出差事由', value: request.value.reason, icon: 'mdi mdi-briefcase-outline' } ] } return [ ...commonItems, { label: '业务地点', value: request.value.sceneTarget, icon: 'mdi mdi-map-marker-outline' }, { label: '发生时间', value: request.value.occurredDisplay, icon: 'mdi mdi-calendar-month-outline' }, { label: '关联客户', value: request.value.relatedCustomer, icon: 'mdi mdi-domain' }, { label: '票据关联', value: request.value.attachmentSummary, icon: 'mdi mdi-paperclip' }, { label: '风险提示', value: request.value.riskSummary, icon: 'mdi mdi-shield-alert-outline' } ] }) const progressSteps = computed(() => Array.isArray(request.value.progressSteps) && request.value.progressSteps.length ? request.value.progressSteps : buildFallbackProgressSteps() ) const currentProgressRingMotion = { initial: { scale: 1, opacity: 0.34 }, enter: { scale: [1, 1.42, 1.78], opacity: [0.34, 0.16, 0], transition: { duration: 3.2, repeat: Infinity, repeatType: 'loop', repeatDelay: 0.85, ease: 'easeOut', times: [0, 0.5, 1] } } } const expenseTotal = computed(() => { const total = expenseItems.value.reduce((sum, item) => sum + parseCurrency(item.amount), 0) return formatCurrency(total) }) const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length) const expenseSummaryText = computed( () => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。' ) const detailNote = computed( () => request.value.note || (isTravelRequest.value ? '该差旅报销单尚未补充完整说明,请继续完善后再提交审批。' : '该报销单尚未补充完整说明,请继续完善后再提交审批。') ) const draftBlockingIssues = computed(() => isDraftRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : [] ) const canSubmit = computed(() => isDraftRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value) const validationTone = computed(() => (canSubmit.value ? 'ready' : 'pending')) const validationSummary = computed(() => canSubmit.value ? '当前草稿信息完整,可以提交审批。' : '当前草稿仍有未完善字段,提交按钮会保持禁用。' ) function toggleExpenseAttachments(id) { expandedExpenseId.value = expandedExpenseId.value === id ? null : id } function resolveExpenseIssues(item) { return buildExpenseDraftIssues(item) } function showExpenseRisk(item) { return Boolean(resolveExpenseIssues(item).length || item.riskText) } function startExpenseEdit(item) { if (!isDraftRequest.value || actionBusy.value) { return } editingExpenseId.value = item.id expenseEditor.itemDate = item.itemDate || '' expenseEditor.itemType = item.itemType || 'other' expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc) expenseEditor.itemLocation = item.itemLocation || (item.detail === '待补充' ? '' : item.detail) expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : '' expenseEditor.invoiceId = item.invoiceId || '' expandedExpenseId.value = null } function cancelExpenseEdit() { editingExpenseId.value = '' } function validateExpenseEditor() { if (!isValidIsoDate(expenseEditor.itemDate)) { return '请输入正确的费用日期,格式为 YYYY-MM-DD。' } if (isPlaceholderValue(expenseEditor.itemType)) { return '请选择费用项目。' } if (isPlaceholderValue(expenseEditor.itemReason)) { return '请输入费用说明。' } if (isPlaceholderValue(expenseEditor.itemLocation)) { return '请输入业务地点。' } const amount = Number(expenseEditor.itemAmount) if (!Number.isFinite(amount) || amount <= 0) { return '请输入大于 0 的费用金额。' } if (isPlaceholderValue(expenseEditor.invoiceId)) { return '请输入票据标识或附件名称。' } return '' } async function saveExpenseEdit(item) { if (!request.value.claimId) { toast('当前草稿缺少 claimId,暂时无法保存费用明细。') return } const validationError = validateExpenseEditor() if (validationError) { toast(validationError) return } savingExpenseId.value = item.id try { await updateExpenseClaimItem(request.value.claimId, item.id, { item_date: expenseEditor.itemDate, item_type: expenseEditor.itemType, item_reason: expenseEditor.itemReason.trim(), item_location: expenseEditor.itemLocation.trim(), item_amount: Number(expenseEditor.itemAmount), invoice_id: expenseEditor.invoiceId.trim() }) editingExpenseId.value = '' toast('费用明细已保存。') emit('request-updated', { claimId: request.value.claimId }) } catch (error) { toast(error?.message || '费用明细保存失败,请稍后重试。') } finally { savingExpenseId.value = '' } } async function handleSubmit() { if (!request.value.claimId) { toast('当前草稿缺少 claimId,暂时无法提交。') return } if (!canSubmit.value) { toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。') return } submitBusy.value = true try { await submitExpenseClaim(request.value.claimId) toast(`${request.value.id} 已提交审批。`) emit('request-updated', { claimId: request.value.claimId }) } catch (error) { toast(error?.message || '提交审批失败,请稍后重试。') } finally { submitBusy.value = false } } async function handleDeleteDraft() { if (!request.value.claimId) { toast('当前草稿缺少 claimId,暂时无法删除。') return } deleteDialogOpen.value = true } function closeDeleteDialog() { if (deleteBusy.value) { return } deleteDialogOpen.value = false } async function confirmDeleteDraft() { if (!request.value.claimId) { toast('当前草稿缺少 claimId,暂时无法删除。') return } deleteBusy.value = true try { const payload = await deleteExpenseClaim(request.value.claimId) deleteDialogOpen.value = false toast(payload?.message || `${request.value.id} 草稿已删除。`) emit('request-deleted', { claimId: request.value.claimId }) } catch (error) { toast(error?.message || '删除草稿失败,请稍后重试。') } finally { deleteBusy.value = false } } function openAiEntry() { emit('openAssistant', { source: 'detail', prompt: '', request: request.value }) } return { emit, actionBusy, canSubmit, closeDeleteDialog, confirmDeleteDraft, currentProgressRingMotion, deleteBusy, deleteDialogOpen, detailNote, draftBlockingIssues, editingExpenseId, expenseEditor, expenseItems, expenseSummaryText, expenseTotal, expandedExpenseId, expenseTypeOptions: EXPENSE_TYPE_OPTIONS, handleDeleteDraft, handleSubmit, heroStats, heroSummaryItems, isDraftRequest, isTravelRequest, openAiEntry, profile, progressSteps, request, resolveExpenseIssues, savingExpenseId, showExpenseRisk, startExpenseEdit, submitBusy, toggleExpenseAttachments, uploadedExpenseCount, validationSummary, validationTone, cancelExpenseEdit, saveExpenseEdit } } }