refactor(web): update view scripts
- 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
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
@@ -847,6 +848,9 @@ function buildReviewNote(status) {
|
||||
|
||||
export default {
|
||||
name: 'AuditView',
|
||||
components: {
|
||||
ConfirmDialog
|
||||
},
|
||||
emits: ['detail-open-change'],
|
||||
setup(_, { emit }) {
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { disableEmployee, fetchEmployeeMeta, fetchEmployees, updateEmployee } from '../../services/employees.js'
|
||||
|
||||
@@ -199,6 +200,9 @@ function buildEmployeeSummary(employees) {
|
||||
|
||||
export default {
|
||||
name: 'EmployeeManagementView',
|
||||
components: {
|
||||
ConfirmDialog
|
||||
},
|
||||
emits: ['overview-change'],
|
||||
setup(_, { emit }) {
|
||||
const { toast } = useToast()
|
||||
@@ -219,6 +223,7 @@ export default {
|
||||
const actionState = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const disableDialogOpen = ref(false)
|
||||
|
||||
const tabs = computed(() => buildStatusTabs(employees.value))
|
||||
const employeeSummary = computed(() => buildEmployeeSummary(employees.value))
|
||||
@@ -532,12 +537,24 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function disableEmployeeAccount() {
|
||||
function disableEmployeeAccount() {
|
||||
if (!selectedEmployee.value || disableActionDisabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.confirm(`确认停用 ${selectedEmployee.value.name} 的账号吗?`)) {
|
||||
disableDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeDisableDialog() {
|
||||
if (actionState.value === 'disable') {
|
||||
return
|
||||
}
|
||||
|
||||
disableDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmDisableEmployeeAccount() {
|
||||
if (!selectedEmployee.value || disableActionDisabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -545,6 +562,7 @@ export default {
|
||||
|
||||
try {
|
||||
const updated = await disableEmployee(selectedEmployee.value.id)
|
||||
disableDialogOpen.value = false
|
||||
selectedEmployee.value = updated
|
||||
await loadEmployees()
|
||||
toast('员工账号已停用。')
|
||||
@@ -642,7 +660,10 @@ export default {
|
||||
resetFilters,
|
||||
openEmployeeDetail,
|
||||
closeEmployeeDetail,
|
||||
closeDisableDialog,
|
||||
confirmDisableEmployeeAccount,
|
||||
saveEmployeeChanges,
|
||||
disableDialogOpen,
|
||||
disableEmployeeAccount,
|
||||
changePageSize,
|
||||
togglePageSizeOpen,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
deleteKnowledgeDocument,
|
||||
fetchKnowledgeDocument,
|
||||
@@ -73,10 +74,13 @@ function setBodyScrollLocked(isLocked) {
|
||||
bodyOverscrollBehaviorSnapshot = ''
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'PoliciesView',
|
||||
emits: ['summary-change'],
|
||||
setup(_, { emit }) {
|
||||
export default {
|
||||
name: 'PoliciesView',
|
||||
components: {
|
||||
ConfirmDialog
|
||||
},
|
||||
emits: ['summary-change'],
|
||||
setup(_, { emit }) {
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -91,9 +95,11 @@ export default {
|
||||
const pageSizes = [10, 20, 50]
|
||||
const loading = ref(false)
|
||||
const uploadInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
const deletingId = ref('')
|
||||
const previewLoading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const deletingId = ref('')
|
||||
const deleteDialogOpen = ref(false)
|
||||
const deleteTargetDocument = ref(null)
|
||||
const previewLoading = ref(false)
|
||||
const previewBlobUrl = ref('')
|
||||
const previewError = ref('')
|
||||
const onlyOfficeLoading = ref(false)
|
||||
@@ -365,19 +371,35 @@ export default {
|
||||
await uploadFiles(event.dataTransfer?.files)
|
||||
}
|
||||
|
||||
async function handleDelete(document) {
|
||||
if (!isAdmin.value || deletingId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(`确认删除文件“${document.name}”吗?`)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
|
||||
deletingId.value = document.id
|
||||
try {
|
||||
async function handleDelete(document) {
|
||||
if (!isAdmin.value || deletingId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleteTargetDocument.value = document
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
if (deletingId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleteDialogOpen.value = false
|
||||
deleteTargetDocument.value = null
|
||||
}
|
||||
|
||||
async function confirmDeleteDocument() {
|
||||
const document = deleteTargetDocument.value
|
||||
if (!document || !isAdmin.value || deletingId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deletingId.value = document.id
|
||||
try {
|
||||
await deleteKnowledgeDocument(document.id)
|
||||
deleteDialogOpen.value = false
|
||||
deleteTargetDocument.value = null
|
||||
if (selectedDocument.value?.id === document.id) {
|
||||
closePreview()
|
||||
}
|
||||
@@ -459,11 +481,15 @@ export default {
|
||||
activeFolder,
|
||||
activePreviewPage,
|
||||
changePageSize,
|
||||
closePreview,
|
||||
excelPreviewTable,
|
||||
currentPage,
|
||||
currentPreviewPageIndex,
|
||||
deletingId,
|
||||
closePreview,
|
||||
closeDeleteDialog,
|
||||
confirmDeleteDocument,
|
||||
excelPreviewTable,
|
||||
currentPage,
|
||||
currentPreviewPageIndex,
|
||||
deleteDialogOpen,
|
||||
deleteTargetDocument,
|
||||
deletingId,
|
||||
documentSearch,
|
||||
filteredFolders,
|
||||
handleDelete,
|
||||
|
||||
@@ -2,16 +2,25 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
|
||||
function extractRowDate(value) {
|
||||
const matched = String(value || '').match(/\d{4}-\d{2}-\d{2}/)
|
||||
return matched ? matched[0] : ''
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'RequestsView',
|
||||
props: {
|
||||
filteredRequests: { type: Array, required: true }
|
||||
filteredRequests: { type: Array, required: true },
|
||||
hasData: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' }
|
||||
},
|
||||
emits: ['ask', 'approve', 'reject', 'create-request'],
|
||||
emits: ['ask', 'approve', 'reject', 'create-request', 'reload'],
|
||||
setup(props, { emit }) {
|
||||
const activeTab = ref('全部')
|
||||
const tabs = ['全部', '待提交', '审批中', '待出行', '已完成']
|
||||
const filters = ['报销状态', '出差城市', '费用类型']
|
||||
const tabs = ['全部', '草稿', '审批中', '待补充', '已完成']
|
||||
const filters = ['报销状态', '报销类型', '所属主体']
|
||||
const listKeyword = ref('')
|
||||
|
||||
const datePopover = ref(false)
|
||||
const rangeStart = ref('')
|
||||
@@ -55,38 +64,65 @@ export default {
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
if (activeTab.value === '全部') {
|
||||
return rows.value
|
||||
}
|
||||
const keyword = listKeyword.value.trim().toLowerCase()
|
||||
|
||||
if (activeTab.value === '待提交') {
|
||||
return rows.value.filter((row) => row.approval === '待提交')
|
||||
}
|
||||
return rows.value.filter((row) => {
|
||||
const matchesKeyword =
|
||||
!keyword
|
||||
|| [
|
||||
row.id,
|
||||
row.documentNo,
|
||||
row.typeLabel,
|
||||
row.reason,
|
||||
row.sceneTarget,
|
||||
row.relatedCustomer,
|
||||
row.riskSummary
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
.toLowerCase()
|
||||
.includes(keyword)
|
||||
|
||||
if (activeTab.value === '审批中') {
|
||||
return rows.value.filter((row) => row.approval === '审批中')
|
||||
}
|
||||
const applyDate = extractRowDate(row.applyTime)
|
||||
const matchesDateRange =
|
||||
!appliedStart.value
|
||||
|| !appliedEnd.value
|
||||
|| (applyDate && applyDate >= appliedStart.value && applyDate <= appliedEnd.value)
|
||||
|
||||
if (activeTab.value === '待出行') {
|
||||
return rows.value.filter((row) => row.travel.includes('待'))
|
||||
}
|
||||
const matchesTab =
|
||||
activeTab.value === '全部'
|
||||
|| (activeTab.value === '草稿' && row.approvalKey === 'draft')
|
||||
|| (activeTab.value === '审批中' && row.approvalKey === 'in_progress')
|
||||
|| (activeTab.value === '待补充' && row.approvalKey === 'supplement')
|
||||
|| (activeTab.value === '已完成' && row.approvalKey === 'completed')
|
||||
|
||||
if (activeTab.value === '已完成') {
|
||||
return rows.value.filter((row) => row.approval === '已完成')
|
||||
}
|
||||
|
||||
return rows.value
|
||||
return matchesKeyword && matchesDateRange && matchesTab
|
||||
})
|
||||
})
|
||||
|
||||
const totalCount = computed(() => filteredRows.value.length)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredRows.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
const showTable = computed(() => !props.loading && !props.error && visibleRows.value.length > 0)
|
||||
const showEmpty = computed(() => !props.loading && !props.error && visibleRows.value.length === 0)
|
||||
const emptyState = computed(() => {
|
||||
if (!props.hasData) {
|
||||
return {
|
||||
title: '暂无真实报销单据',
|
||||
desc: '数据库里还没有可见的个人报销数据。保存草稿或提交报销后,会显示在这里。'
|
||||
}
|
||||
}
|
||||
|
||||
watch([activeTab, rows], () => {
|
||||
return {
|
||||
title: '没有匹配结果',
|
||||
desc: '当前筛选条件下没有可展示的报销单据。'
|
||||
}
|
||||
})
|
||||
|
||||
watch([activeTab, rows, listKeyword, appliedStart, appliedEnd], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
|
||||
@@ -95,6 +131,7 @@ export default {
|
||||
activeTab,
|
||||
tabs,
|
||||
filters,
|
||||
listKeyword,
|
||||
datePopover,
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
@@ -111,7 +148,10 @@ export default {
|
||||
filteredRows,
|
||||
totalCount,
|
||||
totalPages,
|
||||
visibleRows
|
||||
visibleRows,
|
||||
showTable,
|
||||
showEmpty,
|
||||
emptyState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { runOrchestrator } from '../../services/orchestrator.js'
|
||||
@@ -47,25 +48,29 @@ const EXPENSE_TYPE_LABELS = {
|
||||
meal: '伙食费',
|
||||
meeting: '会务费',
|
||||
entertainment: '业务招待费',
|
||||
office: '办公费',
|
||||
training: '培训费',
|
||||
communication: '通讯费',
|
||||
welfare: '福利费',
|
||||
other: '其他费用'
|
||||
}
|
||||
|
||||
const REVIEW_SLOT_CONFIG = {
|
||||
expense_type: {
|
||||
title: '报销类型',
|
||||
hint: '请选择本次费用类型',
|
||||
title: '报销分类',
|
||||
hint: '请选择本次报销分类',
|
||||
status: '待确认',
|
||||
icon: 'mdi mdi-shape-outline'
|
||||
},
|
||||
customer_name: {
|
||||
title: '客户单位名称',
|
||||
title: '关联客户',
|
||||
hint: '请补充客户单位全称',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-domain'
|
||||
},
|
||||
time_range: {
|
||||
title: '业务发生时间',
|
||||
hint: '请确认费用发生日期',
|
||||
title: '发生时间',
|
||||
hint: '请按 YYYY-MM-DD 补充业务发生日期',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-calendar-month-outline'
|
||||
},
|
||||
@@ -82,32 +87,44 @@ const REVIEW_SLOT_CONFIG = {
|
||||
icon: 'mdi mdi-storefront-outline'
|
||||
},
|
||||
amount: {
|
||||
title: '报销金额',
|
||||
title: '金额',
|
||||
hint: '请补充本次费用金额',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-cash'
|
||||
},
|
||||
reason: {
|
||||
title: '报销事由',
|
||||
hint: '请补充本次费用背景或用途',
|
||||
title: '场景 / 事由',
|
||||
hint: '请补充本次费用场景或事由',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-text-box-outline'
|
||||
},
|
||||
participants: {
|
||||
title: '同行人员信息',
|
||||
title: '同行人员',
|
||||
hint: '请至少填写 1 名同行人员',
|
||||
status: '待补充',
|
||||
icon: 'mdi mdi-account-group-outline'
|
||||
},
|
||||
attachments: {
|
||||
title: '票据附件',
|
||||
title: '票据状态',
|
||||
hint: '请上传发票/收据等票据附件',
|
||||
status: '未上传',
|
||||
icon: 'mdi mdi-paperclip'
|
||||
}
|
||||
}
|
||||
|
||||
const REVIEW_FALLBACK_GROUP_CODES = ['other', 'travel', 'transport', 'hotel', 'meal', 'entertainment']
|
||||
const REVIEW_FALLBACK_GROUP_CODES = [
|
||||
'other',
|
||||
'travel',
|
||||
'transport',
|
||||
'hotel',
|
||||
'meal',
|
||||
'meeting',
|
||||
'entertainment',
|
||||
'office',
|
||||
'training',
|
||||
'communication',
|
||||
'welfare'
|
||||
]
|
||||
|
||||
const REVIEW_CATEGORY_PRESET_OPTIONS = [
|
||||
{ key: 'travel', label: '差旅费' },
|
||||
@@ -128,6 +145,19 @@ const REVIEW_OTHER_CATEGORY_OPTIONS = [
|
||||
]
|
||||
|
||||
const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', '其他场景']
|
||||
const DATE_INPUT_FORMAT = 'YYYY-MM-DD'
|
||||
const CATEGORY_CONFIDENCE_KEYWORDS = {
|
||||
travel: [/出差|差旅|行程|机票|火车|高铁|航班/],
|
||||
hotel: [/住宿|酒店|宾馆|民宿/],
|
||||
transport: [/交通|打车|网约车|出租车|车费|地铁|公交|停车|过路费/],
|
||||
meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
|
||||
meeting: [/会务|会议|论坛|展会|参会|会场/],
|
||||
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
|
||||
office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/],
|
||||
training: [/培训|授课|讲师|课程|签到|讲义/],
|
||||
communication: [/通讯|电话|流量|话费|宽带|网络/],
|
||||
welfare: [/福利|体检|团建|节日|慰问|关怀/]
|
||||
}
|
||||
|
||||
let messageSeed = 0
|
||||
|
||||
@@ -179,7 +209,9 @@ function sanitizeRequest(request) {
|
||||
|
||||
const normalized = {
|
||||
id: String(request.id || '').trim(),
|
||||
typeLabel: String(request.typeLabel || request.category || '').trim(),
|
||||
reason: String(request.reason || request.title || '').trim(),
|
||||
entity: String(request.entity || '').trim(),
|
||||
city: String(request.city || request.location || '').trim(),
|
||||
period: String(request.period || '').trim(),
|
||||
applyTime: String(request.applyTime || request.occurredAt || '').trim(),
|
||||
@@ -424,6 +456,9 @@ function createEmptyInlineReviewState() {
|
||||
scene_label: '',
|
||||
reason_value: '',
|
||||
customer_name: '',
|
||||
location: '',
|
||||
merchant_name: '',
|
||||
participants: '',
|
||||
attachment_names: '',
|
||||
attachment_count: 0,
|
||||
expense_type: ''
|
||||
@@ -444,6 +479,71 @@ function buildClientTimeContext() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDraftApplyTime(date = new Date()) {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function buildDraftSavedPayload({
|
||||
draftPayload,
|
||||
reviewPayload,
|
||||
inlineState,
|
||||
linkedRequest,
|
||||
currentUser
|
||||
}) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
const riskItems = buildReviewRiskItems(reviewPayload)
|
||||
const missingItems = resolveReviewMissingSlotCards(reviewPayload)
|
||||
const typeCode = resolveExpenseTypeCode(inlineState?.expense_type)
|
||||
const amountNumber = parseAmountNumber(inlineState?.amount)
|
||||
const location = String(inlineState?.location || linkedRequest?.city || '').trim()
|
||||
const customerName = String(inlineState?.customer_name || '').trim()
|
||||
const typeLabel = String(inlineState?.expense_type || linkedRequest?.typeLabel || resolveExpenseTypeLabel(typeCode)).trim()
|
||||
const title =
|
||||
String(inlineState?.reason_value || '').trim()
|
||||
|| String(inlineState?.scene_label || '').trim()
|
||||
|| String(draftPayload?.title || '').trim()
|
||||
|| `${typeLabel}报销草稿`
|
||||
const sceneLabel = String(inlineState?.scene_label || summarizeReviewScene(title, typeLabel)).trim() || typeLabel
|
||||
const attachmentSummary = documents.length
|
||||
? `${documents.length} 条识别票据 / ${documents.length} 份材料`
|
||||
: String(inlineState?.attachment_names || '').trim()
|
||||
? '1 条识别票据 / 1 份材料'
|
||||
: '待上传票据'
|
||||
|
||||
return {
|
||||
claimId: String(draftPayload?.claim_id || '').trim(),
|
||||
claimNo: String(draftPayload?.claim_no || '').trim(),
|
||||
person: String(currentUser?.name || '').trim() || '当前用户',
|
||||
dept: String(currentUser?.role || '').trim() || '待补充部门',
|
||||
entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.',
|
||||
typeCode,
|
||||
typeLabel,
|
||||
detailVariant: typeCode === 'travel' ? 'travel' : 'general',
|
||||
title,
|
||||
sceneLabel,
|
||||
sceneTarget: location || customerName || '待补充',
|
||||
location,
|
||||
relatedCustomer: customerName,
|
||||
occurredDisplay: String(inlineState?.occurred_date || '').trim() || '待补充',
|
||||
applyTime: formatDraftApplyTime(),
|
||||
amount: amountNumber === null ? 0 : amountNumber,
|
||||
secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态',
|
||||
secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据',
|
||||
secondaryStatusTone: documents.length ? 'warning' : 'neutral',
|
||||
riskSummary: riskItems[0] || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
|
||||
attachmentSummary,
|
||||
expenseTableSummary: documents.length
|
||||
? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认`
|
||||
: '当前尚未上传票据,请在报销页继续补充附件',
|
||||
note: '该草稿由 AI 工作台根据当前识别结果生成,可在个人报销页面继续补充明细、票据与说明。'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReviewRecognizedSlotCards(reviewPayload) {
|
||||
return Array.isArray(reviewPayload?.slot_cards)
|
||||
? reviewPayload.slot_cards.filter((item) => item.status !== 'missing')
|
||||
@@ -495,16 +595,74 @@ function resolveExpenseTypeCode(value) {
|
||||
return matched?.[0] || 'other'
|
||||
}
|
||||
|
||||
function formatAmountDisplay(value) {
|
||||
function isValidIsoDateString(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
const match = normalized.match(/^(\d+(?:\.\d+)?)元$/)
|
||||
if (!match) return normalized
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const amount = Number(match[1])
|
||||
if (!Number.isFinite(amount)) return normalized
|
||||
const [yearText, monthText, dayText] = normalized.split('-')
|
||||
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 parseAmountNumber(value) {
|
||||
const normalized = String(value || '')
|
||||
.replace(/[,,\s]/g, '')
|
||||
.replace(/[¥¥]/g, '')
|
||||
.replace(/元/g, '')
|
||||
.trim()
|
||||
|
||||
if (!normalized || !/^\d+(?:\.\d+)?$/.test(normalized)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const amount = Number(normalized)
|
||||
return Number.isFinite(amount) ? amount : null
|
||||
}
|
||||
|
||||
function normalizeAmountValue(value) {
|
||||
const amount = parseAmountNumber(value)
|
||||
if (amount === null) {
|
||||
return ''
|
||||
}
|
||||
return Number.isInteger(amount) ? `${amount}元` : `${amount.toFixed(2).replace(/\.?0+$/, '')}元`
|
||||
}
|
||||
|
||||
function extractAmountInputValue(value) {
|
||||
const amount = parseAmountNumber(value)
|
||||
if (amount === null) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
return Number.isInteger(amount) ? String(amount) : amount.toFixed(2).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
function formatAmountDisplay(value) {
|
||||
const amount = parseAmountNumber(value)
|
||||
if (amount === null) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
|
||||
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function buildReviewHeadline(reviewPayload, draftPayload) {
|
||||
const claimNo = String(draftPayload?.claim_no || '').trim()
|
||||
if (claimNo) {
|
||||
@@ -539,13 +697,13 @@ function buildReviewStateTone(reviewPayload, draftPayload) {
|
||||
|
||||
function buildReviewAlertLabel(slotKey, expenseTypeLabel = '') {
|
||||
if (slotKey === 'customer_name') {
|
||||
return expenseTypeLabel === '业务招待费' ? '业务招待费需补充客户单位名称' : '缺少客户单位名称'
|
||||
return expenseTypeLabel === '业务招待费' ? '业务招待费需补充关联客户' : '缺少关联客户'
|
||||
}
|
||||
if (slotKey === 'participants') return '缺少同行人员'
|
||||
if (slotKey === 'attachments') return '票据附件未上传'
|
||||
if (slotKey === 'amount') return '报销金额待确认'
|
||||
if (slotKey === 'time_range') return '业务发生时间待确认'
|
||||
if (slotKey === 'reason') return '事由说明待补充'
|
||||
if (slotKey === 'attachments') return '票据状态待补充'
|
||||
if (slotKey === 'amount') return '金额待确认'
|
||||
if (slotKey === 'time_range') return '发生时间待确认'
|
||||
if (slotKey === 'reason') return '场景 / 事由待补充'
|
||||
if (slotKey === 'expense_type') return '报销类型待确认'
|
||||
if (slotKey === 'location') return '业务地点待补充'
|
||||
if (slotKey === 'merchant_name') return '酒店/商户待补充'
|
||||
@@ -709,12 +867,21 @@ function buildInlineReviewState(reviewPayload) {
|
||||
occurred_date: String(
|
||||
editFieldMap.occurred_date?.value || slotMap.time_range?.normalized_value || slotMap.time_range?.value || ''
|
||||
).trim(),
|
||||
amount: String(
|
||||
editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || ''
|
||||
).trim(),
|
||||
amount: normalizeAmountValue(
|
||||
String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim()
|
||||
),
|
||||
scene_label: summarizeReviewScene(reasonValue, expenseType),
|
||||
reason_value: reasonValue,
|
||||
customer_name: String(editFieldMap.customer_name?.value || slotMap.customer_name?.value || '').trim(),
|
||||
location: String(
|
||||
editFieldMap.business_location?.value ||
|
||||
editFieldMap.location?.value ||
|
||||
slotMap.location?.normalized_value ||
|
||||
slotMap.location?.value ||
|
||||
''
|
||||
).trim(),
|
||||
merchant_name: String(editFieldMap.merchant_name?.value || slotMap.merchant_name?.value || '').trim(),
|
||||
participants: String(editFieldMap.participants?.value || slotMap.participants?.value || '').trim(),
|
||||
attachment_names: attachmentNames,
|
||||
attachment_count: attachmentCount,
|
||||
expense_type: expenseType
|
||||
@@ -727,78 +894,213 @@ function buildReviewAttachmentStatus(reviewPayload) {
|
||||
return documents.length === 1 ? '已上传 1 份' : `已上传 ${documents.length} 份`
|
||||
}
|
||||
|
||||
function shouldShowReviewFactCard(reviewPayload, slotKey, value = '') {
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const slot = slotMap[slotKey]
|
||||
return Boolean(String(value || slot?.normalized_value || slot?.value || '').trim()) || slot?.status === 'missing'
|
||||
}
|
||||
|
||||
function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
const attachmentStatus =
|
||||
inlineState.attachment_count > 0
|
||||
? `待保存 ${inlineState.attachment_count} 份`
|
||||
: buildReviewAttachmentStatus(reviewPayload)
|
||||
|
||||
return [
|
||||
const cards = [
|
||||
{
|
||||
key: 'occurred_date',
|
||||
label: '发生时间',
|
||||
value: String(inlineState.occurred_date || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-calendar-month-outline',
|
||||
editor: 'date'
|
||||
editor: 'date',
|
||||
modelKey: 'occurred_date',
|
||||
placeholder: `例如 ${DATE_INPUT_FORMAT}`
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '金额',
|
||||
value: formatAmountDisplay(inlineState.amount) || '待补充',
|
||||
icon: 'mdi mdi-cash',
|
||||
editor: 'text'
|
||||
editor: 'amount',
|
||||
modelKey: 'amount',
|
||||
placeholder: '例如 200.00'
|
||||
},
|
||||
{
|
||||
key: 'scene',
|
||||
label: '场景',
|
||||
label: '场景 / 事由',
|
||||
value: String(inlineState.scene_label || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-silverware-fork-knife',
|
||||
editor: 'select'
|
||||
editor: 'select',
|
||||
modelKey: 'scene_label',
|
||||
placeholder: '请选择场景'
|
||||
},
|
||||
{
|
||||
key: 'customer_name',
|
||||
label: '关联客户',
|
||||
value: String(inlineState.customer_name || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-domain',
|
||||
editor: 'text'
|
||||
editor: 'text',
|
||||
modelKey: 'customer_name',
|
||||
placeholder: '请输入客户名称'
|
||||
},
|
||||
{
|
||||
key: 'attachments',
|
||||
label: '票据状态',
|
||||
value: attachmentStatus,
|
||||
icon: 'mdi mdi-file-document-outline',
|
||||
editor: 'upload'
|
||||
editor: 'upload',
|
||||
modelKey: 'attachment_names',
|
||||
placeholder: ''
|
||||
}
|
||||
]
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
|
||||
cards.splice(4, 0, {
|
||||
key: 'location',
|
||||
label: '业务地点',
|
||||
value: String(inlineState.location || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-map-marker-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'location',
|
||||
placeholder: '请输入业务地点'
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'merchant_name', inlineState.merchant_name)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'merchant_name',
|
||||
label: '酒店/商户',
|
||||
value: String(inlineState.merchant_name || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-storefront-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'merchant_name',
|
||||
placeholder: '请输入酒店或商户名称'
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowReviewFactCard(reviewPayload, 'participants', inlineState.participants)) {
|
||||
cards.splice(cards.length - 1, 0, {
|
||||
key: 'participants',
|
||||
label: '同行人员',
|
||||
value: String(inlineState.participants || '').trim() || '待补充',
|
||||
icon: 'mdi mdi-account-group-outline',
|
||||
editor: 'text',
|
||||
modelKey: 'participants',
|
||||
placeholder: '例如 客户 2 人,我方 1 人'
|
||||
})
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
|
||||
function buildReviewCategoryOptions(selectedLabel = '') {
|
||||
function buildReviewEvidenceText(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
|
||||
return [
|
||||
String(inlineState.reason_value || '').trim(),
|
||||
String(inlineState.scene_label || '').trim(),
|
||||
String(slotMap.reason?.value || slotMap.reason?.raw_value || '').trim(),
|
||||
...documents.map((item) =>
|
||||
[item.scene_label, item.summary, item.filename, ...(Array.isArray(item.warnings) ? item.warnings : [])]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function resolveReviewCategoryTextScore(text, categoryCode) {
|
||||
const patterns = CATEGORY_CONFIDENCE_KEYWORDS[categoryCode]
|
||||
if (!patterns?.length || !text) {
|
||||
return 0
|
||||
}
|
||||
return patterns.some((pattern) => pattern.test(text))
|
||||
? {
|
||||
travel: 0.84,
|
||||
hotel: 0.82,
|
||||
transport: 0.8,
|
||||
meal: 0.76,
|
||||
meeting: 0.78,
|
||||
entertainment: 0.88,
|
||||
office: 0.74,
|
||||
training: 0.77,
|
||||
communication: 0.7,
|
||||
welfare: 0.72
|
||||
}[categoryCode] || 0
|
||||
: 0
|
||||
}
|
||||
|
||||
function resolveReviewCategoryDocumentScore(reviewPayload, categoryCode) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
const matchedScores = documents
|
||||
.filter((item) => resolveExpenseTypeCode(item?.suggested_expense_type) === categoryCode)
|
||||
.map((item) => Number(item?.avg_score || 0))
|
||||
.filter((score) => Number.isFinite(score) && score > 0)
|
||||
|
||||
if (!matchedScores.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return matchedScores.reduce((sum, score) => sum + score, 0) / matchedScores.length
|
||||
}
|
||||
|
||||
function resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) {
|
||||
const normalizedLabel = String(selectedLabel || '').trim()
|
||||
if (!normalizedLabel) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const selectedCode = resolveExpenseTypeCode(normalizedLabel)
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const expenseSlot = slotMap.expense_type
|
||||
const recognizedCode = resolveExpenseTypeCode(expenseSlot?.normalized_value || expenseSlot?.value || '')
|
||||
let score = 0
|
||||
|
||||
if (recognizedCode === selectedCode) {
|
||||
score = Math.max(score, Number(expenseSlot?.confidence || 0))
|
||||
}
|
||||
|
||||
score = Math.max(score, resolveReviewCategoryDocumentScore(reviewPayload, selectedCode))
|
||||
score = Math.max(score, resolveReviewCategoryTextScore(buildReviewEvidenceText(reviewPayload, inlineState), selectedCode))
|
||||
|
||||
if (!score && normalizedLabel) {
|
||||
score = selectedCode === 'other' ? 0.52 : 0.58
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(0.98, Number(score.toFixed(2))))
|
||||
}
|
||||
|
||||
function buildReviewCategoryOptions(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) {
|
||||
const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label)
|
||||
return REVIEW_CATEGORY_PRESET_OPTIONS.map((item, index) => ({
|
||||
...item,
|
||||
active: item.is_other ? Boolean(selectedLabel) && !presetLabels.includes(selectedLabel) : item.label === selectedLabel,
|
||||
caption: index === 0 ? '常用' : index < 5 ? '常用' : '更多'
|
||||
confidenceLabel: item.is_other
|
||||
? formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))
|
||||
: formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState)),
|
||||
caption: item.is_other
|
||||
? selectedLabel && !presetLabels.includes(selectedLabel)
|
||||
? `${selectedLabel} · ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))}`
|
||||
: '点击选择更多类型'
|
||||
: `置信度 ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState))}`,
|
||||
groupLabel: index === 0 ? '常用' : index < 5 ? '常用' : '更多'
|
||||
}))
|
||||
}
|
||||
|
||||
function buildReviewPanelConfidence(reviewPayload) {
|
||||
const recognized = resolveReviewRecognizedSlotCards(reviewPayload).filter((item) =>
|
||||
['expense_type', 'time_range', 'amount', 'customer_name', 'attachments'].includes(item.key)
|
||||
function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInlineReviewState()) {
|
||||
return formatConfidenceLabel(
|
||||
resolveReviewCategoryConfidenceScore(reviewPayload, inlineState.expense_type, inlineState)
|
||||
)
|
||||
if (!recognized.length) return '0%'
|
||||
const average = recognized.reduce((sum, item) => sum + Number(item.confidence || 0), 0) / recognized.length
|
||||
return formatConfidenceLabel(average)
|
||||
}
|
||||
|
||||
function buildReviewRiskScore(reviewPayload) {
|
||||
const missingCount = resolveReviewMissingSlotCards(reviewPayload).length
|
||||
const riskPenalty = resolveReviewRiskBriefs(reviewPayload).reduce((sum, item) => {
|
||||
if (item.level === 'high') return sum + 10
|
||||
if (item.level === 'warning') return sum + 6
|
||||
return sum + 3
|
||||
}, 0)
|
||||
const score = 92 - missingCount * 9 - riskPenalty
|
||||
return Math.max(28, Math.min(98, score))
|
||||
const score = Number(reviewPayload?.risk_score)
|
||||
if (!Number.isFinite(score) || score <= 0) {
|
||||
return null
|
||||
}
|
||||
return Math.max(0, Math.min(100, Math.round(score)))
|
||||
}
|
||||
|
||||
function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
|
||||
@@ -826,30 +1128,17 @@ function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
|
||||
}
|
||||
|
||||
function buildReviewRiskSummary(reviewPayload) {
|
||||
if (resolveReviewMissingSlotCards(reviewPayload).length) {
|
||||
return '存在一定合规风险,请尽快补充完整信息以降低风险。'
|
||||
}
|
||||
if (resolveReviewRiskBriefs(reviewPayload).length) {
|
||||
return '当前识别结果可继续处理,但提交前仍建议核对以下提醒。'
|
||||
return '当前识别到了合规提醒,提交前建议逐项核对。'
|
||||
}
|
||||
return '当前未发现明显阻断项,确认无误后可以继续下一步。'
|
||||
return '当前版本暂未生成风险评分结果。'
|
||||
}
|
||||
|
||||
function buildReviewRiskItems(reviewPayload) {
|
||||
const slotMap = buildReviewSlotMap(reviewPayload)
|
||||
const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim()
|
||||
const items = []
|
||||
|
||||
for (const slot of resolveReviewMissingSlotCards(reviewPayload)) {
|
||||
items.push(buildMissingRiskLine(slot.key, expenseTypeLabel))
|
||||
}
|
||||
|
||||
for (const brief of resolveReviewRiskBriefs(reviewPayload)) {
|
||||
if (items.includes(brief.content)) continue
|
||||
items.push(brief.content)
|
||||
}
|
||||
|
||||
return items.slice(0, 4)
|
||||
return resolveReviewRiskBriefs(reviewPayload)
|
||||
.map((brief) => String(brief?.content || '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
}
|
||||
|
||||
function normalizeInlineReviewComparableState(state) {
|
||||
@@ -860,6 +1149,9 @@ function normalizeInlineReviewComparableState(state) {
|
||||
scene_label: String(source.scene_label || '').trim(),
|
||||
reason_value: String(source.reason_value || '').trim(),
|
||||
customer_name: String(source.customer_name || '').trim(),
|
||||
location: String(source.location || '').trim(),
|
||||
merchant_name: String(source.merchant_name || '').trim(),
|
||||
participants: String(source.participants || '').trim(),
|
||||
attachment_names: String(source.attachment_names || '').trim(),
|
||||
expense_type: String(source.expense_type || '').trim()
|
||||
}
|
||||
@@ -882,6 +1174,15 @@ function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = [])
|
||||
if (base.customer_name !== next.customer_name) {
|
||||
lines.push(`关联客户 ${next.customer_name || '待补充'}`)
|
||||
}
|
||||
if (base.location !== next.location) {
|
||||
lines.push(`业务地点 ${next.location || '待补充'}`)
|
||||
}
|
||||
if (base.merchant_name !== next.merchant_name) {
|
||||
lines.push(`酒店/商户 ${next.merchant_name || '待补充'}`)
|
||||
}
|
||||
if (base.participants !== next.participants) {
|
||||
lines.push(`同行人员 ${next.participants || '待补充'}`)
|
||||
}
|
||||
if (base.expense_type !== next.expense_type) {
|
||||
lines.push(`报销分类 ${next.expense_type || '待补充'}`)
|
||||
}
|
||||
@@ -907,6 +1208,9 @@ function mergeInlineReviewFields(baseFields, inlineState) {
|
||||
occurred_date: inlineState.occurred_date,
|
||||
amount: inlineState.amount,
|
||||
customer_name: inlineState.customer_name,
|
||||
business_location: inlineState.location,
|
||||
merchant_name: inlineState.merchant_name,
|
||||
participants: inlineState.participants,
|
||||
reason: inlineState.reason_value || inlineState.scene_label,
|
||||
attachment_names: inlineState.attachment_names
|
||||
}
|
||||
@@ -1084,6 +1388,9 @@ function buildAgentInsight(payload, fileNames = [], filePreviews = []) {
|
||||
|
||||
export default {
|
||||
name: 'TravelReimbursementCreateView',
|
||||
components: {
|
||||
ConfirmDialog
|
||||
},
|
||||
props: {
|
||||
initialPrompt: {
|
||||
type: String,
|
||||
@@ -1106,7 +1413,7 @@ export default {
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['close'],
|
||||
emits: ['close', 'draft-saved'],
|
||||
setup(props, { emit }) {
|
||||
const { currentUser } = useSystemState()
|
||||
|
||||
@@ -1146,6 +1453,7 @@ export default {
|
||||
const reviewInlineBaseFields = ref([])
|
||||
const reviewInlinePendingFiles = ref([])
|
||||
const reviewInlineEditorKey = ref('')
|
||||
const reviewInlineErrors = ref({})
|
||||
const reviewOtherCategoryOpen = ref(false)
|
||||
const sourceLabel = computed(() => SOURCE_LABELS[props.entrySource] ?? '来自 AI 工作台')
|
||||
const canSubmit = computed(
|
||||
@@ -1176,7 +1484,17 @@ export default {
|
||||
)
|
||||
const reviewIntentText = computed(() => buildReviewIntentText(activeReviewPayload.value))
|
||||
const reviewFactCards = computed(() => buildReviewFactCards(activeReviewPayload.value, reviewInlineForm.value))
|
||||
const reviewCategoryOptions = computed(() => buildReviewCategoryOptions(reviewInlineForm.value.expense_type))
|
||||
const reviewCategoryOptions = computed(() =>
|
||||
buildReviewCategoryOptions(activeReviewPayload.value, reviewInlineForm.value.expense_type, reviewInlineForm.value)
|
||||
)
|
||||
const reviewOtherCategoryOptions = computed(() =>
|
||||
REVIEW_OTHER_CATEGORY_OPTIONS.map((item) => ({
|
||||
...item,
|
||||
confidenceLabel: formatConfidenceLabel(
|
||||
resolveReviewCategoryConfidenceScore(activeReviewPayload.value, item.label, reviewInlineForm.value)
|
||||
)
|
||||
}))
|
||||
)
|
||||
const reviewSelectedOtherCategory = computed(() => {
|
||||
const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label)
|
||||
return presetLabels.includes(reviewInlineForm.value.expense_type) ? '' : reviewInlineForm.value.expense_type
|
||||
@@ -1189,10 +1507,12 @@ export default {
|
||||
reviewInlinePendingFiles.value
|
||||
).length > 0
|
||||
)
|
||||
const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value))
|
||||
const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value))
|
||||
const reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value))
|
||||
const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value))
|
||||
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
|
||||
const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length)
|
||||
const reviewRiskActionAvailable = computed(() => reviewRiskItems.value.length > 0)
|
||||
const recognizedNarratives = computed(() => buildReviewRecognizedLines(activeReviewPayload.value))
|
||||
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
|
||||
const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value))
|
||||
@@ -1217,6 +1537,7 @@ export default {
|
||||
reviewInlineBaseFields.value = cloneReviewEditFields(payload?.edit_fields)
|
||||
reviewInlinePendingFiles.value = []
|
||||
reviewInlineEditorKey.value = ''
|
||||
reviewInlineErrors.value = {}
|
||||
reviewOtherCategoryOpen.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -1269,6 +1590,7 @@ export default {
|
||||
attachment_names: files.map((file) => file.name).join('、'),
|
||||
attachment_count: files.length
|
||||
}
|
||||
clearInlineReviewFieldError('attachments')
|
||||
reviewInlineEditorKey.value = ''
|
||||
} else {
|
||||
attachedFiles.value = files
|
||||
@@ -1285,13 +1607,48 @@ export default {
|
||||
submitComposer()
|
||||
}
|
||||
|
||||
function setInlineReviewFieldError(key, message) {
|
||||
reviewInlineErrors.value = {
|
||||
...reviewInlineErrors.value,
|
||||
[key]: String(message || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
function clearInlineReviewFieldError(key) {
|
||||
if (!reviewInlineErrors.value[key]) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextErrors = { ...reviewInlineErrors.value }
|
||||
delete nextErrors[key]
|
||||
reviewInlineErrors.value = nextErrors
|
||||
}
|
||||
|
||||
function openInlineReviewEditor(key) {
|
||||
if (!activeReviewPayload.value || submitting.value || reviewActionBusy.value) return
|
||||
if (key === 'attachments') {
|
||||
triggerFileUpload('inline-review')
|
||||
return
|
||||
}
|
||||
reviewInlineEditorKey.value = reviewInlineEditorKey.value === key ? '' : key
|
||||
|
||||
if (reviewInlineEditorKey.value && reviewInlineEditorKey.value !== key && !commitInlineReviewEditor()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (reviewInlineEditorKey.value === key) {
|
||||
commitInlineReviewEditor()
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'amount') {
|
||||
reviewInlineForm.value = {
|
||||
...reviewInlineForm.value,
|
||||
amount: extractAmountInputValue(reviewInlineForm.value.amount)
|
||||
}
|
||||
}
|
||||
|
||||
clearInlineReviewFieldError(key)
|
||||
reviewInlineEditorKey.value = key
|
||||
if (key !== 'expense_type') {
|
||||
reviewOtherCategoryOpen.value = false
|
||||
}
|
||||
@@ -1303,16 +1660,41 @@ export default {
|
||||
}
|
||||
|
||||
function commitInlineReviewEditor() {
|
||||
reviewInlineForm.value = {
|
||||
const activeEditorKey = reviewInlineEditorKey.value
|
||||
const nextForm = {
|
||||
...reviewInlineForm.value,
|
||||
occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(),
|
||||
amount: String(reviewInlineForm.value.amount || '').trim(),
|
||||
customer_name: String(reviewInlineForm.value.customer_name || '').trim(),
|
||||
location: String(reviewInlineForm.value.location || '').trim(),
|
||||
merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(),
|
||||
participants: String(reviewInlineForm.value.participants || '').trim(),
|
||||
scene_label: String(reviewInlineForm.value.scene_label || '').trim(),
|
||||
reason_value: String(reviewInlineForm.value.reason_value || reviewInlineForm.value.scene_label || '').trim(),
|
||||
expense_type: String(reviewInlineForm.value.expense_type || '').trim()
|
||||
}
|
||||
|
||||
if (activeEditorKey === 'occurred_date' && nextForm.occurred_date && !isValidIsoDateString(nextForm.occurred_date)) {
|
||||
setInlineReviewFieldError('occurred_date', `请输入正确的时间格式:${DATE_INPUT_FORMAT}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (activeEditorKey === 'amount' && nextForm.amount) {
|
||||
const normalizedAmount = normalizeAmountValue(nextForm.amount)
|
||||
if (!normalizedAmount) {
|
||||
setInlineReviewFieldError('amount', '请输入正确的数字金额,例如 200 或 200.50')
|
||||
return false
|
||||
}
|
||||
nextForm.amount = normalizedAmount
|
||||
}
|
||||
|
||||
if (activeEditorKey) {
|
||||
clearInlineReviewFieldError(activeEditorKey)
|
||||
}
|
||||
|
||||
reviewInlineForm.value = nextForm
|
||||
reviewInlineEditorKey.value = ''
|
||||
return true
|
||||
}
|
||||
|
||||
function selectInlineScene(scene) {
|
||||
@@ -1367,6 +1749,10 @@ export default {
|
||||
async function saveInlineReviewChanges() {
|
||||
if (!activeReviewPayload.value || !reviewInlineDirty.value || reviewActionBusy.value) return
|
||||
|
||||
if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) {
|
||||
return
|
||||
}
|
||||
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value)
|
||||
@@ -1445,6 +1831,8 @@ export default {
|
||||
submitting.value = true
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
let responsePayload = null
|
||||
|
||||
try {
|
||||
const user = currentUser.value || {}
|
||||
let ocrPayload = null
|
||||
@@ -1483,6 +1871,7 @@ export default {
|
||||
...extraContext
|
||||
}
|
||||
})
|
||||
responsePayload = payload
|
||||
|
||||
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
|
||||
draftClaimId.value =
|
||||
@@ -1519,6 +1908,8 @@ export default {
|
||||
submitting.value = false
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
|
||||
return responsePayload
|
||||
}
|
||||
|
||||
function openCancelReviewDialog(message) {
|
||||
@@ -1590,21 +1981,50 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) {
|
||||
return
|
||||
}
|
||||
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const fields = cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
||||
await submitComposer({
|
||||
const baseFields = reviewInlineBaseFields.value.length
|
||||
? reviewInlineBaseFields.value
|
||||
: cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
||||
const fields = mergeInlineReviewFields(baseFields, reviewInlineForm.value)
|
||||
const reviewChangedUserText = reviewInlineDirty.value
|
||||
? buildInlineReviewUserText(
|
||||
reviewInlineBaseForm.value,
|
||||
reviewInlineForm.value,
|
||||
reviewInlinePendingFiles.value
|
||||
)
|
||||
: ''
|
||||
const payload = await submitComposer({
|
||||
rawText:
|
||||
actionType === 'save_draft'
|
||||
? '请按当前已识别信息先保存草稿,缺失字段后续再补。'
|
||||
: '我已核对右侧识别结果,请进入下一步。',
|
||||
userText: actionType === 'save_draft' ? '我先按当前信息保存草稿。' : '我确认当前识别结果,继续下一步。',
|
||||
userText:
|
||||
reviewChangedUserText
|
||||
|| (actionType === 'save_draft' ? '我先按当前信息保存草稿。' : '我确认当前识别结果,继续下一步。'),
|
||||
pendingText: actionType === 'save_draft' ? '正在保存当前草稿...' : '正在进入下一步...',
|
||||
extraContext: {
|
||||
review_action: actionType,
|
||||
review_form_values: buildReviewFormValues(fields)
|
||||
}
|
||||
})
|
||||
|
||||
if (actionType === 'save_draft' && payload?.result?.draft_payload?.claim_no) {
|
||||
emit(
|
||||
'draft-saved',
|
||||
buildDraftSavedPayload({
|
||||
draftPayload: payload.result.draft_payload,
|
||||
reviewPayload: payload?.result?.review_payload || message?.reviewPayload || activeReviewPayload.value,
|
||||
inlineState: reviewInlineForm.value,
|
||||
linkedRequest: linkedRequest.value,
|
||||
currentUser: currentUser.value
|
||||
})
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
@@ -1633,18 +2053,23 @@ export default {
|
||||
reviewIntentText,
|
||||
reviewFactCards,
|
||||
reviewCategoryOptions,
|
||||
reviewOtherCategoryOptions,
|
||||
reviewSelectedOtherCategory,
|
||||
reviewInlineDirty,
|
||||
reviewInlineForm,
|
||||
reviewInlineEditorKey,
|
||||
reviewInlineErrors,
|
||||
reviewOtherCategoryOpen,
|
||||
reviewInlinePendingFiles,
|
||||
DATE_INPUT_FORMAT,
|
||||
REVIEW_SCENE_OPTIONS,
|
||||
REVIEW_OTHER_CATEGORY_OPTIONS,
|
||||
reviewPanelConfidence,
|
||||
reviewRiskScore,
|
||||
reviewRiskSummary,
|
||||
reviewRiskItems,
|
||||
reviewRiskEmpty,
|
||||
reviewRiskActionAvailable,
|
||||
recognizedNarratives,
|
||||
reviewRecognitionNotes,
|
||||
reviewDocumentSummaries,
|
||||
@@ -1676,6 +2101,7 @@ export default {
|
||||
openInlineReviewEditor,
|
||||
closeInlineReviewEditor,
|
||||
commitInlineReviewEditor,
|
||||
clearInlineReviewFieldError,
|
||||
selectInlineScene,
|
||||
selectReviewCategory,
|
||||
selectReviewOtherCategory,
|
||||
|
||||
@@ -1,139 +1,311 @@
|
||||
import { computed, ref } from 'vue'
|
||||
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: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
||||
setup(props, { emit }) {
|
||||
const { toast } = useToast()
|
||||
const expandedExpenseId = ref(null)
|
||||
const aiEntryOpen = ref(false)
|
||||
const aiDraft = ref('')
|
||||
const aiFileInput = ref(null)
|
||||
const aiEntrySeed = ref(2)
|
||||
const pendingAiExpense = ref(null)
|
||||
const uploadedAiFiles = ref([])
|
||||
const expenseItems = ref([
|
||||
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(() => [
|
||||
{
|
||||
id: 'exp-1',
|
||||
time: '07-08',
|
||||
dayLabel: '第 1 天',
|
||||
name: '高铁票',
|
||||
category: '交通',
|
||||
desc: '上海虹桥 -> 杭州东',
|
||||
detail: '客户方案汇报前往现场',
|
||||
amount: '¥236.00',
|
||||
status: '规则通过',
|
||||
tone: 'ok',
|
||||
attachmentStatus: '2 份附件',
|
||||
attachmentHint: '车票 + 行程单',
|
||||
attachmentTone: 'ok',
|
||||
attachments: ['高铁票.pdf', '行程单.pdf'],
|
||||
riskLabel: '规则通过',
|
||||
riskText: '票据与行程匹配',
|
||||
riskTone: 'low'
|
||||
label: '金额',
|
||||
value: request.value.amountDisplay,
|
||||
kind: 'text'
|
||||
},
|
||||
{
|
||||
id: 'exp-2',
|
||||
time: '07-09',
|
||||
dayLabel: '第 2 天',
|
||||
name: '酒店住宿',
|
||||
category: '住宿',
|
||||
desc: '杭州西湖商务酒店',
|
||||
detail: '1 晚住宿,含早餐',
|
||||
amount: '¥1,180.00',
|
||||
status: '待补材料',
|
||||
tone: 'bad',
|
||||
attachmentStatus: '缺 1 份',
|
||||
attachmentHint: '缺少入住清单',
|
||||
attachmentTone: 'partial',
|
||||
attachments: ['酒店发票.jpg'],
|
||||
riskLabel: '待补材料',
|
||||
riskText: '需补酒店入住清单',
|
||||
riskTone: 'medium'
|
||||
label: '当前节点',
|
||||
value: request.value.node,
|
||||
kind: 'pill',
|
||||
className: 'state-pill',
|
||||
tone: request.value.approvalTone
|
||||
},
|
||||
{
|
||||
id: 'exp-3',
|
||||
time: '07-10',
|
||||
dayLabel: '第 3 天',
|
||||
name: '出租车',
|
||||
category: '市内交通',
|
||||
desc: '客户公司往返酒店',
|
||||
detail: '含夜间打车 2 次',
|
||||
amount: '¥128.00',
|
||||
status: '需说明',
|
||||
tone: 'bad',
|
||||
attachmentStatus: '3 份附件',
|
||||
attachmentHint: '发票已上传',
|
||||
attachmentTone: 'ok',
|
||||
attachments: ['出租车发票1.jpg', '出租车发票2.jpg', '打车订单.png'],
|
||||
riskLabel: '超标说明',
|
||||
riskText: '1 笔夜间交通需补充说明',
|
||||
riskTone: 'medium'
|
||||
label: '审批状态',
|
||||
value: request.value.approval,
|
||||
kind: 'pill',
|
||||
className: 'approval-pill',
|
||||
tone: request.value.approvalTone
|
||||
},
|
||||
{
|
||||
id: 'exp-4',
|
||||
time: '07-11',
|
||||
dayLabel: '第 4 天',
|
||||
name: '餐补',
|
||||
category: '补贴',
|
||||
desc: '差旅餐补',
|
||||
detail: '按 4 天标准自动计算',
|
||||
amount: '¥320.00',
|
||||
status: '规则通过',
|
||||
tone: 'ok',
|
||||
attachmentStatus: '系统生成',
|
||||
attachmentHint: '无需上传附件',
|
||||
attachmentTone: 'neutral',
|
||||
attachments: [],
|
||||
riskLabel: '规则通过',
|
||||
riskText: '补贴标准校验通过',
|
||||
riskTone: 'low'
|
||||
label: request.value.secondaryStatusLabel,
|
||||
value: request.value.secondaryStatusValue,
|
||||
kind: 'pill',
|
||||
className: 'risk-pill',
|
||||
tone: request.value.secondaryStatusTone
|
||||
}
|
||||
])
|
||||
|
||||
const request = computed(() => ({
|
||||
id: props.request?.id ?? 'BR240712001',
|
||||
reason: props.request?.reason ?? '客户方案汇报',
|
||||
city: props.request?.city ?? '上海',
|
||||
period: props.request?.period ?? '07-08~07-11 (4天)',
|
||||
applyTime: props.request?.applyTime ?? '2024-07-07',
|
||||
amount: props.request?.amount ?? '¥3,680.00',
|
||||
node: props.request?.node ?? '财务审核',
|
||||
approval: props.request?.approval ?? '审批中',
|
||||
approvalTone: props.request?.approvalTone ?? 'info',
|
||||
travel: props.request?.travel ?? '已订酒店/机票',
|
||||
travelTone: props.request?.travelTone ?? 'low'
|
||||
}))
|
||||
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' }
|
||||
]
|
||||
|
||||
const profile = {
|
||||
name: '张晓明',
|
||||
department: '财务管理员',
|
||||
avatar: '张'
|
||||
}
|
||||
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' }
|
||||
]
|
||||
}
|
||||
|
||||
const summaryItems = [
|
||||
{ label: '出差城市', value: request.value.city, icon: 'mdi mdi-map-marker-path' },
|
||||
{ label: '出差区间', value: request.value.period, icon: 'mdi mdi-clock-outline' },
|
||||
{ label: '票据关联', value: '6 条明细 / 5 份材料', icon: 'mdi mdi-file-document-multiple-outline' },
|
||||
{ label: '商旅状态', value: request.value.travel, icon: 'mdi mdi-airplane' },
|
||||
{ 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 heroSummaryItems = computed(() => [
|
||||
{ label: '单号', value: request.value.id, icon: 'mdi mdi-pound-box-outline' },
|
||||
{ label: '申请类型', value: '差旅费申请/报销', icon: 'mdi mdi-tag-multiple' },
|
||||
...summaryItems
|
||||
])
|
||||
const progressSteps = computed(() =>
|
||||
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
|
||||
? request.value.progressSteps
|
||||
: buildFallbackProgressSteps()
|
||||
)
|
||||
|
||||
const currentProgressRingMotion = {
|
||||
initial: {
|
||||
scale: 1,
|
||||
opacity: 0.34,
|
||||
opacity: 0.34
|
||||
},
|
||||
enter: {
|
||||
scale: [1, 1.42, 1.78],
|
||||
@@ -144,41 +316,186 @@ export default {
|
||||
repeatType: 'loop',
|
||||
repeatDelay: 0.85,
|
||||
ease: 'easeOut',
|
||||
times: [0, 0.5, 1],
|
||||
},
|
||||
},
|
||||
times: [0, 0.5, 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const progressSteps = computed(() => {
|
||||
return [
|
||||
{ index: 1, label: '提交申请', time: '07-11 08:46', done: true, active: true },
|
||||
{ index: 2, label: '票据识别', time: '07-11 08:48', done: true, active: true },
|
||||
{ index: 3, label: '费用归类', time: '07-11 08:49', done: true, active: true },
|
||||
{ index: 4, label: '部门负责人审批', time: '07-11 11:28', done: true, active: true },
|
||||
{ index: 5, label: '财务审批', time: '进行中', active: true, current: true },
|
||||
{ index: 6, label: '归档入账', time: '待处理' }
|
||||
]
|
||||
})
|
||||
|
||||
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 canSendAiEntry = computed(() => Boolean(aiDraft.value.trim() || uploadedAiFiles.value.length))
|
||||
|
||||
const detailNote = '本次出差用于客户方案汇报与现场沟通,需覆盖往返交通、住宿及市内交通费用。已完成主要票据上传,待补酒店入住清单后即可进入完整审批流程。'
|
||||
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(item.riskText)
|
||||
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() {
|
||||
aiEntryOpen.value = false
|
||||
emit('openAssistant', {
|
||||
source: 'detail',
|
||||
prompt: '',
|
||||
@@ -186,266 +503,45 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
function closeAiEntry() {
|
||||
aiEntryOpen.value = false
|
||||
aiDraft.value = ''
|
||||
pendingAiExpense.value = null
|
||||
uploadedAiFiles.value = []
|
||||
if (aiFileInput.value) {
|
||||
aiFileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function parseCurrency(value) {
|
||||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return `¥${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
function buildNextExpenseId() {
|
||||
aiEntrySeed.value += 1
|
||||
return `exp-ai-${aiEntrySeed.value}`
|
||||
}
|
||||
|
||||
function inferExpenseCategory(text) {
|
||||
if (/高铁|火车|机票|航班|打车|出租车|地铁|公交|交通/.test(text)) return '交通'
|
||||
if (/酒店|住宿|房费/.test(text)) return '住宿'
|
||||
if (/餐|午饭|晚饭|早餐|餐补/.test(text)) return '餐饮'
|
||||
return '其他'
|
||||
}
|
||||
|
||||
function inferExpenseName(text, category) {
|
||||
if (/高铁/.test(text)) return '高铁票'
|
||||
if (/机票|航班/.test(text)) return '机票'
|
||||
if (/出租车|打车/.test(text)) return '出租车'
|
||||
if (/酒店|住宿/.test(text)) return '酒店住宿'
|
||||
if (/餐补/.test(text)) return '餐补'
|
||||
if (/餐|午饭|晚饭|早餐/.test(text)) return '餐饮'
|
||||
return `${category}费用`
|
||||
}
|
||||
|
||||
function inferAttachments(text, uploadedFiles = []) {
|
||||
if (uploadedFiles.length) {
|
||||
return {
|
||||
status: `${uploadedFiles.length} 份附件`,
|
||||
hint: uploadedFiles.map((file) => file.name).join(' + '),
|
||||
tone: 'ok',
|
||||
files: uploadedFiles.map((file) => file.name),
|
||||
}
|
||||
}
|
||||
|
||||
if (/无需|免附件|系统生成/.test(text)) {
|
||||
return {
|
||||
status: '系统生成',
|
||||
hint: '无需上传附件',
|
||||
tone: 'neutral',
|
||||
files: [],
|
||||
}
|
||||
}
|
||||
|
||||
const uploaded = /已上传|上传了|附上|附件/.test(text)
|
||||
const receipt = /发票/.test(text)
|
||||
const itinerary = /行程单/.test(text)
|
||||
const ticket = /车票|机票/.test(text)
|
||||
const hotelList = /入住清单/.test(text)
|
||||
const files = []
|
||||
|
||||
if (receipt) files.push('发票.jpg')
|
||||
if (itinerary) files.push('行程单.pdf')
|
||||
if (ticket && !files.includes('票据.pdf')) files.push('票据.pdf')
|
||||
if (hotelList) files.push('入住清单.pdf')
|
||||
|
||||
if (uploaded || files.length) {
|
||||
return {
|
||||
status: `${Math.max(files.length, 1)} 份附件`,
|
||||
hint: files.length ? files.join(' + ') : '已上传附件待识别',
|
||||
tone: 'ok',
|
||||
files: files.length ? files : ['附件1.jpg'],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: '缺 1 份',
|
||||
hint: '待补上传票据原件',
|
||||
tone: 'missing',
|
||||
files: [],
|
||||
}
|
||||
}
|
||||
|
||||
function inferRisk(text, attachmentTone) {
|
||||
if (/夜间|超标|说明/.test(text)) {
|
||||
return {
|
||||
status: '需说明',
|
||||
tone: 'bad',
|
||||
riskLabel: '超标说明',
|
||||
riskText: '识别到特殊场景,建议补充费用说明',
|
||||
riskTone: 'medium',
|
||||
}
|
||||
}
|
||||
|
||||
if (attachmentTone === 'missing' || attachmentTone === 'partial') {
|
||||
return {
|
||||
status: '待补材料',
|
||||
tone: 'bad',
|
||||
riskLabel: '待补材料',
|
||||
riskText: '附件不完整,需补齐后再提交审批',
|
||||
riskTone: 'medium',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: '规则通过',
|
||||
tone: 'ok',
|
||||
riskLabel: '规则通过',
|
||||
riskText: 'AI 识别通过,字段已结构化',
|
||||
riskTone: 'low',
|
||||
}
|
||||
}
|
||||
|
||||
function extractDateLabel(text) {
|
||||
const match = text.match(/(\d{1,2})月(\d{1,2})日|(\d{1,2})[-/.](\d{1,2})/)
|
||||
if (!match) {
|
||||
return { time: '07-12', dayLabel: `第 ${expenseItems.value.length + 1} 天` }
|
||||
}
|
||||
|
||||
const month = String(match[1] || match[3] || '07').padStart(2, '0')
|
||||
const day = String(match[2] || match[4] || '12').padStart(2, '0')
|
||||
return { time: `${month}-${day}`, dayLabel: `第 ${expenseItems.value.length + 1} 天` }
|
||||
}
|
||||
|
||||
function extractAmount(text) {
|
||||
const match = text.match(/(\d+(?:\.\d{1,2})?)\s*元/)
|
||||
return formatCurrency(Number.parseFloat(match?.[1] || '0'))
|
||||
}
|
||||
|
||||
function buildAiExpense(text) {
|
||||
const category = inferExpenseCategory(text)
|
||||
const name = inferExpenseName(text, category)
|
||||
const dateInfo = extractDateLabel(text)
|
||||
const attachments = inferAttachments(text, uploadedAiFiles.value)
|
||||
const risk = inferRisk(text, attachments.tone)
|
||||
|
||||
return {
|
||||
id: buildNextExpenseId(),
|
||||
time: dateInfo.time,
|
||||
dayLabel: dateInfo.dayLabel,
|
||||
name,
|
||||
category,
|
||||
desc: text.slice(0, 24),
|
||||
detail: text,
|
||||
amount: extractAmount(text),
|
||||
status: risk.status,
|
||||
tone: risk.tone,
|
||||
attachmentStatus: attachments.status,
|
||||
attachmentHint: attachments.hint,
|
||||
attachmentTone: attachments.tone,
|
||||
attachments: attachments.files,
|
||||
riskLabel: risk.riskLabel,
|
||||
riskText: risk.riskText,
|
||||
riskTone: risk.riskTone,
|
||||
}
|
||||
}
|
||||
|
||||
const aiMessages = ref([
|
||||
{
|
||||
id: 'ai-msg-1',
|
||||
role: 'assistant',
|
||||
text: '请直接描述费用场景、日期、金额和是否已上传票据,我会整理成费用明细。',
|
||||
},
|
||||
])
|
||||
|
||||
function sendAiEntry() {
|
||||
const text = aiDraft.value.trim() || `已上传 ${uploadedAiFiles.value.length} 份单据,请根据附件识别费用。`
|
||||
if (!text && !uploadedAiFiles.value.length) return
|
||||
|
||||
aiMessages.value.push({
|
||||
id: `ai-msg-user-${Date.now()}`,
|
||||
role: 'user',
|
||||
text: uploadedAiFiles.value.length ? `${text}\n附件:${uploadedAiFiles.value.map((file) => file.name).join('、')}` : text,
|
||||
})
|
||||
|
||||
pendingAiExpense.value = buildAiExpense(text)
|
||||
|
||||
aiMessages.value.push({
|
||||
id: `ai-msg-assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
text: `已识别为 ${pendingAiExpense.value.name},金额 ${pendingAiExpense.value.amount},可直接加入费用明细。`,
|
||||
})
|
||||
|
||||
aiDraft.value = ''
|
||||
}
|
||||
|
||||
function regenerateAiEntry() {
|
||||
if (!pendingAiExpense.value) return
|
||||
const sourceText = pendingAiExpense.value.detail
|
||||
pendingAiExpense.value = buildAiExpense(sourceText.replace('待补上传票据原件', '已上传发票'))
|
||||
aiMessages.value.push({
|
||||
id: `ai-msg-regenerate-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
text: '已重新整理识别结果,你可以继续确认后加入费用明细。',
|
||||
})
|
||||
}
|
||||
|
||||
function applyAiExpense() {
|
||||
if (!pendingAiExpense.value) return
|
||||
expenseItems.value.push({ ...pendingAiExpense.value })
|
||||
expandedExpenseId.value = pendingAiExpense.value.id
|
||||
aiMessages.value.push({
|
||||
id: `ai-msg-apply-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
text: '该费用条目已加入下方费用明细表。',
|
||||
})
|
||||
pendingAiExpense.value = null
|
||||
aiDraft.value = ''
|
||||
uploadedAiFiles.value = []
|
||||
if (aiFileInput.value) {
|
||||
aiFileInput.value.value = ''
|
||||
}
|
||||
aiEntryOpen.value = false
|
||||
}
|
||||
|
||||
function triggerAiUpload() {
|
||||
aiFileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleAiFilesChange(event) {
|
||||
const files = Array.from(event.target.files ?? [])
|
||||
uploadedAiFiles.value = files
|
||||
}
|
||||
|
||||
return {
|
||||
emit,
|
||||
expandedExpenseId,
|
||||
aiEntryOpen,
|
||||
aiDraft,
|
||||
aiFileInput,
|
||||
aiEntrySeed,
|
||||
pendingAiExpense,
|
||||
uploadedAiFiles,
|
||||
expenseItems,
|
||||
request,
|
||||
profile,
|
||||
summaryItems,
|
||||
heroSummaryItems,
|
||||
actionBusy,
|
||||
canSubmit,
|
||||
closeDeleteDialog,
|
||||
confirmDeleteDraft,
|
||||
currentProgressRingMotion,
|
||||
progressSteps,
|
||||
expenseTotal,
|
||||
uploadedExpenseCount,
|
||||
canSendAiEntry,
|
||||
deleteBusy,
|
||||
deleteDialogOpen,
|
||||
detailNote,
|
||||
toggleExpenseAttachments,
|
||||
showExpenseRisk,
|
||||
draftBlockingIssues,
|
||||
editingExpenseId,
|
||||
expenseEditor,
|
||||
expenseItems,
|
||||
expenseSummaryText,
|
||||
expenseTotal,
|
||||
expandedExpenseId,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
handleDeleteDraft,
|
||||
handleSubmit,
|
||||
heroStats,
|
||||
heroSummaryItems,
|
||||
isDraftRequest,
|
||||
isTravelRequest,
|
||||
openAiEntry,
|
||||
closeAiEntry,
|
||||
aiMessages,
|
||||
sendAiEntry,
|
||||
regenerateAiEntry,
|
||||
applyAiExpense,
|
||||
triggerAiUpload,
|
||||
handleAiFilesChange
|
||||
profile,
|
||||
progressSteps,
|
||||
request,
|
||||
resolveExpenseIssues,
|
||||
savingExpenseId,
|
||||
showExpenseRisk,
|
||||
startExpenseEdit,
|
||||
submitBusy,
|
||||
toggleExpenseAttachments,
|
||||
uploadedExpenseCount,
|
||||
validationSummary,
|
||||
validationTone,
|
||||
cancelExpenseEdit,
|
||||
saveExpenseEdit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user