- AuditView.js: update audit view logic - EmployeeManagementView.js: update employee management logic - PoliciesView.js: update policies view logic - RequestsView.js: update requests view logic - TravelReimbursementCreateView.js: update travel form logic - TravelRequestDetailView.js: update travel detail view logic
548 lines
17 KiB
JavaScript
548 lines
17 KiB
JavaScript
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
|
||
}
|
||
}
|
||
}
|