Files
X-Financial/web/src/views/DocumentsCenterView.vue

836 lines
30 KiB
Vue
Raw Normal View History

<template>
<section class="documents-page">
<article class="documents-list panel">
<nav class="status-tabs document-scope-tabs" aria-label="单据工作视角">
<button
v-for="tab in scopeTabItems"
:key="tab.value"
type="button"
:class="{ active: activeScopeTab === tab.value }"
@click="activeScopeTab = tab.value"
>
<span>{{ tab.label }}</span>
<span v-if="tab.badgeCount > 0" class="scope-tab-badge" aria-label="新增单据数">
{{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }}
</span>
</button>
</nav>
<div class="document-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="listKeyword" type="search" :placeholder="activeFilterConfig.searchPlaceholder" />
</div>
<div class="document-status-filter" :aria-label="activeFilterConfig.statusTitle">
<div class="document-filter status-dropdown-filter" :class="{ open: openFilterKey === 'status' }">
<button
class="filter-btn status-filter-trigger"
type="button"
:aria-expanded="openFilterKey === 'status'"
@click="toggleFilter('status')"
>
<i class="mdi mdi-filter-variant"></i>
<span>{{ statusFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div
v-if="openFilterKey === 'status'"
class="document-filter-menu status-filter-menu"
role="listbox"
aria-label="单据状态"
>
<button
v-for="option in statusFilterOptions"
:key="option.value"
type="button"
role="option"
:aria-selected="activeStatusTab === option.value"
:class="{ active: activeStatusTab === option.value }"
@click="selectStatusTab(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<div v-if="showDocumentTypeFilter" class="document-filter">
<button class="filter-btn" type="button" @click="toggleFilter('documentType')">
<span>{{ documentTypeFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="openFilterKey === 'documentType'" class="document-filter-menu">
<button
v-for="option in documentTypeOptions"
:key="option.value"
type="button"
:class="{ active: activeDocumentType === option.value }"
@click="selectDocumentType(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="document-filter">
<button class="filter-btn" type="button" @click="toggleFilter('scene')">
<span>{{ sceneFilterLabel }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<div v-if="openFilterKey === 'scene'" class="document-filter-menu">
<button
v-for="option in sceneFilterOptions"
:key="option.value"
type="button"
:class="{ active: activeScene === option.value }"
@click="selectScene(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
<div class="date-range-filter" :class="{ open: datePopover }">
<button class="filter-btn date-range-trigger" type="button" @click="toggleDatePopover">
<span class="date-range-label">{{ dateRangeLabel }}</span>
<i class="mdi mdi-calendar"></i>
</button>
<div v-if="datePopover" class="date-range-popover" role="dialog" aria-label="选择时间段">
<header>
<strong>选择时间段</strong>
<button type="button" aria-label="关闭" @click="datePopover = false">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="date-range-fields">
<label>
<span>开始日期</span>
<input v-model="rangeStart" type="date" />
</label>
<label>
<span>结束日期</span>
<input v-model="rangeEnd" type="date" />
</label>
</div>
<footer>
<button class="ghost-btn" type="button" @click="clearDateRange">清空</button>
<button class="apply-btn" type="button" :disabled="!rangeStart || !rangeEnd" @click="applyDateRange">应用</button>
</footer>
</div>
</div>
</div>
<div v-if="[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT].includes(activeScopeTab)" class="document-actions">
<button v-if="activeScopeTab === DOCUMENT_SCOPE_APPLICATION" class="create-request-btn" type="button" @click="emit('create-application')">
<i class="mdi mdi-file-plus-outline"></i>
<span>发起申请</span>
</button>
<button v-if="activeScopeTab === DOCUMENT_SCOPE_REIMBURSEMENT" class="create-request-btn" type="button" @click="emit('create-request')">
<i class="mdi mdi-plus-circle-outline"></i>
<span>发起报销</span>
</button>
</div>
</div>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="showLoading" class="table-state">
<TableLoadingState
title="单据数据同步中"
message="正在汇总当前报销、审批待办与归档单据"
icon="mdi mdi-file-document-multiple-outline"
floating
/>
</div>
<div v-else-if="showError" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>单据中心加载失败</strong>
<p>{{ errorMessage }}</p>
<button class="retry-btn" type="button" @click="reloadAll">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
:eyebrow="emptyState.eyebrow"
:title="emptyState.title"
:description="emptyState.desc"
:icon="emptyState.icon"
:action-label="emptyState.actionLabel"
:action-icon="emptyState.actionIcon"
:tone="emptyState.tone"
:art-label="emptyState.artLabel"
:tips="emptyState.tips"
@action="handleEmptyAction"
/>
<table v-else>
<colgroup>
<col class="col-id">
<col class="col-created">
<col v-if="showStayTimeColumn" class="col-stay">
<col class="col-doc-type">
<col class="col-scene">
<col class="col-initiator">
<col class="col-title">
<col class="col-amount">
<col class="col-node">
<col class="col-status">
<col class="col-updated">
</colgroup>
<thead>
<tr>
<th>单号</th>
<th>创建时间</th>
<th v-if="showStayTimeColumn">停留时间</th>
<th>单据类型</th>
<th>费用场景</th>
<th>发起人</th>
<th>事项</th>
<th>金额</th>
<th>当前环节</th>
<th>状态</th>
<th>更新时间</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.documentKey" @click="openDocument(row)">
<td>
<span v-if="row.isNewDocument" class="new-document-badge">NEW</span>
<strong class="doc-id">{{ row.documentNo }}</strong>
</td>
<td>{{ row.createdAtDisplay }}</td>
<td v-if="showStayTimeColumn">{{ row.stayTimeDisplay }}</td>
<td><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td>
<td><span class="type-tag" :class="row.typeTone">{{ row.typeLabel }}</span></td>
<td>{{ row.initiatorName }}</td>
<td>{{ row.reason }}</td>
<td>{{ row.amountDisplay }}</td>
<td>{{ row.node }}</td>
<td><span class="status-tag" :class="row.statusTone">{{ row.statusLabel }}</span></td>
<td>{{ row.updatedAtDisplay }}</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="showTable" class="list-foot">
<span class="page-summary"> {{ filteredRows.length }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
:aria-current="currentPage === page ? 'page' : undefined"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect v-model="pageSize" class="page-size-select" :options="pageSizeOptions" size="small" @change="changePageSize" />
</footer>
</article>
</section>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
const DOCUMENT_TYPE_ALL = 'all'
const DOCUMENT_TYPE_APPLICATION = 'application'
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
const SCENE_ALL = 'all'
const DOCUMENT_SCOPE_ALL = '全部'
const DOCUMENT_SCOPE_APPLICATION = '申请单'
const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
const DOCUMENT_SCOPE_REVIEW = '审核单'
const DOCUMENT_SCOPE_ARCHIVE = '归档'
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
const DOCUMENT_LOADING_MIN_VISIBLE_MS = 720
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '待付款', '已完成']
const FILTER_CONFIG_BY_SCOPE = {
[DOCUMENT_SCOPE_ALL]: {
searchPlaceholder: '搜索单号、事项、费用场景...',
sceneFallbackLabel: '单据场景',
dateLabel: '单据时间',
statusTitle: '单据状态',
statusTabs,
showDocumentType: true
},
[DOCUMENT_SCOPE_APPLICATION]: {
searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
sceneFallbackLabel: '申请场景',
dateLabel: '申请时间',
statusTitle: '申请状态',
statusTabs: ['全部', '草稿', '审批中', '已完成'],
showDocumentType: false
},
[DOCUMENT_SCOPE_REIMBURSEMENT]: {
searchPlaceholder: '搜索报销单号、报销事由、费用场景...',
sceneFallbackLabel: '费用场景',
dateLabel: '报销时间',
statusTitle: '报销状态',
statusTabs,
showDocumentType: false
},
[DOCUMENT_SCOPE_REVIEW]: {
searchPlaceholder: '搜索审核单号、事项、当前环节...',
sceneFallbackLabel: '审核场景',
dateLabel: '审核时间',
statusTitle: '审核状态',
statusTabs: ['全部', '审批中', '待补充', '已完成'],
showDocumentType: false
},
[DOCUMENT_SCOPE_ARCHIVE]: {
searchPlaceholder: '搜索归档单号、事项、费用场景...',
sceneFallbackLabel: '归档场景',
dateLabel: '归档时间',
statusTitle: '归档状态',
statusTabs: ['全部', '已付款', '已完成'],
showDocumentType: false
}
}
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
const documentTypeOptions = [
{ value: DOCUMENT_TYPE_ALL, label: '单据类型' },
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
]
const props = defineProps({
filteredRequests: { type: Array, required: true },
hasData: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
error: { type: String, default: '' }
})
const emit = defineEmits([
'open-document',
'create-request',
'create-application',
'reload',
'summary-change'
])
const activeScopeTab = ref(readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs))
const activeStatusTab = ref('全部')
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
const activeScene = ref(SCENE_ALL)
const openFilterKey = ref('')
const listKeyword = ref('')
const datePopover = ref(false)
const rangeStart = ref('')
const rangeEnd = ref('')
const appliedStart = ref('')
const appliedEnd = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const archiveRows = ref([])
const approvalRows = ref([])
const supportingLoading = ref(false)
const supportingError = ref('')
const viewedDocumentKeys = ref(readViewedDocumentKeys())
const activeFilterConfig = computed(() =>
FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION]
)
const showDocumentTypeFilter = computed(() => Boolean(activeFilterConfig.value.showDocumentType))
const documentTypeFilterLabel = computed(() =>
documentTypeOptions.find((item) => item.value === activeDocumentType.value)?.label || '单据类型'
)
const statusFilterOptions = computed(() =>
activeFilterConfig.value.statusTabs.map((tab) => ({
value: tab,
label: tab === '全部' ? '全部状态' : tab
}))
)
const dateRangeLabel = computed(() => {
if (appliedStart.value && appliedEnd.value) {
return `${appliedStart.value} ~ ${appliedEnd.value}`
}
return activeFilterConfig.value.dateLabel
})
const ownedRows = computed(() =>
excludeArchivedDocumentRows(
props.filteredRequests
.map((item) => buildDocumentRow(item, { source: 'owned' }))
.filter(Boolean)
)
)
const nonArchivedRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value]))
const applicationScopeRows = computed(() => prepareApplicationScopeRows(ownedRows.value))
const scopeNewCountMap = computed(() => ({
[DOCUMENT_SCOPE_ALL]: countNewDocuments(nonArchivedRows.value, viewedDocumentKeys.value),
[DOCUMENT_SCOPE_APPLICATION]: countNewDocuments(filterApplicationScopeNewRows(applicationScopeRows.value), viewedDocumentKeys.value),
[DOCUMENT_SCOPE_REIMBURSEMENT]: countNewDocuments(ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT), viewedDocumentKeys.value),
[DOCUMENT_SCOPE_REVIEW]: countNewDocuments(approvalRows.value, viewedDocumentKeys.value),
[DOCUMENT_SCOPE_ARCHIVE]: countNewDocuments(archiveRows.value, viewedDocumentKeys.value)
}))
const scopeTabItems = computed(() =>
scopeTabs.map((tab) => ({
value: tab,
label: tab,
badgeCount: scopeNewCountMap.value[tab] || 0
}))
)
const activeScopeRows = computed(() => {
if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value
if (activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION) {
return applicationScopeRows.value
}
if (activeScopeTab.value === DOCUMENT_SCOPE_REIMBURSEMENT) {
return ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT)
}
if (activeScopeTab.value === DOCUMENT_SCOPE_REVIEW) {
return approvalRows.value
}
if (activeScopeTab.value === DOCUMENT_SCOPE_ARCHIVE) {
return archiveRows.value
}
return nonArchivedRows.value
})
const sceneFilterOptions = computed(() => {
const sceneMap = new Map([[SCENE_ALL, activeFilterConfig.value.sceneFallbackLabel]])
activeScopeRows.value.forEach((row) => {
if (row.typeCode && row.typeLabel) {
sceneMap.set(row.typeCode, row.typeLabel)
}
})
return Array.from(sceneMap, ([value, label]) => ({ value, label }))
})
const sceneFilterLabel = computed(() =>
sceneFilterOptions.value.find((item) => item.value === activeScene.value)?.label || activeFilterConfig.value.sceneFallbackLabel
)
const statusFilterLabel = computed(() =>
statusFilterOptions.value.find((item) => item.value === activeStatusTab.value)?.label || '全部状态'
)
const filteredRows = computed(() => {
const keyword = listKeyword.value.trim().toLowerCase()
return activeScopeRows.value.filter((row) => {
const matchesKeyword = !keyword || [
row.documentNo,
row.documentTypeLabel,
row.typeLabel,
row.initiatorName,
row.reason,
row.node,
row.statusLabel
].filter(Boolean).join('').toLowerCase().includes(keyword)
const matchesDocumentType =
!showDocumentTypeFilter.value
|| activeDocumentType.value === DOCUMENT_TYPE_ALL
|| row.documentTypeCode === activeDocumentType.value
const matchesScene = activeScene.value === SCENE_ALL || row.typeCode === activeScene.value
const matchesStatus = matchesStatusTab(row, activeStatusTab.value)
const matchesDateRange = matchesAppliedDateRange(row)
return matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange
})
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
})
const documentLoadingSource = computed(() => (props.loading || supportingLoading.value) && !visibleRows.value.length)
const visibleDocumentLoading = useMinimumVisibleState(documentLoadingSource, {
minVisibleMs: DOCUMENT_LOADING_MIN_VISIBLE_MS
})
const showLoading = computed(() => visibleDocumentLoading.value)
const showError = computed(() => Boolean(props.error) && !visibleRows.value.length)
const errorMessage = computed(() => props.error || supportingError.value || '单据中心加载失败。')
const showEmpty = computed(() => !showLoading.value && !showError.value && visibleRows.value.length === 0)
const showTable = computed(() => !showLoading.value && !showError.value && visibleRows.value.length > 0)
const showStayTimeColumn = computed(() =>
[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REVIEW].includes(activeScopeTab.value)
)
const documentSummary = computed(() => {
const rows = nonArchivedRows.value
return {
total: rows.length,
toSubmit: rows.filter((row) => ['draft', 'pending_submit'].includes(row.statusGroup)).length,
toProcess: approvalRows.value.length,
archived: archiveRows.value.length
}
})
const emptyState = computed(() => {
const filtered = hasActiveFilters()
if (
activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION
|| activeDocumentType.value === DOCUMENT_TYPE_APPLICATION
) {
return {
eyebrow: '申请单',
title: '当前还没有申请单数据',
desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
icon: 'mdi mdi-file-sign-outline',
actionLabel: '',
actionIcon: '',
tone: 'theme',
artLabel: 'APPLY',
tips: ['申请、报销、审批与归档统一在此查看', '申请批准后可继续发起报销']
}
}
return {
eyebrow: filtered ? '筛选结果为空' : '单据中心',
title: filtered ? '没有符合当前条件的单据' : `${activeScopeTab.value}”里暂时没有单据`,
desc: filtered
? '可以清空当前分类下的筛选条件后再看看。'
: '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
actionLabel: '',
actionIcon: '',
tone: 'theme',
artLabel: filtered ? 'FILTER' : 'DOCS',
tips: ['单据中心已接入当前报销单据', '归档视角会同步已归档数据']
}
})
function resolveArchivedDocumentNode(normalized, documentTypeCode) {
if (documentTypeCode === DOCUMENT_TYPE_APPLICATION) {
return '申请归档'
}
if (normalized.status === 'paid' || normalized.approvalStatus === '已付款') {
return '已付款'
}
return normalized.node || normalized.workflowNode || '财务归档'
}
function resolveArchivedStatusLabel(normalized) {
if (normalized.status === 'paid' || normalized.approvalStatus === '已付款' || normalized.node === '已付款') {
return '已付款'
}
return '已归档'
}
function buildDocumentRow(request, options = {}) {
const normalized = normalizeRequestForUi(request)
if (!normalized) {
return null
}
const archived = Boolean(options.archived)
const statusGroup = resolveStatusGroup(normalized, archived)
const statusLabel = archived ? resolveArchivedStatusLabel(normalized) : resolveStatusLabel(normalized, statusGroup)
const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成'
const claimId = normalized.claimId || normalized.id || documentNo
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
const documentTypeLabel =
normalized.documentTypeLabel
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
const initiatorName = String(
normalized.person
|| normalized.employeeName
|| normalized.profileName
|| normalized.applicant
|| request?.employee_name
|| request?.employeeName
|| request?.person
|| ''
).trim() || '待补充'
return {
...normalized,
rawRequest: request,
documentKey: `${options.source || 'owned'}:${claimId || documentNo}`,
documentTypeCode,
documentTypeLabel,
claimId,
documentNo,
initiatorName,
node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
statusGroup,
statusLabel,
statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
source: options.source || 'owned',
archived,
createdAtDisplay: formatDocumentListTime(createdAtSource),
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
isNewDocument: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
sortTime: resolveDocumentSortTime(updatedAtSource)
}
}
function resolveStatusGroup(row, archived) {
if (archived) return 'completed'
if (row.approvalKey === 'draft') return 'draft'
if (row.approvalKey === 'supplement' && row.status === 'returned') return 'pending_submit'
if (row.approvalKey === 'supplement') return 'supplement'
if (row.approvalKey === 'pending_payment') return 'pending_payment'
if (row.approvalKey === 'in_progress') return 'in_progress'
if (row.approvalKey === 'completed') return 'completed'
return 'other'
}
function resolveStatusLabel(row, statusGroup) {
if (statusGroup === 'pending_submit') return '待提交'
if (statusGroup === 'pending_payment') return '待付款'
return row.approval || row.approvalStatus || '处理中'
}
function resolveStatusTone(row, statusGroup) {
if (statusGroup === 'pending_submit') return 'warning'
return row.approvalTone || 'neutral'
}
function matchesStatusTab(row, tab) {
if (activeScopeTab.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) {
return false
}
if (tab === '全部') return true
if (tab === '草稿') return row.statusGroup === 'draft'
if (tab === '待提交') return row.statusGroup === 'pending_submit'
if (tab === '审批中') return row.statusGroup === 'in_progress'
if (tab === '待补充') return row.statusGroup === 'supplement'
if (tab === '待付款') return row.statusGroup === 'pending_payment'
if (tab === '已付款') return row.statusLabel === '已付款' || row.node === '已付款'
if (tab === '已完成') return row.statusGroup === 'completed'
return true
}
function matchesAppliedDateRange(row) {
if (!appliedStart.value || !appliedEnd.value) {
return true
}
const date = extractDateText(row.updatedAt || row.submittedAt || row.createdAt || row.applyTime)
return Boolean(date) && date >= appliedStart.value && date <= appliedEnd.value
}
function mergeDocumentRows(rows) {
const rowMap = new Map()
rows.filter(Boolean).forEach((row) => {
const key = row.claimId || row.documentNo || row.documentKey
const current = rowMap.get(key)
if (!current || resolveSourcePriority(row) >= resolveSourcePriority(current)) {
rowMap.set(key, row)
}
})
return Array.from(rowMap.values()).sort((left, right) => right.sortTime - left.sortTime)
}
function resolveSourcePriority(row) {
if (row.archived) return 3
if (row.source === 'approval') return 2
return 1
}
function hasActiveFilters() {
return Boolean(
listKeyword.value.trim()
|| activeStatusTab.value !== '全部'
|| (showDocumentTypeFilter.value && activeDocumentType.value !== DOCUMENT_TYPE_ALL)
|| activeScene.value !== SCENE_ALL
|| appliedStart.value
|| appliedEnd.value
)
}
function toggleFilter(key) {
openFilterKey.value = openFilterKey.value === key ? '' : key
if (openFilterKey.value) {
datePopover.value = false
}
}
function toggleDatePopover() {
datePopover.value = !datePopover.value
if (datePopover.value) {
openFilterKey.value = ''
}
}
function selectDocumentType(value) {
activeDocumentType.value = value
openFilterKey.value = ''
}
function selectScene(value) {
activeScene.value = value
openFilterKey.value = ''
}
function selectStatusTab(value) {
activeStatusTab.value = value
openFilterKey.value = ''
}
function applyDateRange() {
if (!rangeStart.value || !rangeEnd.value) {
return
}
appliedStart.value = rangeStart.value
appliedEnd.value = rangeEnd.value
datePopover.value = false
}
function clearDateRange() {
rangeStart.value = ''
rangeEnd.value = ''
appliedStart.value = ''
appliedEnd.value = ''
datePopover.value = false
}
function resetFilters() {
activeStatusTab.value = '全部'
activeDocumentType.value = DOCUMENT_TYPE_ALL
activeScene.value = SCENE_ALL
listKeyword.value = ''
clearDateRange()
openFilterKey.value = ''
currentPage.value = 1
}
function handleEmptyAction() {
if (activeDocumentType.value === DOCUMENT_TYPE_APPLICATION) {
emit('create-application')
return
}
if (hasActiveFilters()) {
resetFilters()
return
}
emit('create-request')
}
function changePageSize(size) {
pageSize.value = size
currentPage.value = 1
}
function openDocument(row) {
writeDocumentScope(activeScopeTab.value, scopeTabs)
viewedDocumentKeys.value = markDocumentViewed(row, viewedDocumentKeys.value)
emit('open-document', row.rawRequest || row)
}
async function loadSupportingRows() {
supportingLoading.value = true
supportingError.value = ''
const [approvalResult, archiveResult] = await Promise.allSettled([
fetchApprovalExpenseClaims(),
fetchArchivedExpenseClaims()
])
if (approvalResult.status === 'fulfilled') {
approvalRows.value = excludeArchivedDocumentRows(
Array.isArray(approvalResult.value)
? approvalResult.value
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'approval' }))
.filter(Boolean)
: []
)
} else {
approvalRows.value = []
}
if (archiveResult.status === 'fulfilled') {
archiveRows.value = Array.isArray(archiveResult.value)
? archiveResult.value
.map((item) => mapExpenseClaimToRequest(item))
.map((item) => buildDocumentRow(item, { source: 'archive', archived: true }))
.filter(Boolean)
: []
} else {
archiveRows.value = []
supportingError.value = archiveResult.reason instanceof Error
? archiveResult.reason.message
: '归档数据加载失败。'
}
supportingLoading.value = false
}
function reloadAll() {
emit('reload')
void loadSupportingRows()
}
watch(
[activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd],
() => {
currentPage.value = 1
}
)
watch(activeFilterConfig, () => {
openFilterKey.value = ''
datePopover.value = false
if (!showDocumentTypeFilter.value) {
activeDocumentType.value = DOCUMENT_TYPE_ALL
}
if (!statusFilterOptions.value.some((item) => item.value === activeStatusTab.value)) {
activeStatusTab.value = '全部'
}
})
watch(sceneFilterOptions, (options) => {
if (!options.some((item) => item.value === activeScene.value)) {
activeScene.value = SCENE_ALL
}
})
watch(documentSummary, (summary) => {
emit('summary-change', summary)
}, { immediate: true })
onMounted(() => {
void loadSupportingRows()
})
</script>
<style scoped src="../assets/styles/views/documents-center-view.css"></style>