Files
X-Financial/web/src/views/DocumentsCenterView.vue
2026-05-29 13:17:39 +08:00

836 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>