refactor(web): update view scripts
- AuditView.js: update audit view logic - EmployeeManagementView.js: update employee management logic - RequestsView.js: update requests view logic - TravelRequestDetailView.js: update travel detail view logic
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||||
|
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||||
import { useSystemState } from '../../composables/useSystemState.js'
|
import { useSystemState } from '../../composables/useSystemState.js'
|
||||||
import { useToast } from '../../composables/useToast.js'
|
import { useToast } from '../../composables/useToast.js'
|
||||||
import {
|
import {
|
||||||
@@ -849,7 +850,8 @@ function buildReviewNote(status) {
|
|||||||
export default {
|
export default {
|
||||||
name: 'AuditView',
|
name: 'AuditView',
|
||||||
components: {
|
components: {
|
||||||
ConfirmDialog
|
ConfirmDialog,
|
||||||
|
TableEmptyState
|
||||||
},
|
},
|
||||||
emits: ['detail-open-change'],
|
emits: ['detail-open-change'],
|
||||||
setup(_, { emit }) {
|
setup(_, { emit }) {
|
||||||
@@ -946,6 +948,39 @@ export default {
|
|||||||
|
|
||||||
return tokens
|
return tokens
|
||||||
})
|
})
|
||||||
|
const auditEmptyState = computed(() => {
|
||||||
|
const hasFilters = activeFilterTokens.value.length > 0
|
||||||
|
|
||||||
|
if (!currentAssets.value.length) {
|
||||||
|
return {
|
||||||
|
eyebrow: `${activeTabLabel.value}资产`,
|
||||||
|
title: `${activeTabLabel.value}列表暂时还是空的`,
|
||||||
|
desc: `当前环境里还没有可展示的${activeTabLabel.value}资产。完成接入或同步后,会统一展示在这里。`,
|
||||||
|
icon: 'mdi mdi-database-search-outline',
|
||||||
|
actionLabel: '重新加载',
|
||||||
|
actionIcon: 'mdi mdi-refresh',
|
||||||
|
tone: 'amber',
|
||||||
|
artLabel: 'ASSET',
|
||||||
|
tips: ['切换页签可查看其他资产类型', '支持按业务域、负责人和状态做过滤']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eyebrow: '筛选结果为空',
|
||||||
|
title: `没有找到匹配的${activeTabLabel.value}`,
|
||||||
|
desc: hasFilters
|
||||||
|
? '试试清空业务域、负责人、状态或关键词筛选,再重新查看。'
|
||||||
|
: `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`,
|
||||||
|
icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline',
|
||||||
|
actionLabel: hasFilters ? '清空筛选' : '重新加载',
|
||||||
|
actionIcon: hasFilters ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-refresh',
|
||||||
|
tone: hasFilters ? 'emerald' : 'slate',
|
||||||
|
artLabel: hasFilters ? 'FILTER' : 'QUEUE',
|
||||||
|
tips: hasFilters
|
||||||
|
? ['业务域、负责人、状态与关键词会叠加过滤', '可以换个编码、名称或负责人关键词继续搜索']
|
||||||
|
: ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据']
|
||||||
|
}
|
||||||
|
})
|
||||||
const canActivateSelected = computed(() => {
|
const canActivateSelected = computed(() => {
|
||||||
if (!selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) {
|
if (!selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) {
|
||||||
return false
|
return false
|
||||||
@@ -1010,6 +1045,15 @@ export default {
|
|||||||
activeFilterPopover.value = ''
|
activeFilterPopover.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAuditEmptyAction() {
|
||||||
|
if (!currentAssets.value.length || !activeFilterTokens.value.length) {
|
||||||
|
loadAssets({ force: true }).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFilters()
|
||||||
|
}
|
||||||
|
|
||||||
function toggleFilterPopover(name) {
|
function toggleFilterPopover(name) {
|
||||||
activeFilterPopover.value = activeFilterPopover.value === name ? '' : name
|
activeFilterPopover.value = activeFilterPopover.value === name ? '' : name
|
||||||
}
|
}
|
||||||
@@ -1261,6 +1305,7 @@ export default {
|
|||||||
tableColumns,
|
tableColumns,
|
||||||
showMetricColumn,
|
showMetricColumn,
|
||||||
visibleSkills,
|
visibleSkills,
|
||||||
|
auditEmptyState,
|
||||||
loading,
|
loading,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
detailLoading,
|
detailLoading,
|
||||||
@@ -1287,6 +1332,7 @@ export default {
|
|||||||
openAssetDetail,
|
openAssetDetail,
|
||||||
closeDetail,
|
closeDetail,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
|
handleAuditEmptyAction,
|
||||||
toggleFilterPopover,
|
toggleFilterPopover,
|
||||||
selectFilter,
|
selectFilter,
|
||||||
closeFilterPopover,
|
closeFilterPopover,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||||
|
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||||
import { useToast } from '../../composables/useToast.js'
|
import { useToast } from '../../composables/useToast.js'
|
||||||
import { disableEmployee, fetchEmployeeMeta, fetchEmployees, updateEmployee } from '../../services/employees.js'
|
import { disableEmployee, fetchEmployeeMeta, fetchEmployees, updateEmployee } from '../../services/employees.js'
|
||||||
|
|
||||||
@@ -201,7 +202,8 @@ function buildEmployeeSummary(employees) {
|
|||||||
export default {
|
export default {
|
||||||
name: 'EmployeeManagementView',
|
name: 'EmployeeManagementView',
|
||||||
components: {
|
components: {
|
||||||
ConfirmDialog
|
ConfirmDialog,
|
||||||
|
TableEmptyState
|
||||||
},
|
},
|
||||||
emits: ['overview-change'],
|
emits: ['overview-change'],
|
||||||
setup(_, { emit }) {
|
setup(_, { emit }) {
|
||||||
@@ -311,6 +313,40 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const hasActiveFilters = computed(() => activeFilterTokens.value.length > 0)
|
const hasActiveFilters = computed(() => activeFilterTokens.value.length > 0)
|
||||||
|
const hasEmployeeFilters = computed(() => {
|
||||||
|
return activeTab.value !== DEFAULT_STATUS_TABS[0] || hasActiveFilters.value
|
||||||
|
})
|
||||||
|
const employeeEmptyState = computed(() => {
|
||||||
|
if (!employees.value.length) {
|
||||||
|
return {
|
||||||
|
eyebrow: '员工台账',
|
||||||
|
title: '员工目录暂时还是空的',
|
||||||
|
desc: '当前环境还没有同步任何员工档案。完成目录接入后,这里会展示员工基础信息、角色和状态。',
|
||||||
|
icon: 'mdi mdi-account-group-outline',
|
||||||
|
actionLabel: '重新加载',
|
||||||
|
actionIcon: 'mdi mdi-refresh',
|
||||||
|
tone: 'sky',
|
||||||
|
artLabel: 'PEOPLE',
|
||||||
|
tips: ['支持按部门、职级和角色统一维护', '点击列表行即可进入档案和权限详情']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
eyebrow: hasEmployeeFilters.value ? '筛选结果为空' : '员工状态为空',
|
||||||
|
title: hasEmployeeFilters.value ? '当前条件下没有匹配员工' : `“${activeTab.value}”里暂时没有员工`,
|
||||||
|
desc: hasEmployeeFilters.value
|
||||||
|
? '可以切回“全部员工”,或者清空关键词、部门、职级和角色条件后再试。'
|
||||||
|
: '这个状态标签下目前还没有记录,你可以切换到其他状态继续查看。',
|
||||||
|
icon: hasEmployeeFilters.value ? 'mdi mdi-account-search-outline' : 'mdi mdi-badge-account-horizontal-outline',
|
||||||
|
actionLabel: hasEmployeeFilters.value ? '清空筛选' : '查看全部员工',
|
||||||
|
actionIcon: hasEmployeeFilters.value ? 'mdi mdi-filter-remove-outline' : 'mdi mdi-format-list-bulleted',
|
||||||
|
tone: hasEmployeeFilters.value ? 'emerald' : 'slate',
|
||||||
|
artLabel: hasEmployeeFilters.value ? 'FILTER' : 'STATUS',
|
||||||
|
tips: hasEmployeeFilters.value
|
||||||
|
? ['关键词、部门、职级和角色条件会叠加生效', '也可以直接搜索姓名、工号或岗位']
|
||||||
|
: ['员工状态统计会按真实目录数据自动更新', '停用员工仍会保留在台账中便于追溯']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
employeeSummary,
|
employeeSummary,
|
||||||
@@ -343,6 +379,15 @@ export default {
|
|||||||
pageSizeOpen.value = false
|
pageSizeOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEmployeeEmptyAction() {
|
||||||
|
if (!employees.value.length) {
|
||||||
|
loadEmployees().catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFilters()
|
||||||
|
}
|
||||||
|
|
||||||
function changePageSize(size) {
|
function changePageSize(size) {
|
||||||
pageSize.value = size
|
pageSize.value = size
|
||||||
pageSizeOpen.value = false
|
pageSizeOpen.value = false
|
||||||
@@ -641,6 +686,7 @@ export default {
|
|||||||
roleOptions,
|
roleOptions,
|
||||||
employees,
|
employees,
|
||||||
visibleEmployees,
|
visibleEmployees,
|
||||||
|
employeeEmptyState,
|
||||||
searchKeyword,
|
searchKeyword,
|
||||||
selectedDepartment,
|
selectedDepartment,
|
||||||
selectedGrade,
|
selectedGrade,
|
||||||
@@ -655,9 +701,11 @@ export default {
|
|||||||
roleFilterOptions,
|
roleFilterOptions,
|
||||||
activeFilterTokens,
|
activeFilterTokens,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
|
hasEmployeeFilters,
|
||||||
totalCount,
|
totalCount,
|
||||||
totalPages,
|
totalPages,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
|
handleEmployeeEmptyAction,
|
||||||
openEmployeeDetail,
|
openEmployeeDetail,
|
||||||
closeEmployeeDetail,
|
closeEmployeeDetail,
|
||||||
closeDisableDialog,
|
closeDisableDialog,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||||
|
|
||||||
function extractRowDate(value) {
|
function extractRowDate(value) {
|
||||||
@@ -9,6 +10,9 @@ function extractRowDate(value) {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'RequestsView',
|
name: 'RequestsView',
|
||||||
|
components: {
|
||||||
|
TableEmptyState
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
filteredRequests: { type: Array, required: true },
|
filteredRequests: { type: Array, required: true },
|
||||||
hasData: { type: Boolean, default: false },
|
hasData: { type: Boolean, default: false },
|
||||||
@@ -108,20 +112,67 @@ export default {
|
|||||||
})
|
})
|
||||||
const showTable = computed(() => !props.loading && !props.error && visibleRows.value.length > 0)
|
const showTable = computed(() => !props.loading && !props.error && visibleRows.value.length > 0)
|
||||||
const showEmpty = computed(() => !props.loading && !props.error && visibleRows.value.length === 0)
|
const showEmpty = computed(() => !props.loading && !props.error && visibleRows.value.length === 0)
|
||||||
|
const hasListFilters = computed(() => {
|
||||||
|
return Boolean(
|
||||||
|
activeTab.value !== '全部'
|
||||||
|
|| listKeyword.value.trim()
|
||||||
|
|| appliedStart.value
|
||||||
|
|| appliedEnd.value
|
||||||
|
)
|
||||||
|
})
|
||||||
const emptyState = computed(() => {
|
const emptyState = computed(() => {
|
||||||
if (!props.hasData) {
|
if (!props.hasData) {
|
||||||
return {
|
return {
|
||||||
title: '暂无真实报销单据',
|
eyebrow: '个人报销',
|
||||||
desc: '数据库里还没有可见的个人报销数据。保存草稿或提交报销后,会显示在这里。'
|
title: '还没有任何报销单据',
|
||||||
|
desc: '首张草稿或已提交的报销单会自动出现在这里,后续可以继续补充、提交和跟踪进度。',
|
||||||
|
icon: 'mdi mdi-receipt-text-plus-outline',
|
||||||
|
actionLabel: '发起报销',
|
||||||
|
actionIcon: 'mdi mdi-plus-circle-outline',
|
||||||
|
tone: 'emerald',
|
||||||
|
artLabel: 'CLAIM',
|
||||||
|
tips: ['保存草稿后会自动回到这里', '支持草稿、待提交、审批中和已完成全流程管理']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: '没有匹配结果',
|
eyebrow: hasListFilters.value ? '筛选结果为空' : '状态列表为空',
|
||||||
desc: '当前筛选条件下没有可展示的报销单据。'
|
title: hasListFilters.value ? '当前条件下没有匹配单据' : `“${activeTab.value}”里暂时没有单据`,
|
||||||
|
desc: hasListFilters.value
|
||||||
|
? '可以清空关键词、时间段或状态筛选后再看看。'
|
||||||
|
: '当前状态下还没有可展示的报销记录,可以先发起一笔报销或切换到其他状态。',
|
||||||
|
icon: hasListFilters.value ? 'mdi mdi-magnify-scan' : 'mdi mdi-clipboard-text-clock-outline',
|
||||||
|
actionLabel: hasListFilters.value ? '清空筛选' : '',
|
||||||
|
actionIcon: hasListFilters.value ? 'mdi mdi-filter-remove-outline' : '',
|
||||||
|
tone: hasListFilters.value ? 'sky' : 'slate',
|
||||||
|
artLabel: hasListFilters.value ? 'FILTER' : 'QUEUE',
|
||||||
|
tips: hasListFilters.value
|
||||||
|
? ['关键词、时间段和状态会叠加生效', '可尝试搜索单号、事由或报销类型']
|
||||||
|
: ['已完成单据会保留在列表中便于追踪', '草稿、审批中和待补充会按真实状态实时归类']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
activeTab.value = '全部'
|
||||||
|
listKeyword.value = ''
|
||||||
|
datePopover.value = false
|
||||||
|
rangeStart.value = ''
|
||||||
|
rangeEnd.value = ''
|
||||||
|
appliedStart.value = ''
|
||||||
|
appliedEnd.value = ''
|
||||||
|
pageSizeOpen.value = false
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEmptyAction() {
|
||||||
|
if (!props.hasData) {
|
||||||
|
emit('create-request')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFilters()
|
||||||
|
}
|
||||||
|
|
||||||
watch([activeTab, rows, listKeyword, appliedStart, appliedEnd], () => {
|
watch([activeTab, rows, listKeyword, appliedStart, appliedEnd], () => {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
})
|
})
|
||||||
@@ -151,7 +202,9 @@ export default {
|
|||||||
visibleRows,
|
visibleRows,
|
||||||
showTable,
|
showTable,
|
||||||
showEmpty,
|
showEmpty,
|
||||||
emptyState
|
emptyState,
|
||||||
|
resetFilters,
|
||||||
|
handleEmptyAction
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { useToast } from '../../composables/useToast.js'
|
import { useToast } from '../../composables/useToast.js'
|
||||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||||
import { deleteExpenseClaim, submitExpenseClaim, updateExpenseClaimItem } from '../../services/reimbursements.js'
|
import {
|
||||||
|
createExpenseClaimItem,
|
||||||
|
deleteExpenseClaimItem,
|
||||||
|
deleteExpenseClaimItemAttachment,
|
||||||
|
deleteExpenseClaim,
|
||||||
|
fetchExpenseClaimItemAttachment,
|
||||||
|
fetchExpenseClaimItemAttachmentMeta,
|
||||||
|
submitExpenseClaim,
|
||||||
|
uploadExpenseClaimItemAttachment,
|
||||||
|
updateExpenseClaimItem
|
||||||
|
} from '../../services/reimbursements.js'
|
||||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||||
|
|
||||||
const EXPENSE_TYPE_OPTIONS = [
|
const EXPENSE_TYPE_OPTIONS = [
|
||||||
@@ -17,6 +27,15 @@ const EXPENSE_TYPE_OPTIONS = [
|
|||||||
{ value: 'other', label: '其他费用' }
|
{ value: 'other', label: '其他费用' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||||
|
'travel',
|
||||||
|
'hotel',
|
||||||
|
'transport',
|
||||||
|
'meal',
|
||||||
|
'meeting',
|
||||||
|
'entertainment'
|
||||||
|
])
|
||||||
|
|
||||||
function parseCurrency(value) {
|
function parseCurrency(value) {
|
||||||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
||||||
}
|
}
|
||||||
@@ -30,6 +49,34 @@ function formatCurrency(value) {
|
|||||||
}).format(value)
|
}).format(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeExpenseType(value) {
|
||||||
|
return String(value || '').trim() || 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExpenseTypeLabel(value) {
|
||||||
|
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocationRequiredExpenseType(value) {
|
||||||
|
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocationInputPlaceholder(value) {
|
||||||
|
return isLocationRequiredExpenseType(value) ? '输入业务地点' : '输入采购/收货地点(可选)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocationSummaryLabel(value) {
|
||||||
|
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocationDisplay(value, expenseType) {
|
||||||
|
if (!isLocationRequiredExpenseType(expenseType) && isPlaceholderValue(value)) {
|
||||||
|
return '非必填'
|
||||||
|
}
|
||||||
|
|
||||||
|
return isPlaceholderValue(value) ? '待补充' : value
|
||||||
|
}
|
||||||
|
|
||||||
function buildFallbackProgressSteps() {
|
function buildFallbackProgressSteps() {
|
||||||
return [
|
return [
|
||||||
{ index: 1, label: '保存草稿', time: '已完成', done: true, active: true },
|
{ index: 1, label: '保存草稿', time: '已完成', done: true, active: true },
|
||||||
@@ -43,7 +90,7 @@ function buildFallbackProgressSteps() {
|
|||||||
|
|
||||||
function buildFallbackExpenseItems(request) {
|
function buildFallbackExpenseItems(request) {
|
||||||
return [
|
return [
|
||||||
{
|
buildExpenseItemViewModel({
|
||||||
id: 'fallback-1',
|
id: 'fallback-1',
|
||||||
itemDate: '',
|
itemDate: '',
|
||||||
itemType: request.typeCode || 'other',
|
itemType: request.typeCode || 'other',
|
||||||
@@ -56,7 +103,7 @@ function buildFallbackExpenseItems(request) {
|
|||||||
name: request.typeLabel,
|
name: request.typeLabel,
|
||||||
category: request.typeLabel,
|
category: request.typeLabel,
|
||||||
desc: request.reason,
|
desc: request.reason,
|
||||||
detail: request.sceneTarget,
|
detail: resolveLocationDisplay(request.sceneTarget, request.typeCode),
|
||||||
amount: request.amountDisplay,
|
amount: request.amountDisplay,
|
||||||
status: '待补充',
|
status: '待补充',
|
||||||
tone: 'bad',
|
tone: 'bad',
|
||||||
@@ -67,7 +114,7 @@ function buildFallbackExpenseItems(request) {
|
|||||||
riskLabel: '待补材料',
|
riskLabel: '待补材料',
|
||||||
riskText: request.riskSummary,
|
riskText: request.riskSummary,
|
||||||
riskTone: 'medium'
|
riskTone: 'medium'
|
||||||
}
|
}, 0, request)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,16 +128,110 @@ function isPlaceholderValue(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isValidIsoDate(value) {
|
function isValidIsoDate(value) {
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value || '').trim())) {
|
const normalized = String(value || '').trim()
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextDate = new Date(`${value}T00:00:00`)
|
const [yearText, monthText, dayText] = normalized.split('-')
|
||||||
return !Number.isNaN(nextDate.getTime()) && nextDate.toISOString().slice(0, 10) === value
|
const year = Number(yearText)
|
||||||
|
const month = Number(monthText)
|
||||||
|
const day = Number(dayText)
|
||||||
|
|
||||||
|
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = new Date(Date.UTC(year, month - 1, day))
|
||||||
|
return (
|
||||||
|
candidate.getUTCFullYear() === year &&
|
||||||
|
candidate.getUTCMonth() === month - 1 &&
|
||||||
|
candidate.getUTCDate() === day
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIsoDateValue(value) {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
if (isValidIsoDate(normalized)) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||||
|
if (match && isValidIsoDate(match[1])) {
|
||||||
|
return match[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||||
|
if (Number.isNaN(candidate.getTime())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = candidate.getFullYear()
|
||||||
|
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(candidate.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExpenseUploadHint(value) {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
return normalized || '支持上传 JPG、PNG、PDF,未上传也可先保存草稿'
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAttachmentDisplayName(value) {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
if (!normalized) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExpenseItemViewModel(source, index, requestModel) {
|
||||||
|
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
|
||||||
|
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
|
||||||
|
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
|
||||||
|
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
|
||||||
|
const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount)
|
||||||
|
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
|
||||||
|
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
|
||||||
|
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||||||
|
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
|
||||||
|
const riskText = String(source?.riskText || '').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`),
|
||||||
|
itemDate,
|
||||||
|
itemType,
|
||||||
|
itemReason,
|
||||||
|
itemLocation,
|
||||||
|
itemAmount,
|
||||||
|
invoiceId,
|
||||||
|
time: itemDate || '待补充',
|
||||||
|
dayLabel: requestModel?.detailVariant === 'travel' ? `第 ${index + 1} 项` : '业务发生项',
|
||||||
|
name: resolveExpenseTypeLabel(itemType),
|
||||||
|
category: resolveExpenseTypeLabel(itemType),
|
||||||
|
desc: itemReason || '待补充',
|
||||||
|
detail: resolveLocationDisplay(itemLocation, itemType),
|
||||||
|
amount: amountDisplay,
|
||||||
|
status: attachments.length ? '已识别' : '待补充',
|
||||||
|
tone: attachments.length ? 'ok' : 'bad',
|
||||||
|
attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '未上传',
|
||||||
|
attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||||||
|
attachmentTone: attachments.length ? 'ok' : 'missing',
|
||||||
|
attachments,
|
||||||
|
riskLabel: String(source?.riskLabel || '').trim() || '无',
|
||||||
|
riskText,
|
||||||
|
riskTone: String(source?.riskTone || '').trim() || 'low'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildExpenseItems(items, requestModel) {
|
||||||
|
return items.map((item, index) => buildExpenseItemViewModel(item, index, requestModel))
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildExpenseDraftIssues(item) {
|
function buildExpenseDraftIssues(item) {
|
||||||
const issues = []
|
const issues = []
|
||||||
|
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
||||||
|
|
||||||
if (!isValidIsoDate(item.itemDate)) {
|
if (!isValidIsoDate(item.itemDate)) {
|
||||||
issues.push('缺少日期')
|
issues.push('缺少日期')
|
||||||
@@ -101,7 +242,7 @@ function buildExpenseDraftIssues(item) {
|
|||||||
if (isPlaceholderValue(item.itemReason)) {
|
if (isPlaceholderValue(item.itemReason)) {
|
||||||
issues.push('缺少说明')
|
issues.push('缺少说明')
|
||||||
}
|
}
|
||||||
if (isPlaceholderValue(item.itemLocation)) {
|
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
|
||||||
issues.push('缺少地点')
|
issues.push('缺少地点')
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
||||||
@@ -116,6 +257,7 @@ function buildExpenseDraftIssues(item) {
|
|||||||
|
|
||||||
function buildDraftBlockingIssues(request, expenseItems) {
|
function buildDraftBlockingIssues(request, expenseItems) {
|
||||||
const issues = []
|
const issues = []
|
||||||
|
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
|
||||||
|
|
||||||
if (isPlaceholderValue(request.profileName)) {
|
if (isPlaceholderValue(request.profileName)) {
|
||||||
issues.push('申请人未完善')
|
issues.push('申请人未完善')
|
||||||
@@ -129,7 +271,7 @@ function buildDraftBlockingIssues(request, expenseItems) {
|
|||||||
if (isPlaceholderValue(request.reason)) {
|
if (isPlaceholderValue(request.reason)) {
|
||||||
issues.push('报销事由未完善')
|
issues.push('报销事由未完善')
|
||||||
}
|
}
|
||||||
if (isPlaceholderValue(request.location)) {
|
if (locationRequired && isPlaceholderValue(request.location)) {
|
||||||
issues.push('业务地点未完善')
|
issues.push('业务地点未完善')
|
||||||
}
|
}
|
||||||
if (isPlaceholderValue(request.occurredDisplay)) {
|
if (isPlaceholderValue(request.occurredDisplay)) {
|
||||||
@@ -151,6 +293,66 @@ function buildDraftBlockingIssues(request, expenseItems) {
|
|||||||
return [...new Set(issues)]
|
return [...new Set(issues)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapIssueToAdvice(issue) {
|
||||||
|
const text = String(issue || '').trim()
|
||||||
|
if (!text) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text === '费用明细不能为空') {
|
||||||
|
return '先新增至少 1 条费用明细,再补充金额、用途和附件。'
|
||||||
|
}
|
||||||
|
if (text === '申请人未完善') {
|
||||||
|
return '补充申请人信息,确保审批单据归属明确。'
|
||||||
|
}
|
||||||
|
if (text === '所属部门未完善') {
|
||||||
|
return '补充所属部门,便于财务和审批人识别成本归属。'
|
||||||
|
}
|
||||||
|
if (text === '报销类型未完善') {
|
||||||
|
return '选择报销类型,明确本次费用归类。'
|
||||||
|
}
|
||||||
|
if (text === '报销事由未完善') {
|
||||||
|
return '补充报销事由,说明本次费用用途。'
|
||||||
|
}
|
||||||
|
if (text === '业务地点未完善') {
|
||||||
|
return '补充业务地点,方便审核业务发生场景。'
|
||||||
|
}
|
||||||
|
if (text === '发生时间未完善') {
|
||||||
|
return '补充费用发生时间,确保单据时间完整。'
|
||||||
|
}
|
||||||
|
if (text === '报销金额未完善') {
|
||||||
|
return '补充报销金额,并与费用明细金额保持一致。'
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/)
|
||||||
|
if (!itemMatch) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, indexText, fieldText] = itemMatch
|
||||||
|
const labelPrefix = `完善第 ${indexText} 条费用明细`
|
||||||
|
if (fieldText === '缺少日期') {
|
||||||
|
return `${labelPrefix}的发生日期。`
|
||||||
|
}
|
||||||
|
if (fieldText === '缺少费用项目') {
|
||||||
|
return `${labelPrefix}的费用项目。`
|
||||||
|
}
|
||||||
|
if (fieldText === '缺少说明') {
|
||||||
|
return `${labelPrefix}的用途说明。`
|
||||||
|
}
|
||||||
|
if (fieldText === '缺少地点') {
|
||||||
|
return `${labelPrefix}的业务地点。`
|
||||||
|
}
|
||||||
|
if (fieldText === '缺少金额') {
|
||||||
|
return `${labelPrefix}的金额。`
|
||||||
|
}
|
||||||
|
if (fieldText === '缺少票据标识') {
|
||||||
|
return `为第 ${indexText} 条费用明细上传或关联票据附件。`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${labelPrefix}。`
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TravelRequestDetailView',
|
name: 'TravelRequestDetailView',
|
||||||
components: {
|
components: {
|
||||||
@@ -165,12 +367,24 @@ export default {
|
|||||||
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const expandedExpenseId = ref(null)
|
|
||||||
const editingExpenseId = ref('')
|
const editingExpenseId = ref('')
|
||||||
const savingExpenseId = ref('')
|
const savingExpenseId = ref('')
|
||||||
|
const creatingExpense = ref(false)
|
||||||
|
const uploadingExpenseId = ref('')
|
||||||
|
const deletingAttachmentId = ref('')
|
||||||
|
const deletingExpenseId = ref('')
|
||||||
|
const pendingUploadExpenseId = ref('')
|
||||||
const submitBusy = ref(false)
|
const submitBusy = ref(false)
|
||||||
const deleteBusy = ref(false)
|
const deleteBusy = ref(false)
|
||||||
const deleteDialogOpen = ref(false)
|
const deleteDialogOpen = ref(false)
|
||||||
|
const expenseUploadInput = ref(null)
|
||||||
|
const expenseAttachmentMeta = reactive({})
|
||||||
|
const attachmentPreviewOpen = ref(false)
|
||||||
|
const attachmentPreviewLoading = ref(false)
|
||||||
|
const attachmentPreviewError = ref('')
|
||||||
|
const attachmentPreviewUrl = ref('')
|
||||||
|
const attachmentPreviewName = ref('')
|
||||||
|
const attachmentPreviewMediaType = ref('')
|
||||||
const expenseEditor = reactive({
|
const expenseEditor = reactive({
|
||||||
itemDate: '',
|
itemDate: '',
|
||||||
itemType: 'other',
|
itemType: 'other',
|
||||||
@@ -217,7 +431,15 @@ export default {
|
|||||||
|
|
||||||
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
|
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
|
||||||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||||||
const actionBusy = computed(() => Boolean(savingExpenseId.value) || submitBusy.value || deleteBusy.value)
|
const actionBusy = computed(() =>
|
||||||
|
Boolean(savingExpenseId.value)
|
||||||
|
|| submitBusy.value
|
||||||
|
|| deleteBusy.value
|
||||||
|
|| creatingExpense.value
|
||||||
|
|| Boolean(uploadingExpenseId.value)
|
||||||
|
|| Boolean(deletingAttachmentId.value)
|
||||||
|
|| Boolean(deletingExpenseId.value)
|
||||||
|
)
|
||||||
|
|
||||||
const profile = computed(() => ({
|
const profile = computed(() => ({
|
||||||
name: request.value.profileName,
|
name: request.value.profileName,
|
||||||
@@ -229,22 +451,32 @@ export default {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
request,
|
request,
|
||||||
(nextRequest) => {
|
(nextRequest, previousRequest) => {
|
||||||
expenseItems.value =
|
expenseItems.value =
|
||||||
Array.isArray(nextRequest.expenseItems) && nextRequest.expenseItems.length
|
Array.isArray(nextRequest.expenseItems)
|
||||||
? nextRequest.expenseItems
|
? rebuildExpenseItems(nextRequest.expenseItems, nextRequest)
|
||||||
: buildFallbackExpenseItems(nextRequest)
|
: buildFallbackExpenseItems(nextRequest)
|
||||||
expandedExpenseId.value = null
|
if (nextRequest.claimId !== previousRequest?.claimId) {
|
||||||
|
Object.keys(expenseAttachmentMeta).forEach((key) => {
|
||||||
|
delete expenseAttachmentMeta[key]
|
||||||
|
})
|
||||||
|
closeAttachmentPreview()
|
||||||
|
}
|
||||||
|
pendingUploadExpenseId.value = ''
|
||||||
|
uploadingExpenseId.value = ''
|
||||||
|
deletingExpenseId.value = ''
|
||||||
editingExpenseId.value = ''
|
editingExpenseId.value = ''
|
||||||
|
void syncExpenseAttachmentMeta()
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
const heroStats = computed(() => [
|
const heroStats = computed(() => [
|
||||||
{
|
{
|
||||||
label: '金额',
|
label: '报销金额',
|
||||||
value: request.value.amountDisplay,
|
value: request.value.amountDisplay,
|
||||||
kind: 'text'
|
kind: 'text',
|
||||||
|
emphasis: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '当前节点',
|
label: '当前节点',
|
||||||
@@ -259,40 +491,15 @@ export default {
|
|||||||
kind: 'pill',
|
kind: 'pill',
|
||||||
className: 'approval-pill',
|
className: 'approval-pill',
|
||||||
tone: request.value.approvalTone
|
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 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 [
|
return [
|
||||||
...commonItems,
|
{ label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' },
|
||||||
{ 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.occurredDisplay, icon: 'mdi mdi-calendar-month-outline' },
|
||||||
{ label: '关联客户', value: request.value.relatedCustomer, icon: 'mdi mdi-domain' },
|
{ label: '费用明细', value: `${expenseItems.value.length} 条`, icon: 'mdi mdi-format-list-bulleted-square' },
|
||||||
{ label: '票据关联', value: request.value.attachmentSummary, icon: 'mdi mdi-paperclip' },
|
{ label: '申请时间', value: request.value.applyTime, icon: 'mdi mdi-timer-sand' }
|
||||||
{ label: '风险提示', value: request.value.riskSummary, icon: 'mdi mdi-shield-alert-outline' }
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -327,39 +534,198 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||||||
|
const hasExpenseRiskColumn = computed(() => expenseItems.value.some((item) => item.attachments.length))
|
||||||
|
const expenseTableColumnCount = computed(
|
||||||
|
() => 5 + (hasExpenseRiskColumn.value ? 1 : 0) + (isDraftRequest.value ? 1 : 0)
|
||||||
|
)
|
||||||
const expenseSummaryText = computed(
|
const expenseSummaryText = computed(
|
||||||
() => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。'
|
() => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。'
|
||||||
)
|
)
|
||||||
const detailNote = computed(
|
const detailNote = computed(
|
||||||
() =>
|
() =>
|
||||||
request.value.note
|
request.value.note
|
||||||
|| (isTravelRequest.value
|
|| '暂无附加说明。可在这里补充特殊背景、例外原因、补件计划或其他需要财务和审批人重点关注的信息。'
|
||||||
? '该差旅报销单尚未补充完整说明,请继续完善后再提交审批。'
|
|
||||||
: '该报销单尚未补充完整说明,请继续完善后再提交审批。')
|
|
||||||
)
|
)
|
||||||
const draftBlockingIssues = computed(() =>
|
const draftBlockingIssues = computed(() =>
|
||||||
isDraftRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
isDraftRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||||
)
|
)
|
||||||
const canSubmit = computed(() => isDraftRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
|
const canSubmit = computed(() => isDraftRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
|
||||||
const validationTone = computed(() => (canSubmit.value ? 'ready' : 'pending'))
|
const locationInputPlaceholder = computed(() => resolveLocationInputPlaceholder(expenseEditor.itemType))
|
||||||
const validationSummary = computed(() =>
|
|
||||||
canSubmit.value
|
|
||||||
? '当前草稿信息完整,可以提交审批。'
|
|
||||||
: '当前草稿仍有未完善字段,提交按钮会保持禁用。'
|
|
||||||
)
|
|
||||||
|
|
||||||
function toggleExpenseAttachments(id) {
|
function applyLocalExpenseItemPatch(itemId, patch) {
|
||||||
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
|
expenseItems.value = rebuildExpenseItems(
|
||||||
|
expenseItems.value.map((item) => (item.id === itemId ? { ...item, ...patch } : item)),
|
||||||
|
request.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAttachmentMeta(item) {
|
||||||
|
return expenseAttachmentMeta[item.id] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAttachmentDisplayName(item) {
|
||||||
|
const metadata = resolveAttachmentMeta(item)
|
||||||
|
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttachmentRiskNotice(attachment) {
|
||||||
|
const analysis = attachment?.analysis
|
||||||
|
const severity = String(analysis?.severity || '').trim()
|
||||||
|
|
||||||
|
if (!analysis || severity === 'pass') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const label =
|
||||||
|
String(analysis?.label || '').trim()
|
||||||
|
|| (severity === 'high' ? '高风险' : severity === 'medium' ? '中风险' : '低风险')
|
||||||
|
const summary = String(analysis?.summary || analysis?.headline || '').trim() || '附件存在待核对风险。'
|
||||||
|
return `${label}:${summary}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshExpenseAttachmentMeta(itemId) {
|
||||||
|
if (!request.value.claimId || !itemId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, itemId)
|
||||||
|
expenseAttachmentMeta[itemId] = payload
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPreviewAttachment(item) {
|
||||||
|
const metadata = resolveAttachmentMeta(item)
|
||||||
|
return Boolean(item.invoiceId && metadata?.previewable)
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeAttachmentPreviewUrl() {
|
||||||
|
if (attachmentPreviewUrl.value && attachmentPreviewUrl.value.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(attachmentPreviewUrl.value)
|
||||||
|
}
|
||||||
|
attachmentPreviewUrl.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAttachmentPreview() {
|
||||||
|
attachmentPreviewOpen.value = false
|
||||||
|
attachmentPreviewLoading.value = false
|
||||||
|
attachmentPreviewError.value = ''
|
||||||
|
attachmentPreviewName.value = ''
|
||||||
|
attachmentPreviewMediaType.value = ''
|
||||||
|
revokeAttachmentPreviewUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncExpenseAttachmentMeta() {
|
||||||
|
if (!request.value.claimId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = expenseItems.value
|
||||||
|
.filter((item) => item.invoiceId)
|
||||||
|
.map(async (item) => {
|
||||||
|
try {
|
||||||
|
const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, item.id)
|
||||||
|
expenseAttachmentMeta[item.id] = payload
|
||||||
|
} catch {
|
||||||
|
delete expenseAttachmentMeta[item.id]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.keys(expenseAttachmentMeta).forEach((itemId) => {
|
||||||
|
if (!expenseItems.value.some((item) => item.id === itemId && item.invoiceId)) {
|
||||||
|
delete expenseAttachmentMeta[itemId]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.allSettled(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveExpenseIssues(item) {
|
function resolveExpenseIssues(item) {
|
||||||
return buildExpenseDraftIssues(item)
|
return buildExpenseDraftIssues(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
function showExpenseRisk(item) {
|
function resolveExpenseRiskState(item) {
|
||||||
return Boolean(resolveExpenseIssues(item).length || item.riskText)
|
if (!item.invoiceId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadingExpenseId.value === item.id) {
|
||||||
|
return {
|
||||||
|
label: 'AI识别中',
|
||||||
|
tone: 'medium',
|
||||||
|
headline: 'AI提示:正在分析附件内容',
|
||||||
|
summary: '附件已上传,系统正在识别票据内容与风险点,请稍候。',
|
||||||
|
points: [],
|
||||||
|
suggestion: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = resolveAttachmentMeta(item)
|
||||||
|
const analysis = metadata?.analysis
|
||||||
|
if (analysis) {
|
||||||
|
return {
|
||||||
|
label: analysis.label || '已上传',
|
||||||
|
tone: analysis.severity === 'pass' ? 'pass' : analysis.severity || 'low',
|
||||||
|
headline: analysis.headline || 'AI提示',
|
||||||
|
summary: analysis.summary || '',
|
||||||
|
points: Array.isArray(analysis.points) ? analysis.points : [],
|
||||||
|
suggestion: analysis.suggestion || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: '已上传',
|
||||||
|
tone: 'low',
|
||||||
|
headline: 'AI提示:附件已上传',
|
||||||
|
summary: '附件已成功保存,当前可继续查看原图并人工核对票据内容。',
|
||||||
|
points: [],
|
||||||
|
suggestion: ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showExpenseRisk(item) {
|
||||||
|
return Boolean(resolveExpenseRiskState(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiAdvice = computed(() => {
|
||||||
|
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
||||||
|
const riskItems = expenseItems.value
|
||||||
|
.map((item, index) => {
|
||||||
|
const state = resolveExpenseRiskState(item)
|
||||||
|
if (!state || !['medium', 'high'].includes(state.tone)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const adviceText = String(state.suggestion || state.summary || '').trim()
|
||||||
|
const prefix = state.tone === 'high' ? '优先整改' : '继续核对'
|
||||||
|
return `第 ${index + 1} 条附件需${prefix}:${adviceText || '请根据系统提示补充或更换附件。'}`
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (!completionItems.length && !riskItems.length) {
|
||||||
|
return {
|
||||||
|
tone: 'ready',
|
||||||
|
badge: '可直接提交',
|
||||||
|
summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。',
|
||||||
|
items: [
|
||||||
|
'点击右下角“提交审批”进入流程。',
|
||||||
|
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
|
||||||
|
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasHighRisk = expenseItems.value.some((item) => resolveExpenseRiskState(item)?.tone === 'high')
|
||||||
|
|
||||||
|
return {
|
||||||
|
tone: hasHighRisk ? 'warning' : 'pending',
|
||||||
|
badge: hasHighRisk ? '优先整改' : '待补信息',
|
||||||
|
summary: completionItems.length
|
||||||
|
? '建议先补齐必填信息,再处理附件核验项,完成后即可提交审批。'
|
||||||
|
: '草稿信息已基本齐全,建议先处理附件风险后再提交审批。',
|
||||||
|
items: [...completionItems, ...riskItems]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function startExpenseEdit(item) {
|
function startExpenseEdit(item) {
|
||||||
if (!isDraftRequest.value || actionBusy.value) {
|
if (!isDraftRequest.value || actionBusy.value) {
|
||||||
return
|
return
|
||||||
@@ -369,10 +735,10 @@ export default {
|
|||||||
expenseEditor.itemDate = item.itemDate || ''
|
expenseEditor.itemDate = item.itemDate || ''
|
||||||
expenseEditor.itemType = item.itemType || 'other'
|
expenseEditor.itemType = item.itemType || 'other'
|
||||||
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
|
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
|
||||||
expenseEditor.itemLocation = item.itemLocation || (item.detail === '待补充' ? '' : item.detail)
|
expenseEditor.itemLocation =
|
||||||
|
item.itemLocation || (['待补充', '非必填'].includes(item.detail) ? '' : item.detail)
|
||||||
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
|
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
|
||||||
expenseEditor.invoiceId = item.invoiceId || ''
|
expenseEditor.invoiceId = item.invoiceId || ''
|
||||||
expandedExpenseId.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelExpenseEdit() {
|
function cancelExpenseEdit() {
|
||||||
@@ -389,7 +755,10 @@ export default {
|
|||||||
if (isPlaceholderValue(expenseEditor.itemReason)) {
|
if (isPlaceholderValue(expenseEditor.itemReason)) {
|
||||||
return '请输入费用说明。'
|
return '请输入费用说明。'
|
||||||
}
|
}
|
||||||
if (isPlaceholderValue(expenseEditor.itemLocation)) {
|
if (
|
||||||
|
isLocationRequiredExpenseType(expenseEditor.itemType)
|
||||||
|
&& isPlaceholderValue(expenseEditor.itemLocation)
|
||||||
|
) {
|
||||||
return '请输入业务地点。'
|
return '请输入业务地点。'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,11 +766,197 @@ export default {
|
|||||||
if (!Number.isFinite(amount) || amount <= 0) {
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
return '请输入大于 0 的费用金额。'
|
return '请输入大于 0 的费用金额。'
|
||||||
}
|
}
|
||||||
if (isPlaceholderValue(expenseEditor.invoiceId)) {
|
return ''
|
||||||
return '请输入票据标识或附件名称。'
|
}
|
||||||
|
|
||||||
|
async function handleAddExpenseItem() {
|
||||||
|
if (!isDraftRequest.value || actionBusy.value) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
if (!request.value.claimId) {
|
||||||
|
toast('当前草稿缺少 claimId,暂时无法新增费用明细。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creatingExpense.value = true
|
||||||
|
try {
|
||||||
|
const existingIds = new Set(expenseItems.value.map((item) => item.id))
|
||||||
|
const claim = await createExpenseClaimItem(request.value.claimId, {})
|
||||||
|
const createdItem = Array.isArray(claim?.items)
|
||||||
|
? claim.items.find((entry) => !existingIds.has(String(entry?.id || '')))
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!createdItem) {
|
||||||
|
throw new Error('新增费用明细失败,请稍后重试。')
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value)
|
||||||
|
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
|
||||||
|
creatingExpense.value = false
|
||||||
|
startExpenseEdit(nextItem)
|
||||||
|
toast('已新增一条费用明细,请继续填写。')
|
||||||
|
} catch (error) {
|
||||||
|
toast(error?.message || '新增费用明细失败,请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
creatingExpense.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerExpenseUpload(item) {
|
||||||
|
if (!isDraftRequest.value || actionBusy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.value.claimId) {
|
||||||
|
toast('当前草稿缺少 claimId,暂时无法上传附件。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingUploadExpenseId.value = item.id
|
||||||
|
if (expenseUploadInput.value) {
|
||||||
|
expenseUploadInput.value.value = ''
|
||||||
|
expenseUploadInput.value.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAttachmentPreview(item) {
|
||||||
|
if (!request.value.claimId || !canPreviewAttachment(item)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAttachmentPreview()
|
||||||
|
attachmentPreviewOpen.value = true
|
||||||
|
attachmentPreviewLoading.value = true
|
||||||
|
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
|
||||||
|
attachmentPreviewMediaType.value = String(resolveAttachmentMeta(item)?.media_type || '').trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await fetchExpenseClaimItemAttachment(request.value.claimId, item.id)
|
||||||
|
revokeAttachmentPreviewUrl()
|
||||||
|
attachmentPreviewUrl.value = URL.createObjectURL(blob)
|
||||||
|
attachmentPreviewMediaType.value = blob.type || attachmentPreviewMediaType.value
|
||||||
|
} catch (error) {
|
||||||
|
attachmentPreviewError.value = error?.message || '附件预览失败,请稍后重试。'
|
||||||
|
} finally {
|
||||||
|
attachmentPreviewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadExpenseFile(item, file) {
|
||||||
|
if (!item || !file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadingExpenseId.value = item.id
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
|
||||||
|
expenseAttachmentMeta[item.id] = payload?.attachment || null
|
||||||
|
applyLocalExpenseItemPatch(item.id, {
|
||||||
|
invoiceId: String(payload?.invoice_id || '').trim(),
|
||||||
|
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
|
||||||
|
})
|
||||||
|
if (editingExpenseId.value === item.id) {
|
||||||
|
expenseEditor.invoiceId = String(payload?.invoice_id || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('request-updated', { claimId: request.value.claimId })
|
||||||
|
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
|
||||||
|
toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`)
|
||||||
|
} catch (error) {
|
||||||
|
toast(error?.message || '附件上传失败,请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
uploadingExpenseId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeExpenseAttachment(item) {
|
||||||
|
if (!request.value.claimId || !item?.invoiceId || actionBusy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingAttachmentId.value = item.id
|
||||||
|
try {
|
||||||
|
const payload = await deleteExpenseClaimItemAttachment(request.value.claimId, item.id)
|
||||||
|
delete expenseAttachmentMeta[item.id]
|
||||||
|
applyLocalExpenseItemPatch(item.id, {
|
||||||
|
invoiceId: '',
|
||||||
|
attachmentHint: resolveExpenseUploadHint()
|
||||||
|
})
|
||||||
|
if (editingExpenseId.value === item.id) {
|
||||||
|
expenseEditor.invoiceId = ''
|
||||||
|
}
|
||||||
|
if (attachmentPreviewOpen.value) {
|
||||||
|
closeAttachmentPreview()
|
||||||
|
}
|
||||||
|
emit('request-updated', { claimId: request.value.claimId })
|
||||||
|
toast(payload?.message || '附件已删除。')
|
||||||
|
} catch (error) {
|
||||||
|
toast(error?.message || '附件删除失败,请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
deletingAttachmentId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExpenseFileChange(event) {
|
||||||
|
const target = event?.target
|
||||||
|
const file = target?.files?.[0]
|
||||||
|
const itemId = pendingUploadExpenseId.value
|
||||||
|
pendingUploadExpenseId.value = ''
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file || !itemId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = expenseItems.value.find((entry) => entry.id === itemId)
|
||||||
|
if (!item) {
|
||||||
|
toast('未找到对应的费用明细,请刷新后重试。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await uploadExpenseFile(item, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeExpenseItem(item) {
|
||||||
|
if (!request.value.claimId || !item?.id || actionBusy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletingExpenseId.value = item.id
|
||||||
|
try {
|
||||||
|
const payload = await deleteExpenseClaimItem(request.value.claimId, item.id)
|
||||||
|
delete expenseAttachmentMeta[item.id]
|
||||||
|
expenseItems.value = rebuildExpenseItems(
|
||||||
|
expenseItems.value.filter((entry) => entry.id !== item.id),
|
||||||
|
request.value
|
||||||
|
)
|
||||||
|
if (editingExpenseId.value === item.id) {
|
||||||
|
editingExpenseId.value = ''
|
||||||
|
expenseEditor.itemDate = ''
|
||||||
|
expenseEditor.itemType = 'other'
|
||||||
|
expenseEditor.itemReason = ''
|
||||||
|
expenseEditor.itemLocation = ''
|
||||||
|
expenseEditor.itemAmount = ''
|
||||||
|
expenseEditor.invoiceId = ''
|
||||||
|
}
|
||||||
|
if (pendingUploadExpenseId.value === item.id) {
|
||||||
|
pendingUploadExpenseId.value = ''
|
||||||
|
}
|
||||||
|
if (attachmentPreviewOpen.value) {
|
||||||
|
closeAttachmentPreview()
|
||||||
|
}
|
||||||
|
emit('request-updated', { claimId: request.value.claimId })
|
||||||
|
toast(payload?.message || '费用明细已删除。')
|
||||||
|
} catch (error) {
|
||||||
|
toast(error?.message || '费用明细删除失败,请稍后重试。')
|
||||||
|
} finally {
|
||||||
|
deletingExpenseId.value = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveExpenseEdit(item) {
|
async function saveExpenseEdit(item) {
|
||||||
@@ -418,16 +973,36 @@ export default {
|
|||||||
|
|
||||||
savingExpenseId.value = item.id
|
savingExpenseId.value = item.id
|
||||||
try {
|
try {
|
||||||
|
const nextInvoiceId = expenseEditor.invoiceId.trim()
|
||||||
await updateExpenseClaimItem(request.value.claimId, item.id, {
|
await updateExpenseClaimItem(request.value.claimId, item.id, {
|
||||||
item_date: expenseEditor.itemDate,
|
item_date: expenseEditor.itemDate,
|
||||||
item_type: expenseEditor.itemType,
|
item_type: expenseEditor.itemType,
|
||||||
item_reason: expenseEditor.itemReason.trim(),
|
item_reason: expenseEditor.itemReason.trim(),
|
||||||
item_location: expenseEditor.itemLocation.trim(),
|
item_location: expenseEditor.itemLocation.trim(),
|
||||||
item_amount: Number(expenseEditor.itemAmount),
|
item_amount: Number(expenseEditor.itemAmount),
|
||||||
invoice_id: expenseEditor.invoiceId.trim()
|
invoice_id: nextInvoiceId
|
||||||
})
|
})
|
||||||
|
applyLocalExpenseItemPatch(item.id, {
|
||||||
|
itemDate: expenseEditor.itemDate,
|
||||||
|
itemType: expenseEditor.itemType,
|
||||||
|
itemReason: expenseEditor.itemReason.trim(),
|
||||||
|
itemLocation: expenseEditor.itemLocation.trim(),
|
||||||
|
itemAmount: Number(expenseEditor.itemAmount),
|
||||||
|
invoiceId: nextInvoiceId
|
||||||
|
})
|
||||||
|
let riskNotice = ''
|
||||||
|
if (nextInvoiceId) {
|
||||||
|
try {
|
||||||
|
const attachment = await refreshExpenseAttachmentMeta(item.id)
|
||||||
|
riskNotice = buildAttachmentRiskNotice(attachment)
|
||||||
|
} catch {
|
||||||
|
delete expenseAttachmentMeta[item.id]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete expenseAttachmentMeta[item.id]
|
||||||
|
}
|
||||||
editingExpenseId.value = ''
|
editingExpenseId.value = ''
|
||||||
toast('费用明细已保存。')
|
toast(riskNotice || '费用明细已保存。')
|
||||||
emit('request-updated', { claimId: request.value.claimId })
|
emit('request-updated', { claimId: request.value.claimId })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error?.message || '费用明细保存失败,请稍后重试。')
|
toast(error?.message || '费用明细保存失败,请稍后重试。')
|
||||||
@@ -503,43 +1078,68 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
closeAttachmentPreview()
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
emit,
|
emit,
|
||||||
actionBusy,
|
actionBusy,
|
||||||
|
aiAdvice,
|
||||||
|
attachmentPreviewError,
|
||||||
|
attachmentPreviewLoading,
|
||||||
|
attachmentPreviewMediaType,
|
||||||
|
attachmentPreviewName,
|
||||||
|
attachmentPreviewOpen,
|
||||||
|
attachmentPreviewUrl,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
|
canPreviewAttachment,
|
||||||
closeDeleteDialog,
|
closeDeleteDialog,
|
||||||
|
closeAttachmentPreview,
|
||||||
confirmDeleteDraft,
|
confirmDeleteDraft,
|
||||||
currentProgressRingMotion,
|
currentProgressRingMotion,
|
||||||
deleteBusy,
|
deleteBusy,
|
||||||
deleteDialogOpen,
|
deleteDialogOpen,
|
||||||
|
deletingAttachmentId,
|
||||||
|
deletingExpenseId,
|
||||||
detailNote,
|
detailNote,
|
||||||
draftBlockingIssues,
|
draftBlockingIssues,
|
||||||
editingExpenseId,
|
editingExpenseId,
|
||||||
|
creatingExpense,
|
||||||
expenseEditor,
|
expenseEditor,
|
||||||
expenseItems,
|
expenseItems,
|
||||||
expenseSummaryText,
|
expenseSummaryText,
|
||||||
|
expenseTableColumnCount,
|
||||||
expenseTotal,
|
expenseTotal,
|
||||||
expandedExpenseId,
|
expenseUploadInput,
|
||||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||||
|
handleAddExpenseItem,
|
||||||
handleDeleteDraft,
|
handleDeleteDraft,
|
||||||
|
handleExpenseFileChange,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
hasExpenseRiskColumn,
|
||||||
heroStats,
|
heroStats,
|
||||||
heroSummaryItems,
|
heroSummaryItems,
|
||||||
isDraftRequest,
|
isDraftRequest,
|
||||||
isTravelRequest,
|
isTravelRequest,
|
||||||
|
locationInputPlaceholder,
|
||||||
openAiEntry,
|
openAiEntry,
|
||||||
|
openAttachmentPreview,
|
||||||
profile,
|
profile,
|
||||||
progressSteps,
|
progressSteps,
|
||||||
|
removeExpenseItem,
|
||||||
request,
|
request,
|
||||||
|
removeExpenseAttachment,
|
||||||
|
resolveAttachmentDisplayName,
|
||||||
|
resolveExpenseRiskState,
|
||||||
resolveExpenseIssues,
|
resolveExpenseIssues,
|
||||||
savingExpenseId,
|
savingExpenseId,
|
||||||
showExpenseRisk,
|
showExpenseRisk,
|
||||||
startExpenseEdit,
|
startExpenseEdit,
|
||||||
submitBusy,
|
submitBusy,
|
||||||
toggleExpenseAttachments,
|
triggerExpenseUpload,
|
||||||
uploadedExpenseCount,
|
uploadedExpenseCount,
|
||||||
validationSummary,
|
uploadingExpenseId,
|
||||||
validationTone,
|
|
||||||
cancelExpenseEdit,
|
cancelExpenseEdit,
|
||||||
saveExpenseEdit
|
saveExpenseEdit
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user