feat: 完善报销单审批流程及退回原因追踪

新增直属领导审批通过接口和审批待办列表查询,报销单退回
支持原因码分类和审批环节标记,优化票据附件去重和路径
回退查找,前端新增退回原因对话框、审批收件箱和工作台
图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-20 21:00:47 +08:00
parent f8b25a7ccc
commit 002bf4f756
62 changed files with 5331 additions and 2101 deletions

View File

@@ -3,7 +3,9 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import ReturnReasonDialog from '../../components/shared/ReturnReasonDialog.vue'
import {
approveExpenseClaim,
createExpenseClaimItem,
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
@@ -15,8 +17,13 @@ import {
uploadExpenseClaimItemAttachment,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import { canManageExpenseClaims } from '../../utils/accessControl.js'
import { canManageExpenseClaims, canReturnExpenseClaims } from '../../utils/accessControl.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
buildAiAdviceViewModel,
buildAttachmentInsightViewModel,
buildAttachmentRiskCards
} from './travelRequestDetailInsights.js'
const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' },
@@ -30,21 +37,6 @@ const EXPENSE_TYPE_OPTIONS = [
{ value: 'other', label: '其他费用' }
]
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
train_ticket: '火车/高铁票',
hotel_invoice: '酒店住宿票据',
taxi_receipt: '出租车/网约车票据',
parking_toll_receipt: '停车/通行费票据',
meal_receipt: '餐饮票据',
office_invoice: '办公用品票据',
meeting_invoice: '会议/会务票据',
training_invoice: '培训票据',
vat_invoice: '增值税发票',
receipt: '一般收据/凭证',
other: '其他单据'
}
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel',
'meeting',
@@ -72,18 +64,10 @@ function resolveExpenseTypeLabel(value) {
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
}
function resolveDocumentTypeLabel(value) {
return DOCUMENT_TYPE_LABELS[String(value || '').trim()] || DOCUMENT_TYPE_LABELS.other
}
function isLocationRequiredExpenseType(value) {
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function resolveLocationInputPlaceholder(value) {
return isLocationRequiredExpenseType(value) ? '输入业务地点' : '输入采购/收货地点(可选)'
}
function resolveLocationSummaryLabel(value) {
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
}
@@ -191,9 +175,28 @@ function normalizeIsoDateValue(value) {
return `${year}-${month}-${day}`
}
function formatExpenseFilledTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return ''
}
const candidate = value instanceof Date ? value : new Date(normalized)
if (Number.isNaN(candidate.getTime())) {
return normalized
}
const year = candidate.getFullYear()
const month = String(candidate.getMonth() + 1).padStart(2, '0')
const day = String(candidate.getDate()).padStart(2, '0')
const hours = String(candidate.getHours()).padStart(2, '0')
const minutes = String(candidate.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function resolveExpenseUploadHint(value) {
const normalized = String(value || '').trim()
return normalized || '支持上传 JPG、PNG、PDF,未上传也可先保存草稿'
return normalized || '支持上传 1 张 JPG、PNG、PDF 单据'
}
function extractAttachmentDisplayName(value) {
@@ -216,6 +219,12 @@ function buildExpenseItemViewModel(source, index, requestModel) {
const attachments = invoiceId ? [attachmentName || invoiceId] : []
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
const riskText = String(source?.riskText || '').trim()
const filledAt = formatExpenseFilledTime(
source?.filledAt
|| source?.filled_at
|| source?.createdAt
|| source?.created_at
)
return {
id: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`),
@@ -226,6 +235,7 @@ function buildExpenseItemViewModel(source, index, requestModel) {
itemAmount,
invoiceId,
time: itemDate || '待补充',
filledAt: filledAt || '待同步',
dayLabel: requestModel?.detailVariant === 'travel' ? `${index + 1}` : '业务发生项',
name: resolveExpenseTypeLabel(itemType),
category: resolveExpenseTypeLabel(itemType),
@@ -234,7 +244,7 @@ function buildExpenseItemViewModel(source, index, requestModel) {
amount: amountDisplay,
status: attachments.length ? '已识别' : '待补充',
tone: attachments.length ? 'ok' : 'bad',
attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '未上传',
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
attachmentTone: attachments.length ? 'ok' : 'missing',
attachments,
@@ -372,12 +382,21 @@ function mapIssueToAdvice(issue) {
export default {
name: 'TravelRequestDetailView',
components: {
ConfirmDialog
ConfirmDialog,
ReturnReasonDialog
},
props: {
request: {
type: Object,
default: () => ({})
},
backLabel: {
type: String,
default: '返回报销列表'
},
approvalMode: {
type: Boolean,
default: false
}
},
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
@@ -392,10 +411,14 @@ export default {
const deletingExpenseId = ref('')
const pendingUploadExpenseId = ref('')
const submitBusy = ref(false)
const submitConfirmDialogOpen = ref(false)
const deleteBusy = ref(false)
const deleteDialogOpen = ref(false)
const returnBusy = ref(false)
const returnDialogOpen = ref(false)
const approveBusy = ref(false)
const approveConfirmDialogOpen = ref(false)
const leaderOpinion = ref('')
const expenseUploadInput = ref(null)
const expenseAttachmentMeta = reactive({})
const attachmentPreviewOpen = ref(false)
@@ -404,6 +427,7 @@ export default {
const attachmentPreviewUrl = ref('')
const attachmentPreviewName = ref('')
const attachmentPreviewMediaType = ref('')
const attachmentPreviewItemId = ref('')
const expenseEditor = reactive({
itemDate: '',
itemType: 'other',
@@ -455,13 +479,28 @@ export default {
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.value)
const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批'
})
const showLeaderApprovalPanel = computed(() =>
Boolean(props.approvalMode)
&& request.value.approvalKey === 'in_progress'
&& isDirectManagerApprovalStage.value
&& Boolean(request.value.claimId)
)
const canReturnRequest = computed(() =>
canManageCurrentClaim.value
canReturnExpenseClaims(currentUser.value)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
)
const canApproveRequest = computed(() =>
showLeaderApprovalPanel.value
&& canReturnExpenseClaims(currentUser.value)
)
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
const deleteDialogDescription = computed(() =>
@@ -474,6 +513,7 @@ export default {
|| submitBusy.value
|| deleteBusy.value
|| returnBusy.value
|| approveBusy.value
|| creatingExpense.value
|| Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value)
@@ -583,12 +623,8 @@ export default {
})
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) + (isEditableRequest.value ? 1 : 0)
)
const expenseSummaryText = computed(
() => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。'
() => 6 + (isEditableRequest.value ? 1 : 0)
)
const detailNote = computed(
() =>
@@ -599,7 +635,49 @@ export default {
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
)
const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
const locationInputPlaceholder = computed(() => resolveLocationInputPlaceholder(expenseEditor.itemType))
const attachmentPreviewEntries = computed(() =>
expenseItems.value
.filter((item) => item.invoiceId)
.map((item, index) => ({
item,
itemId: item.id,
index,
name: resolveAttachmentDisplayName(item) || `${index + 1} 条附件`,
metadata: resolveAttachmentMeta(item)
}))
)
const currentAttachmentPreviewIndex = computed(() =>
attachmentPreviewEntries.value.findIndex((entry) => entry.itemId === attachmentPreviewItemId.value)
)
const currentAttachmentPreviewEntry = computed(() => {
const index = currentAttachmentPreviewIndex.value
return index >= 0 ? attachmentPreviewEntries.value[index] : null
})
const attachmentPreviewIndexLabel = computed(() => {
const currentIndex = currentAttachmentPreviewIndex.value
const total = attachmentPreviewEntries.value.length
return currentIndex >= 0 && total > 0 ? `${currentIndex + 1} / ${total}` : ''
})
const canNavigateAttachmentPreview = computed(() => attachmentPreviewEntries.value.length > 1)
const currentAttachmentPreviewInsight = computed(() => {
const entry = currentAttachmentPreviewEntry.value
if (!entry) {
return null
}
return buildAttachmentInsightViewModel(resolveAttachmentMeta(entry.item), entry.item)
})
const currentAttachmentPreviewRiskCards = computed(() => {
const entry = currentAttachmentPreviewEntry.value
if (!entry) {
return []
}
return buildAttachmentRiskCards({
expenseItems: [entry.item],
attachmentMetaByItemId: expenseAttachmentMeta
})
})
function applyLocalExpenseItemPatch(itemId, patch) {
expenseItems.value = rebuildExpenseItems(
@@ -617,36 +695,13 @@ export default {
return String(metadata?.file_name || item.attachmentHint || '').trim()
}
function resolveAttachmentPreviewTitle(item) {
const fileName = resolveAttachmentDisplayName(item)
return fileName ? `预览附件:${fileName}` : '预览附件'
}
function resolveAttachmentRecognition(item) {
const metadata = resolveAttachmentMeta(item)
const documentInfo = metadata?.document_info
const requirementCheck = metadata?.requirement_check
if (!documentInfo && !requirementCheck) {
return null
}
const fields = Array.isArray(documentInfo?.fields)
? documentInfo.fields
.map((field) => ({
label: String(field?.label || '').trim(),
value: String(field?.value || '').trim()
}))
.filter((field) => field.label && field.value)
: []
return {
documentTypeLabel:
String(documentInfo?.document_type_label || '').trim()
|| resolveDocumentTypeLabel(documentInfo?.document_type),
requirementLabel: requirementCheck
? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
: '待校验附件类型',
requirementTone: requirementCheck
? (requirementCheck.matches ? 'pass' : 'high')
: 'medium',
message: String(requirementCheck?.message || '').trim(),
fields: fields.slice(0, 4).map((field) => `${field.label}${field.value}`)
}
return buildAttachmentInsightViewModel(resolveAttachmentMeta(item), item)
}
function buildAttachmentRiskNotice(attachment) {
@@ -676,7 +731,7 @@ export default {
function canPreviewAttachment(item) {
const metadata = resolveAttachmentMeta(item)
return Boolean(item.invoiceId && metadata?.previewable)
return Boolean(item.invoiceId && metadata?.previewable !== false)
}
function revokeAttachmentPreviewUrl() {
@@ -692,6 +747,7 @@ export default {
attachmentPreviewError.value = ''
attachmentPreviewName.value = ''
attachmentPreviewMediaType.value = ''
attachmentPreviewItemId.value = ''
revokeAttachmentPreviewUrl()
}
@@ -769,42 +825,16 @@ export default {
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 riskCards = buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
})
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]
}
return buildAiAdviceViewModel({
completionItems,
riskCards
})
})
function startExpenseEdit(item) {
@@ -836,12 +866,6 @@ export default {
if (isPlaceholderValue(expenseEditor.itemReason)) {
return '请输入费用说明。'
}
if (
isLocationRequiredExpenseType(expenseEditor.itemType)
&& isPlaceholderValue(expenseEditor.itemLocation)
) {
return '请输入业务地点。'
}
const amount = Number(expenseEditor.itemAmount)
if (!Number.isFinite(amount) || amount <= 0) {
@@ -890,7 +914,12 @@ export default {
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法上传附件。')
toast('当前草稿缺少 claimId暂时无法上传单据。')
return
}
if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
return
}
@@ -901,22 +930,29 @@ export default {
}
}
async function openAttachmentPreview(item) {
if (!request.value.claimId || !canPreviewAttachment(item)) {
async function loadAttachmentPreview(item) {
if (!request.value.claimId || !item?.invoiceId) {
return
}
closeAttachmentPreview()
attachmentPreviewOpen.value = true
attachmentPreviewLoading.value = true
attachmentPreviewError.value = ''
attachmentPreviewItemId.value = item.id
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
const metadata = resolveAttachmentMeta(item)
attachmentPreviewMediaType.value =
String(metadata?.preview_kind || '').trim() === 'image'
? 'image/png'
: String(metadata?.media_type || '').trim()
let metadata = resolveAttachmentMeta(item)
try {
if (!metadata) {
metadata = await refreshExpenseAttachmentMeta(item.id)
}
if (metadata?.previewable === false) {
throw new Error('当前附件暂不支持直接预览。')
}
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
attachmentPreviewMediaType.value =
String(metadata?.preview_kind || '').trim() === 'image'
? 'image/png'
: String(metadata?.media_type || '').trim()
const blob = await fetchExpenseClaimItemAttachmentPreview(request.value.claimId, item.id)
revokeAttachmentPreviewUrl()
attachmentPreviewUrl.value = URL.createObjectURL(blob)
@@ -928,11 +964,48 @@ export default {
}
}
async function openAttachmentPreview(item) {
if (!request.value.claimId || !canPreviewAttachment(item)) {
return
}
closeAttachmentPreview()
attachmentPreviewOpen.value = true
await loadAttachmentPreview(item)
}
async function goToAttachmentPreview(offset) {
if (!canNavigateAttachmentPreview.value || attachmentPreviewLoading.value) {
return
}
const entries = attachmentPreviewEntries.value
const currentIndex = currentAttachmentPreviewIndex.value
const nextIndex = (currentIndex + offset + entries.length) % entries.length
const nextEntry = entries[nextIndex]
if (nextEntry?.item) {
await loadAttachmentPreview(nextEntry.item)
}
}
function goToPreviousAttachmentPreview() {
void goToAttachmentPreview(-1)
}
function goToNextAttachmentPreview() {
void goToAttachmentPreview(1)
}
async function uploadExpenseFile(item, file) {
if (!item || !file) {
return
}
if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
return
}
uploadingExpenseId.value = item.id
try {
@@ -986,7 +1059,9 @@ export default {
async function handleExpenseFileChange(event) {
const target = event?.target
const file = target?.files?.[0]
const fileList = target?.files
const fileCount = fileList?.length || 0
const file = fileList?.[0]
const itemId = pendingUploadExpenseId.value
pendingUploadExpenseId.value = ''
@@ -994,6 +1069,11 @@ export default {
target.value = ''
}
if (fileCount > 1) {
toast('一条费用明细只能上传一张单据,请只选择一个文件。')
return
}
if (!file || !itemId) {
return
}
@@ -1059,11 +1139,12 @@ export default {
savingExpenseId.value = item.id
try {
const nextInvoiceId = expenseEditor.invoiceId.trim()
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
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_location: preservedLocation,
item_amount: Number(expenseEditor.itemAmount),
invoice_id: nextInvoiceId
})
@@ -1071,7 +1152,7 @@ export default {
itemDate: expenseEditor.itemDate,
itemType: expenseEditor.itemType,
itemReason: expenseEditor.itemReason.trim(),
itemLocation: expenseEditor.itemLocation.trim(),
itemLocation: preservedLocation,
itemAmount: Number(expenseEditor.itemAmount),
invoiceId: nextInvoiceId
})
@@ -1096,7 +1177,7 @@ export default {
}
}
async function handleSubmit() {
function handleSubmit() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
return
@@ -1107,6 +1188,30 @@ export default {
return
}
submitConfirmDialogOpen.value = true
}
function closeSubmitConfirmDialog() {
if (submitBusy.value) {
return
}
submitConfirmDialogOpen.value = false
}
async function confirmSubmitRequest() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
submitConfirmDialogOpen.value = false
return
}
if (!canSubmit.value) {
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
submitConfirmDialogOpen.value = false
return
}
submitBusy.value = true
try {
const payload = await submitExpenseClaim(request.value.claimId)
@@ -1119,6 +1224,7 @@ export default {
} else {
toast(`${request.value.id} 提交结果已更新。`)
}
submitConfirmDialogOpen.value = false
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '提交审批失败,请稍后重试。')
@@ -1190,7 +1296,7 @@ export default {
returnDialogOpen.value = false
}
async function confirmReturnRequest() {
async function confirmReturnRequest(payload) {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法退回。')
return
@@ -1198,9 +1304,7 @@ export default {
returnBusy.value = true
try {
await returnExpenseClaim(request.value.claimId, {
reason: '详情页退回,请申请人调整后重新提交。'
})
await returnExpenseClaim(request.value.claimId, payload)
returnDialogOpen.value = false
toast(`${request.value.id} 已退回待提交。`)
emit('request-updated', { claimId: request.value.claimId })
@@ -1211,7 +1315,62 @@ export default {
}
}
function handleApproveRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法审批通过。')
return
}
if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。')
return
}
approveConfirmDialogOpen.value = true
}
function closeApproveConfirmDialog() {
if (approveBusy.value) {
return
}
approveConfirmDialogOpen.value = false
}
async function confirmApproveRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法审批通过。')
approveConfirmDialogOpen.value = false
return
}
if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。')
approveConfirmDialogOpen.value = false
return
}
approveBusy.value = true
try {
await approveExpenseClaim(request.value.claimId, {
opinion: leaderOpinion.value.trim()
})
approveConfirmDialogOpen.value = false
leaderOpinion.value = ''
toast(`${request.value.id} 已审批通过,流转至财务审批。`)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。')
} finally {
approveBusy.value = false
}
}
function openAiEntry() {
if (!canOpenAiEntry.value) {
return
}
emit('openAssistant', {
source: 'detail',
prompt: '',
@@ -1229,21 +1388,33 @@ export default {
actionBusy,
aiAdvice,
attachmentPreviewError,
attachmentPreviewIndexLabel,
attachmentPreviewLoading,
attachmentPreviewMediaType,
attachmentPreviewName,
attachmentPreviewOpen,
attachmentPreviewUrl,
approveBusy,
approveConfirmDialogOpen,
canDeleteRequest,
canManageCurrentClaim,
canNavigateAttachmentPreview,
canOpenAiEntry,
canApproveRequest,
canReturnRequest,
canSubmit,
canPreviewAttachment,
closeApproveConfirmDialog,
closeDeleteDialog,
closeAttachmentPreview,
closeSubmitConfirmDialog,
closeReturnDialog,
confirmApproveRequest,
confirmDeleteRequest,
confirmSubmitRequest,
confirmReturnRequest,
currentAttachmentPreviewInsight,
currentAttachmentPreviewRiskCards,
currentProgressRingMotion,
deleteActionLabel,
deleteBusy,
@@ -1258,39 +1429,43 @@ export default {
creatingExpense,
expenseEditor,
expenseItems,
expenseSummaryText,
expenseTableColumnCount,
expenseTotal,
expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
handleAddExpenseItem,
handleApproveRequest,
handleDeleteRequest,
handleExpenseFileChange,
handleReturnRequest,
handleSubmit,
hasExpenseRiskColumn,
heroFactItems,
isDraftRequest,
isEditableRequest,
isTravelRequest,
locationInputPlaceholder,
openAiEntry,
openAttachmentPreview,
goToNextAttachmentPreview,
goToPreviousAttachmentPreview,
profile,
progressSteps,
removeExpenseItem,
request,
leaderOpinion,
removeExpenseAttachment,
removeExpenseItem,
resolveAttachmentDisplayName,
resolveAttachmentPreviewTitle,
resolveAttachmentRecognition,
resolveExpenseRiskState,
resolveExpenseIssues,
returnBusy,
returnDialogOpen,
savingExpenseId,
showLeaderApprovalPanel,
showExpenseRisk,
startExpenseEdit,
submitBusy,
submitConfirmDialogOpen,
triggerExpenseUpload,
uploadedExpenseCount,
uploadingExpenseId,