205 lines
11 KiB
Vue
205 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
import { RefreshCcw } from '@lucide/vue'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { useRouter } from 'vue-router'
|
|
import { ApiError } from '@/api/http'
|
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
|
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
|
import type { TableAction } from '@/components/common/tableActions'
|
|
import { useAuthStore } from '@/modules/auth/public'
|
|
import { confirmMerchantFinanceStatement, getMerchantFinanceBills } from '../api/finance'
|
|
import type { FinanceBillQuery, FinanceBillStatus, FinanceBillSummary, FinanceCustomerConfirmationStatus } from '../contracts'
|
|
|
|
const loading = ref(false)
|
|
const auth = useAuthStore()
|
|
const router = useRouter()
|
|
const errorMessage = ref('')
|
|
const bills = ref<FinanceBillSummary[]>([])
|
|
const confirmingBillId = ref('')
|
|
const confirmationKeys = new Map<string, string>()
|
|
const page = ref(1)
|
|
const pageSize = ref(20)
|
|
const total = ref(0)
|
|
const totals = reactive({ billed: '0', received: '0', outstanding: '0' })
|
|
const filters = reactive<FinanceBillQuery>({ keyword: '', status: undefined, page: 1, size: 20 })
|
|
|
|
const statusOptions: Array<{ label: string; value: FinanceBillStatus }> = [
|
|
{ label: '已出账', value: 'ISSUED' },
|
|
{ label: '部分收款', value: 'PARTIALLY_PAID' },
|
|
{ label: '已收清', value: 'PAID' },
|
|
{ label: '作废', value: 'VOIDED' },
|
|
]
|
|
|
|
const queryModel = ref<QueryModel>({ keyword: '', status: '' })
|
|
const queryFields: QueryField[] = [
|
|
{ key: 'keyword', label: '账单编号', type: 'input', placeholder: '请输入账单编号' },
|
|
{ key: 'status', label: '账单状态', type: 'select', placeholder: '全部', options: statusOptions },
|
|
]
|
|
const tableColumns: DataTableColumn[] = [
|
|
{ label: '操作', slotName: 'actions', width: 190, fixed: 'left' },
|
|
{ label: '账单编号', prop: 'billNo', minWidth: 160 },
|
|
{ label: '出账日期', slotName: 'issueDate', width: 120 },
|
|
{ label: '到期日', slotName: 'dueDate', width: 120 },
|
|
{ label: '账单金额', slotName: 'totalAmount', width: 145, align: 'right' },
|
|
{ label: '已收', slotName: 'receivedAmount', width: 145, align: 'right' },
|
|
{ label: '未收余额', slotName: 'outstandingAmount', width: 145, align: 'right' },
|
|
{ label: '状态', slotName: 'status', width: 110 },
|
|
{ label: '客户确认', slotName: 'confirmationStatus', width: 110 },
|
|
]
|
|
|
|
const canConfirm = computed(() => auth.hasPermission('finance:bill:confirm'))
|
|
|
|
function formatAmount(value: string | null | undefined) {
|
|
if (!value) return '0.00'
|
|
const numeric = Number(value)
|
|
return Number.isFinite(numeric) ? numeric.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : value
|
|
}
|
|
|
|
function formatDate(value: string | null | undefined) {
|
|
if (!value) return '-'
|
|
return value.slice(0, 10)
|
|
}
|
|
|
|
function statusLabel(status: FinanceBillStatus) {
|
|
return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废' })[status] ?? status
|
|
}
|
|
|
|
function statusType(status: FinanceBillStatus) {
|
|
return ({ ISSUED: 'info', PARTIALLY_PAID: 'warning', PAID: 'success', VOIDED: 'danger', DRAFT: 'info' })[status] as 'info' | 'warning' | 'success' | 'danger'
|
|
}
|
|
|
|
function confirmationLabel(status: FinanceCustomerConfirmationStatus) {
|
|
return ({ NOT_REQUIRED: '无需确认', PENDING_CONFIRMATION: '待确认', CONFIRMED: '已确认' })[status]
|
|
}
|
|
|
|
function confirmationType(status: FinanceCustomerConfirmationStatus) {
|
|
return ({ NOT_REQUIRED: 'info', PENDING_CONFIRMATION: 'warning', CONFIRMED: 'success' })[status] as 'info' | 'warning' | 'success'
|
|
}
|
|
|
|
function messageOf(error: unknown) {
|
|
return error instanceof ApiError ? error.message : '账单加载失败,请稍后重试'
|
|
}
|
|
|
|
async function loadBills(nextPage = page.value) {
|
|
loading.value = true
|
|
errorMessage.value = ''
|
|
page.value = nextPage
|
|
try {
|
|
const response = await getMerchantFinanceBills({ ...filters, keyword: filters.keyword?.trim() || undefined, page: nextPage, size: pageSize.value })
|
|
bills.value = response.records
|
|
total.value = response.total
|
|
totals.billed = response.totalBilledAmount
|
|
totals.received = response.totalReceivedAmount
|
|
totals.outstanding = response.totalOutstandingAmount
|
|
} catch (error) {
|
|
bills.value = []
|
|
total.value = 0
|
|
totals.billed = '0'
|
|
totals.received = '0'
|
|
totals.outstanding = '0'
|
|
errorMessage.value = messageOf(error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function openDetail(row: FinanceBillSummary) { void router.push({ name: 'finance-bill-detail', params: { billNo: row.billNo } }) }
|
|
|
|
function rowActions(row: object): TableAction[] {
|
|
const statement = row as FinanceBillSummary
|
|
const actions: TableAction[] = [{ key: 'detail', label: '查看', run: () => openDetail(statement) }]
|
|
if (statement.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm.value) {
|
|
actions.push({
|
|
key: 'confirm',
|
|
label: '确认账单',
|
|
loading: confirmingBillId.value === statement.id,
|
|
disabled: Boolean(confirmingBillId.value),
|
|
run: () => confirmStatement(statement),
|
|
})
|
|
}
|
|
return actions
|
|
}
|
|
|
|
async function confirmStatement(statement: FinanceBillSummary) {
|
|
if (!canConfirm.value || statement.customerConfirmationStatus !== 'PENDING_CONFIRMATION' || confirmingBillId.value) return
|
|
try {
|
|
await ElMessageBox.confirm(
|
|
`确认对账单 ${statement.billNo}(¥ ${formatAmount(statement.totalAmount)})的金额与费用明细无误?确认后平台将记录当前登录成员和确认时间。`,
|
|
'确认客户对账单',
|
|
{ type: 'warning', confirmButtonText: '确认无误', cancelButtonText: '取消' },
|
|
)
|
|
} catch {
|
|
return
|
|
}
|
|
confirmingBillId.value = statement.id
|
|
const statementId = statement.id
|
|
let key = confirmationKeys.get(statementId)
|
|
if (!key) {
|
|
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
key = `statement-confirm-${statementId}-${randomPart}`
|
|
confirmationKeys.set(statementId, key)
|
|
}
|
|
try {
|
|
await confirmMerchantFinanceStatement(statementId, statement.version, key)
|
|
confirmationKeys.delete(statementId)
|
|
ElMessage.success('对账单已确认')
|
|
await loadBills(page.value)
|
|
} catch (error) {
|
|
ElMessage.error(error instanceof ApiError ? error.message : '对账单确认失败,请重试')
|
|
} finally {
|
|
confirmingBillId.value = ''
|
|
}
|
|
}
|
|
|
|
function applyQuery(model: QueryModel) {
|
|
filters.keyword = String(model.keyword || '')
|
|
filters.status = (model.status || undefined) as FinanceBillStatus | undefined
|
|
if (page.value !== 1) page.value = 1
|
|
else void loadBills(1)
|
|
}
|
|
|
|
function resetFilters(model: QueryModel) {
|
|
queryModel.value = model
|
|
applyQuery(model)
|
|
}
|
|
|
|
watch([page, pageSize], () => { void loadBills(page.value) })
|
|
onMounted(() => { void loadBills(1) })
|
|
</script>
|
|
|
|
<template>
|
|
<div class="finance-bills-page">
|
|
<section class="finance-summary" aria-label="账单汇总">
|
|
<span>账单总额 <strong>¥ {{ formatAmount(totals.billed) }}</strong></span>
|
|
<span>已收 <strong>¥ {{ formatAmount(totals.received) }}</strong></span>
|
|
<span class="amount-warning">未收 <strong>¥ {{ formatAmount(totals.outstanding) }}</strong></span>
|
|
</section>
|
|
|
|
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="客户账单筛选" :fields="queryFields" :search-on-reset="false" @search="applyQuery" @reset="resetFilters" />
|
|
|
|
<section class="content-card">
|
|
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="bills" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-actions="rowActions" row-key="billNo" empty-text="暂无账单">
|
|
<template v-if="errorMessage" #error><div class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadBills(1)"><RefreshCcw :size="15" />重试</el-button></div></template>
|
|
<template #issueDate="{ row }">{{ formatDate(row.issueDate) }}</template>
|
|
<template #dueDate="{ row }">{{ formatDate(row.dueDate) }}</template>
|
|
<template #totalAmount="{ row }">¥ {{ formatAmount(row.totalAmount) }}</template>
|
|
<template #receivedAmount="{ row }">¥ {{ formatAmount(row.receivedAmount) }}</template>
|
|
<template #outstandingAmount="{ row }"><strong :class="{ 'amount-warning': row.outstandingAmount !== '0' }">¥ {{ formatAmount(row.outstandingAmount) }}</strong></template>
|
|
<template #status="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
|
<template #confirmationStatus="{ row }"><el-tag :type="confirmationType(row.customerConfirmationStatus)">{{ confirmationLabel(row.customerConfirmationStatus) }}</el-tag></template>
|
|
</DataTable>
|
|
</section>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.finance-bills-page { display: flex; flex-direction: column; gap: 12px; }
|
|
.finance-summary { display: flex; flex-wrap: wrap; gap: 8px 26px; color: var(--muted); font-size: 13px; }.finance-summary strong { margin-left: 6px; color: var(--navy); font-size: 18px; }.amount-warning, .amount-warning strong { color: #b45309; }
|
|
.content-card { min-width: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: var(--surface, #fff); }
|
|
.state-panel { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 12px; flex-direction: column; color: var(--muted); text-align: center; }.state-panel p { margin: 0; }.state-panel--error { color: #b42318; background: #fff8f7; border: 1px solid #f5c2bd; border-radius: 6px; }
|
|
.detail-content { min-height: 300px; }.detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }.detail-heading h2 { margin: 0 0 5px; color: var(--navy); font-size: 18px; }.detail-heading p { margin: 0; color: var(--muted); font-size: 13px; }.detail-statuses { display: flex; align-items: center; gap: 8px; }.confirmation-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding: 14px 16px; border: 1px solid #d7e9e4; border-radius: 8px; background: #f3f9f7; }.confirmation-panel strong { color: var(--navy); }.confirmation-panel p { margin: 4px 0 0; color: var(--muted); font-size: 12px; }.detail-section { margin-top: 22px; }.detail-section h3 { margin: 0 0 10px; color: var(--navy); font-size: 14px; }.sub-empty { padding: 22px; color: var(--muted); font-size: 12px; text-align: center; background: #f7fafb; }.readonly-notice { margin: 22px 0 0; padding: 11px 12px; color: #45615d; background: #edf6f4; font-size: 12px; }
|
|
@media (max-width: 720px) { .finance-summary { align-items: flex-start; flex-direction: column; gap: 6px; } }
|
|
</style>
|