Files
admin-web/src/modules/finance/pages/FinanceCustomerArapView.vue
T
2026-08-11 18:43:56 +08:00

117 lines
11 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, FileCheck2, Link2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listFinanceFeePool } from '../api/finance'
interface ArapRow {
id: string
customerName: string
direction: 'RECEIVABLE' | 'PAYABLE'
documentNo: string
sourceType: string
sourceId: string
orderId: string
inboundId: string
containerId: string
feeName: string
amount: number
status: string
occurredAt: string
}
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null)
const filters = reactive<QueryModel>({ customerId: '', direction: '', status: '', keyword: '' })
const allRows = ref<ArapRow[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const drawerOpen = ref(false)
const selected = ref<ArapRow | null>(null)
const page = ref(1)
const pageSize = ref(20)
const canView = computed(() => access.can('platform:finance:bill:view'))
const canViewSource = computed(() => access.can('platform:finance:fee-source:view'))
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
const canSettleSource = computed(() => access.can('platform:finance:fee-source:settlement'))
const stateError = computed(() => customersError.value || loadError.value)
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 260, placeholder: '请选择客户', props: { 'aria-label': '客户', filterable: true, loading: customersLoading.value }, options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id), disabled: item.status !== 'ACTIVE' })) },
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '全部', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '草稿', value: 'DRAFT' }, { label: '已出账', value: 'ISSUED' }, { label: '部分收款', value: 'PARTIALLY_PAID' }, { label: '已收清', value: 'PAID' }, { label: '作废', value: 'VOIDED' }, { label: '已登记', value: 'RECORDED' }] },
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '单号/费用/来源ID', props: { 'aria-label': '关键词' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ key: 'customerName', label: '客户', minWidth: 180 }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120 },
{ key: 'sourceId', label: '来源追溯', minWidth: 200 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 },
{ key: 'status', label: '状态', width: 120, slotName: 'status' }, { key: 'occurredAt', label: '发生日期', width: 130 },
]
const filteredRows = computed(() => {
const keyword = String(filters.keyword || '').trim().toLowerCase(); const direction = String(filters.direction || ''); const status = String(filters.status || '')
return allRows.value.filter((row) => (!direction || row.direction === direction) && (!status || row.status === status) && (!keyword || [row.documentNo, row.sourceId, row.feeName].some((value) => value.toLowerCase().includes(keyword))))
})
const rows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
async function loadCustomers() {
customersLoading.value = true; customersError.value = null
try { const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1; do { const result = await listPlatformTenants({ page: currentPage, size: 200 }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages); customers.value = records }
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
finally { customersLoading.value = false }
}
async function load(model: QueryModel = filters) {
Object.assign(filters, model); page.value = 1; allRows.value = []; loadError.value = null
const customerId = String(filters.customerId || ''); if (!customerId) return
const customerName = customers.value.find((item) => String(item.id) === customerId)?.tenantName || `客户 ${customerId}`
loading.value = true
try {
const direction = String(filters.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
const items = await listFinanceFeePool(customerId, direction || undefined)
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, customerName, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt }))
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
finally { loading.value = false }
}
function openDetail(row: Record<string, unknown>) { selected.value = row as unknown as ArapRow; drawerOpen.value = true }
function statusLabel(status: string) { return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废', RECORDED: '已登记' } as Record<string, string>)[status] || status }
onMounted(() => void loadCustomers())
</script>
<template>
<AdminPage as="div" class="finance-arap-page" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
<ListPageLayout v-else aria-label="客户应收应付">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="load" /></template>
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
<section v-if="!customersError && !loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="请选择客户查询应收应付">
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :disabled="!canViewSource" :title="canViewSource ? '费用来源详情接口尚未提供' : '无费用来源查看权限'" @click="openDetail(row)"><Eye :size="15" />摘要</el-button><el-button v-if="canReviewSource" link type="primary" disabled title="审核接口尚未提供,暂不可操作"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" disabled title="参与结算接口尚未提供,暂不可操作"><Link2 :size="15" />结算</el-button></div></template>
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ statusLabel(row.status) }}</el-tag></template>
<template #pagination-left>费用来源来自真实账单明细、入库费用快照和成本台账 · CNY</template>
</DataTable></section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" title="费用来源摘要" size="min(680px, 94vw)"><el-descriptions v-if="selected" :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ selected.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(selected.status) }}</el-descriptions-item><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="费用单据">{{ selected.documentNo }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ selected.sourceType }}</el-descriptions-item><el-descriptions-item label="来源追溯">{{ selected.sourceId }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ selected.feeName }}</el-descriptions-item><el-descriptions-item label="金额">{{ selected.amount }} CNY</el-descriptions-item><el-descriptions-item label="发生日期">{{ selected.occurredAt }}</el-descriptions-item></el-descriptions><el-alert type="info" :closable="false" title="费用来源详情、审核和参与结算接口尚未提供,当前仅展示列表摘要。" /><template #footer><el-button @click="drawerOpen = false">关闭</el-button></template></AppDrawer>
</AdminPage>
</template>
<style scoped>
.finance-arap-page { min-width: 0; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.row-actions .el-button { margin-left: 0; }
.arap-detail { margin-bottom: 16px; }
@media (max-width: 720px) { :deep(.el-drawer__footer .el-button) { min-height: 44px; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
@media (max-width: 480px) { :deep(.arap-detail .el-descriptions__cell) { display: block; width: 100%; } }
</style>