Files
admin-web/src/modules/finance/pages/FinanceReceivableStatisticsView.vue
T

174 lines
13 KiB
Vue
Raw Normal View History

2026-08-19 09:14:35 +08:00
<script setup lang="ts">
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-09-15 09:58:15 +08:00
import '@/styles/adminDensity.css'
import '../financeDensity.css'
2026-08-19 09:14:35 +08:00
import { computed, onMounted, ref } from 'vue'
import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
2026-08-19 09:14:35 +08:00
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
2026-08-19 09:14:35 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { getReceivableStatistics } from '../api/finance'
import type { ReceivableStatisticsResult, ReceivableStatisticsRow } from '../types'
const now = new Date()
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const access = useAccessPreviewStore()
const route = useRoute()
2026-08-19 09:14:35 +08:00
const router = useRouter()
const month = ref(defaultMonth)
const customerId = ref('')
const customers = ref<PlatformTenantResource[]>([])
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const exporting = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const result = ref<ReceivableStatisticsResult | null>(null)
const canView = computed(() => access.can('platform:finance:receivable-statistics:list'))
const canPrint = computed(() => route.name === 'finance-receivable-statistics' && canView.value && !loading.value && !error.value && Boolean(result.value))
2026-08-19 09:14:35 +08:00
const canExport = computed(() => access.can('platform:finance:receivable-statistics:export') && access.can('platform:download:list'))
const rows = computed<ReceivableStatisticsRow[]>(() => result.value?.records ?? [])
2026-09-02 19:42:27 +08:00
const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName])))
2026-08-19 09:14:35 +08:00
const columns: DataTableColumn[] = [
2026-09-02 19:42:27 +08:00
{ key: 'customerName', label: '应收单位', minWidth: 240, slotName: 'customerName' },
2026-08-19 09:14:35 +08:00
{ key: 'currency', label: '币种', width: 100 },
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, align: 'right', slotName: 'totalReceivable' },
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, align: 'right', slotName: 'totalReceived' },
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, align: 'right', slotName: 'totalOutstanding' },
2026-08-19 09:14:35 +08:00
]
async function load() {
if (!canView.value || !month.value) return
loading.value = true
error.value = null
try {
result.value = await getReceivableStatistics({ month: month.value, customerId: customerId.value || undefined, page: page.value, pageSize: pageSize.value })
} catch (reason) { error.value = getApiErrorDetails(reason, '应收款统计加载失败,请重试') }
finally { loading.value = false }
}
async function loadCustomers() {
try {
const records: PlatformTenantResource[] = []
let current = 1; let pages = 1
do { const response = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); records.push(...response.records); pages = response.pages; current += 1 } while (current <= pages)
customers.value = records
} catch { /* 客户筛选失败不阻断统计,仍可查询全部客户。 */ }
}
function search() { page.value = 1; void load() }
function reset() { month.value = defaultMonth; customerId.value = ''; page.value = 1; void load() }
function printCurrent() { if (canPrint.value) window.print() }
2026-08-19 09:14:35 +08:00
function statisticsRowKey(row: ReceivableStatisticsRow) { return `${row.customerId}:${row.customerName}` }
2026-09-02 19:42:27 +08:00
function displayCustomerName(value: unknown) {
const row = value as ReceivableStatisticsRow
const masterName = customerNameById.value.get(String(row.customerId))?.trim()
const snapshot = row.customerName?.trim()
if (!snapshot || /^客户\s*\d+$/.test(snapshot) || snapshot === String(row.customerId)) return masterName || '—'
return snapshot
}
2026-08-19 09:14:35 +08:00
async function exportCurrent() {
exporting.value = true
try {
const task = await createPlatformExportTask({ exportType: 'FINANCE_RECEIVABLE_STATISTICS', format: 'XLSX', taskName: `应收款统计表-${month.value}`,
parameters: { month: month.value, ...(customerId.value ? { customerId: customerId.value } : {}) } })
2026-09-15 09:58:15 +08:00
const open = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo, dense: true })
2026-08-19 09:14:35 +08:00
if (open) await router.push({ name: 'download-center' })
} catch (reason) { ElMessage.error(getApiErrorMessage(reason, '应收款统计导出任务创建失败')) }
finally { exporting.value = false }
}
function money(value?: string) { return Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
onMounted(() => { void loadCustomers(); void load() })
</script>
<template>
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="compact">
2026-08-19 09:14:35 +08:00
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
<template v-else>
<section class="finance-metric-grid" aria-label="CNY应收汇总" v-if="!error" v-loading="loading" :aria-busy="loading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">应收总额</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceivable) : '—' }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已收合计</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceived) : '—' }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">余额未收</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalOutstanding) : '—' }}</strong><span>CNY</span></div>
</article>
</section>
2026-10-21 13:22:42 +08:00
<div class="workbench-table is-page-surface"><DataTable class="statistics-table" appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
2026-09-30 14:01:58 +08:00
<template #toolbar-start><h1>应收款统计表</h1></template>
<template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选">
<div class="query-field"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
<div class="query-field"><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
2026-08-19 09:14:35 +08:00
<div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div>
2026-09-30 14:01:58 +08:00
</section></template>
<template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #toolbar-actions><div class="report-actions"><el-button :disabled="!canPrint" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
2026-09-02 19:42:27 +08:00
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
2026-08-19 09:14:35 +08:00
<template #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template>
2026-10-20 23:34:11 +08:00
</DataTable></div>
2026-08-19 09:14:35 +08:00
</template>
</AdminPage>
<Teleport to="body">
<section v-if="canPrint && result" class="receivable-statistics-print" aria-label="应收款统计打印报表">
<header>
<h1>应收款统计表</h1>
<p>统计月份:{{ result.statisticsMonth }} · 币种:CNY</p>
</header>
<table>
<colgroup><col class="print-customer-column" /><col class="print-currency-column" /><col /><col /><col /></colgroup>
<thead><tr><th scope="col">应收单位</th><th scope="col">币种</th><th scope="col">应收总额(CNY)</th><th scope="col">已收合计(CNY)</th><th scope="col">余额未收(CNY)</th></tr></thead>
<tbody>
<tr v-for="row in rows" :key="statisticsRowKey(row)"><td>{{ displayCustomerName(row) }}</td><td>{{ row.currency }}</td><td>{{ money(row.totalReceivable) }}</td><td>{{ money(row.totalReceived) }}</td><td>{{ money(row.totalOutstanding) }}</td></tr>
<tr v-if="!rows.length"><td colspan="5">当前月份暂无应收数据</td></tr>
</tbody>
<tfoot><tr><th scope="row" colspan="2">统计汇总</th><td>{{ money(result.totalReceivable) }}</td><td>{{ money(result.totalReceived) }}</td><td>{{ money(result.totalOutstanding) }}</td></tr></tfoot>
</table>
<p>数据第 {{ result.page }} 页 · 共 {{ result.totalCustomers }} 家应收单位</p>
</section>
</Teleport>
2026-08-19 09:14:35 +08:00
</template>
<style scoped>
2026-09-15 09:58:15 +08:00
.statistics-query { display: flex; align-items: end; gap: var(--admin-space-5); padding: var(--admin-space-6); }
.query-field { min-width: 220px; }
2026-08-19 09:14:35 +08:00
.query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; }
2026-09-15 09:58:15 +08:00
.query-actions, .report-actions { display: flex; gap: var(--admin-space-3); }
2026-08-19 09:14:35 +08:00
.report-actions { margin-left: auto; }
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
2026-09-15 09:58:15 +08:00
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
2026-08-19 09:14:35 +08:00
.statistics-table { overflow: hidden; }
.receivable-statistics-print { display: none; }
2026-08-19 09:14:35 +08:00
@media print {
:global(body:has(> .receivable-statistics-print) > :not(.receivable-statistics-print)) { display: none !important; }
:global(body:has(> .receivable-statistics-print)) { min-width: 0; height: auto; margin: 0; overflow: visible; background: #fff; }
.receivable-statistics-print { display: block; width: 100%; color: #000; font: 10pt/1.5 Arial, 'Microsoft YaHei', sans-serif; }
.receivable-statistics-print h1 { margin: 0 0 8pt; text-align: center; font-size: 18pt; }
.receivable-statistics-print p { margin: 8pt 0; }
.receivable-statistics-print table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.receivable-statistics-print col { width: 18%; }
.receivable-statistics-print .print-customer-column { width: 38%; }
.receivable-statistics-print .print-currency-column { width: 8%; }
.receivable-statistics-print th, .receivable-statistics-print td { padding: 5pt; border: 1px solid #999; text-align: left; overflow-wrap: anywhere; }
.receivable-statistics-print tbody td:nth-child(n+3), .receivable-statistics-print thead th:nth-child(n+3), .receivable-statistics-print tfoot td { text-align: right; font-variant-numeric: tabular-nums; }
.receivable-statistics-print thead { display: table-header-group; }
.receivable-statistics-print tfoot { display: table-row-group; font-weight: 700; }
.receivable-statistics-print tr { break-inside: avoid; }
2026-08-19 09:14:35 +08:00
}
</style>