Files
client-web/src/modules/finance/views/FinanceBillDetailView.vue
T
2026-09-18 16:48:35 +08:00

86 lines
9.5 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.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ApiError } from '@/api/http'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { useAuthStore } from '@/modules/auth/public'
import { InboundCodeAssociationDialog } from '@/modules/first-leg/public'
import { confirmMerchantFinanceStatement, getMerchantFinanceBill, getMerchantFinanceBills, getMerchantFinanceReceipts } from '../api/finance'
import type { FinanceBillDetail, FinanceBillSummary, FinanceBillStatus, FinanceCustomerConfirmationStatus } from '../contracts'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const billNo = computed(() => String(route.params.billNo || ''))
const loading = ref(false)
const errorMessage = ref('')
const detail = ref<FinanceBillDetail | null>(null)
const confirming = ref(false)
const inboundAssociationVisible = ref(false)
const inboundAssociationCode = ref('')
const canConfirm = computed(() => auth.hasPermission('finance:bill:confirm'))
const lineColumns: DataTableColumn[] = [
{ key: 'inboundCode', label: '入仓码', slotName: 'inboundCode', minWidth: 180 },
{ prop: 'feeName', label: '费用项目', minWidth: 220 },
{ key: 'amount', label: '金额', slotName: 'amount', width: 150, align: 'right', headerAlign: 'right' },
]
const receiptColumns: DataTableColumn[] = [
{ key: 'receivedAt', label: '收款日期', slotName: 'receivedAt', width: 130 },
{ prop: 'receiptNo', label: '收款编号', minWidth: 180 },
{ key: 'allocatedAmount', label: '本次金额', slotName: 'allocatedAmount', width: 150, align: 'right', headerAlign: 'right' },
{ key: 'method', label: '方式', slotName: 'method', minWidth: 120 },
]
function formatAmount(value?: string | null) { const number = Number(value); return Number.isFinite(number) ? number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : value || '0.00' }
function formatDate(value?: string | null) { return value ? value.slice(0, 10) : '—' }
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—' }
function statusLabel(status: FinanceBillStatus) { return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废' } as Record<string, string>)[status] || status }
function statusType(status: FinanceBillStatus) { return ({ ISSUED: 'info', PARTIALLY_PAID: 'warning', PAID: 'success', VOIDED: 'danger', DRAFT: 'info' } as const)[status] }
function confirmationLabel(status: FinanceCustomerConfirmationStatus) { return ({ NOT_REQUIRED: '无需确认', PENDING_CONFIRMATION: '待确认', CONFIRMED: '已确认' })[status] }
function paymentMethodLabel(method?: string | null) { return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[method || ''] || '其他' }
function messageOf(error: unknown) { return error instanceof ApiError ? error.message : '账单详情加载失败,请稍后重试' }
async function load() {
if (!billNo.value) return
loading.value = true
errorMessage.value = ''
try {
const result = await getMerchantFinanceBills({ keyword: billNo.value, page: 1, size: 100 })
const summary = result.records.find(item => item.billNo === billNo.value)
if (!summary) throw new Error('未找到该账单')
const [bill, receipts] = await Promise.all([getMerchantFinanceBill(summary.id), getMerchantFinanceReceipts(summary.id)])
detail.value = { ...bill, receipts }
} catch (error) { detail.value = null; errorMessage.value = error instanceof Error ? error.message : messageOf(error) }
finally { loading.value = false }
}
async function confirmStatement(statement: FinanceBillSummary) {
if (!canConfirm.value || statement.customerConfirmationStatus !== 'PENDING_CONFIRMATION' || confirming.value) return
try { await ElMessageBox.confirm(`确认账单 ${statement.billNo}(¥ ${formatAmount(statement.totalAmount)})金额与费用明细无误?`, '确认客户账单', { type: 'warning', confirmButtonText: '确认无误', cancelButtonText: '取消' }) } catch { return }
confirming.value = true
try { await confirmMerchantFinanceStatement(statement.id, statement.version); ElMessage.success('账单已确认'); await load() }
catch (error) { ElMessage.error(messageOf(error)) }
finally { confirming.value = false }
}
function openInboundAssociation(value?: string | null) { if (!value) return; inboundAssociationCode.value = value; inboundAssociationVisible.value = true }
</script>
<template>
<article v-loading="loading" class="bill-detail-surface">
<template v-if="detail">
<header class="bill-detail-header"><button type="button" class="bill-detail-back" @click="router.push({ name: 'finance-bills' })"><ArrowLeft :size="16" />返回列表</button><div class="bill-detail-title"><strong>{{ detail.billNo }}</strong><span>出账 {{ formatDate(detail.issueDate) }} · 到期 {{ formatDate(detail.dueDate) }}</span></div><div class="bill-detail-actions"><el-tag :type="statusType(detail.status)" effect="light">{{ statusLabel(detail.status) }}</el-tag><el-tag effect="light">{{ confirmationLabel(detail.customerConfirmationStatus) }}</el-tag><el-button v-if="detail.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm" type="primary" :loading="confirming" @click="confirmStatement(detail)">确认账单</el-button></div></header>
<section class="bill-detail-section"><h3>账单概览</h3><dl class="bill-detail-grid"><div><dt>账单金额</dt><dd>¥ {{ formatAmount(detail.totalAmount) }}</dd></div><div><dt>已收金额</dt><dd>¥ {{ formatAmount(detail.receivedAmount) }}</dd></div><div><dt>未收余额</dt><dd class="amount-warning">¥ {{ formatAmount(detail.outstandingAmount) }}</dd></div><div><dt>客户确认</dt><dd>{{ confirmationLabel(detail.customerConfirmationStatus) }}</dd></div><div><dt>确认成员</dt><dd>{{ detail.customerConfirmedByName || '—' }}</dd></div><div><dt>确认时间</dt><dd>{{ formatDateTime(detail.customerConfirmedAt) }}</dd></div></dl></section>
<section class="bill-detail-section"><h3>费用明细</h3><DataTable :data="detail.lines" :columns="lineColumns" row-key="id" empty-text="暂无费用明细"><template #inboundCode="{ row }"><el-button v-if="row.inboundCode" link type="primary" @click="openInboundAssociation(row.inboundCode)">{{ row.inboundCode }}</el-button><span v-else>—</span></template><template #amount="{ row }">¥ {{ formatAmount(row.amount) }}</template></DataTable></section>
<section class="bill-detail-section"><h3>收款记录</h3><DataTable :data="detail.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收款记录"><template #receivedAt="{ row }">{{ formatDate(row.receivedAt) }}</template><template #allocatedAmount="{ row }">¥ {{ formatAmount(row.allocatedAmount) }}</template><template #method="{ row }">{{ paymentMethodLabel(row.method) }}</template></DataTable></section>
<p class="bill-detail-notice">如对账单内容有疑问,请先联系平台财务,核对无误后再确认。</p>
</template>
<div v-else-if="errorMessage" class="bill-detail-error" role="alert"><strong>{{ errorMessage }}</strong><el-button @click="load">重新加载</el-button></div>
</article>
<InboundCodeAssociationDialog v-model="inboundAssociationVisible" :inbound-code="inboundAssociationCode" />
</template>
<style scoped>
.bill-detail-surface{overflow:hidden;min-height:240px;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff}.bill-detail-header{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:14px;min-height:64px;padding:10px 16px;border-bottom:1px solid var(--client-border)}.bill-detail-back{display:inline-flex;align-items:center;gap:5px;padding:5px 0;border:0;background:transparent;color:var(--el-color-primary);cursor:pointer}.bill-detail-title{display:flex;min-width:0;flex-direction:column;gap:3px}.bill-detail-title strong{overflow:hidden;color:#1f2d45;font-size:18px;text-overflow:ellipsis;white-space:nowrap}.bill-detail-title span{color:var(--client-muted);font-size:12px}.bill-detail-actions{display:flex;align-items:center;gap:8px}.bill-detail-section+.bill-detail-section{border-top:1px solid var(--client-border)}.bill-detail-section>h3{display:flex;min-height:40px;align-items:center;margin:0;padding:0 16px;border-bottom:1px solid var(--client-border);background:var(--client-header-bg);font-size:14px}.bill-detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));margin:0}.bill-detail-grid div{display:grid;min-height:42px;grid-template-columns:88px minmax(0,1fr);align-items:center;gap:8px;padding:5px 12px;border-right:1px solid var(--client-border);border-bottom:1px solid var(--client-border)}.bill-detail-grid dt{color:var(--client-muted);font-size:12px}.bill-detail-grid dd{margin:0;overflow:hidden;color:var(--client-text);font-size:13px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.amount-warning{color:#b45309!important}.bill-detail-notice{margin:0;padding:10px 14px;border-top:1px solid var(--client-border);background:#f5faf8;color:#45615d;font-size:12px}.bill-detail-error{display:flex;min-height:220px;align-items:center;justify-content:center;gap:12px;color:#b42318}@media(max-width:1100px){.bill-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.bill-detail-header{grid-template-columns:1fr auto}.bill-detail-back{grid-column:1/-1}.bill-detail-grid{grid-template-columns:1fr}}
</style>