Files
admin-web/src/modules/finance/pages/FinanceCustomerStatementsView.vue
T
2026-10-21 13:22:42 +08:00

783 lines
51 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 '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Plus } from '@lucide/vue'
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 StatusBadge from '@/shared/components/StatusBadge.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.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 RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceFeeSourceSummary, FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
selected: boolean
statementLineId: string
feeItemName: string
originalAmount: number
adjustedAmount: number
reason: string
}
type StatementRow = Omit<FinanceStatementReadModel, 'creditDays' | 'settled'> & {
periodStart: string
periodEnd: string
creditDays: number | null
settled: boolean | null
lastPaymentAt: string
remark: string
}
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
const customers = ref<PlatformTenantResource[]>([])
const filters = ref<QueryModel>({
status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [],
settled: '', lastPaymentRange: [], createdRange: [],
})
const appliedFilters = ref<QueryModel>({ ...filters.value })
const rows = ref<StatementRow[]>([])
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const customerError = ref<ApiErrorDetails | null>(null)
const selectedId = ref('')
const detail = ref<FinanceReconciliationDetail | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const activeDetailTab = ref<'lines' | 'payments'>('lines')
const detailCache = new Map<string, FinanceReconciliationDetail>()
const createOpen = ref(false)
const createBusy = ref(false)
const createSourcesLoading = ref(false)
const createSourcesError = ref<ApiErrorDetails | null>(null)
const createError = ref<ApiErrorDetails | null>(null)
const createSourceOptions = ref<FinanceFeeSourceSummary[]>([])
const exportBusy = ref(false)
const actionBusy = ref(false)
const createForm = ref({
generationMode: 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED',
customerId: '',
periodStart: new Date().toISOString().slice(0, 8) + '01',
periodEnd: new Date().toISOString().slice(0, 10),
feeSourceIds: [] as string[],
remark: '',
})
let createSourceLoadSequence = 0
const adjustOpen = ref(false)
const adjustBusy = ref(false)
const adjustError = ref<ApiErrorDetails | null>(null)
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
const adjustReason = ref('')
const adjustLines = ref<AdjustmentRow[]>([])
const canView = computed(() => access.can('platform:finance:customer-statement:list'))
const canListCustomers = computed(() => access.can('platform:customer:list'))
const canViewFeeSource = computed(() => access.can('platform:finance:customer-statement:list'))
const canSelectSources = computed(() => access.can('platform:finance:customer-arap:list'))
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust'))
const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
const canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list'))
const canGenerate = computed(() => canCreate.value && canListCustomers.value)
const selectedCreateSources = computed(() => createSourceOptions.value.filter((item) => createForm.value.feeSourceIds.includes(item.feeSourceId)))
const selectedCreateAmount = computed(() => selectedCreateSources.value.reduce((total, item) => total + Number(item.amount || 0), 0))
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
const isDetailRoute = computed(() => Boolean(detailRouteId.value))
const fields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '对账单状态', width: 180, placeholder: '请选择对账单状态', options: financeBillStatusOptions },
{ key: 'billNo', type: 'input', label: '对账单号', width: 210, placeholder: '请输入对账单号' },
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '请选择客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
{ key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: canViewFeeSource.value ? '请输入柜次号' : '缺少费用来源查看权限', props: { disabled: !canViewFeeSource.value } },
{ key: 'periodStartRange', type: 'daterange', label: '结算起始日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
{ key: 'periodEndRange', type: 'daterange', label: '结算结束日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
{ key: 'settled', type: 'select', label: '是否结清', width: 160, placeholder: '请选择结清状态', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '请选择开始日期', endPlaceholder: '请选择结束日期' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '对账单状态', width: 120, slotName: 'status' },
{ key: 'customerConfirmationStatus', label: '客户确认', width: 130, slotName: 'customerConfirmationStatus' },
{ key: 'billNo', label: '对账单号', minWidth: 180 },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'periodStart', label: '结算起始日', width: 130 },
{ key: 'periodEnd', label: '结算结束日', width: 130 },
{ key: 'creditDays', label: '账期(天)', width: 100 },
{ key: 'lineCount', label: '费用明细数', width: 110 },
{ key: 'sourceCount', label: '来源数量', width: 100 },
{ key: 'currency', label: '货币', width: 86 },
{ key: 'sourceAmount', label: '调整前金额(CNY)', width: 150 },
{ key: 'adjustmentAmount', label: '调整金额(CNY)', width: 145 },
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 },
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 },
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 },
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170, slotName: 'lastPaymentAt' },
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
{ key: 'remark', label: '备注', minWidth: 180 },
{ key: 'createdByName', label: '创建人', width: 130, slotName: 'createdByName' },
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
{ key: 'submittedAt', label: '提交时间', minWidth: 170, slotName: 'submittedAt' },
{ key: 'reviewedAt', label: '审核时间', minWidth: 170, slotName: 'reviewedAt' },
{ key: 'issuedAt', label: '出账时间', minWidth: 170, slotName: 'issuedAt' },
{ key: 'customerConfirmedByName', label: '客户确认人', width: 140, slotName: 'customerConfirmedByName' },
{ key: 'customerConfirmedAt', label: '客户确认时间', minWidth: 170, slotName: 'customerConfirmedAt' },
{ key: 'updatedByName', label: '更新人', width: 130, slotName: 'updatedByName' },
{ key: 'updatedAt', label: '更新时间', minWidth: 170, slotName: 'updatedAt' },
]
const detailLineColumns: DataTableColumn[] = [
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' },
{ key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
{ key: 'sourceNo', label: '业务单号', minWidth: 160 },
{ key: 'orderId', label: '订单', minWidth: 150, slotName: 'orderId' },
{ key: 'inboundCode', label: '入仓号', minWidth: 150, slotName: 'inboundCode' },
{ key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' },
{ key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' },
{ key: 'feeItemName', label: '费用项目', minWidth: 160 },
{ key: 'quantity', label: '计费量', width: 110, slotName: 'quantity' },
{ key: 'unitPrice', label: '单价(CNY)', width: 120, slotName: 'unitPrice' },
{ key: 'sourceAmount', label: '来源金额(CNY)', width: 150 },
{ key: 'amount', label: '账单金额(CNY)', width: 150 },
{ key: 'adjustmentReason', label: '调整说明', minWidth: 190 },
{ key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' },
{ key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' },
{ key: 'periodEnd', label: '结算结束日', width: 130, slotName: 'periodEnd' },
]
const paymentColumns: DataTableColumn[] = [
{ key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' },
{ key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' },
{ key: 'amount', label: '支付金额(CNY)', width: 150 },
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' },
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 },
{ key: 'voucherFileIds', label: '收款凭证', minWidth: 130, slotName: 'voucherFileIds' },
{ key: 'createdByName', label: '登记人', minWidth: 140, slotName: 'createdByName' },
{ key: 'occurredAt', label: '登记时间', width: 170, slotName: 'occurredAt' },
{ key: 'remark', label: '备注', minWidth: 200 },
]
const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName.trim()])))
function isInternalCustomerLabel(value: string, customerId: string) {
const normalized = value.replaceAll(' ', '')
return normalized === customerId || /^客户(?:ID)?\d+$/.test(normalized)
}
function customerDisplayName(customerId: string, value?: string) {
const resolved = customerNameById.value.get(String(customerId))
if (resolved) return resolved
const candidate = String(value || '').trim()
return candidate && !isInternalCustomerLabel(candidate, String(customerId)) ? candidate : '客户名称未提供'
}
function operatorDisplayName(value?: string) {
const candidate = String(value || '').trim()
if (!candidate || /^(?:operator-)?\d+$/i.test(candidate)) return '名称数据缺失'
return candidate
}
function formatDateTime(value?: string) {
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—'
}
function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconciliationDetail): StatementRow {
const latestPayment = current?.paymentHistory.map((item) => item.occurredAt || item.transactionDate).filter(Boolean).sort().at(-1) || row.lastReceiptAt || ''
return {
...row,
periodStart: current?.periodStart || row.periodStart || '', periodEnd: current?.periodEnd || row.periodEnd || row.billDate,
creditDays: current ? current.creditDays : row.creditDays ?? null, settled: current ? current.settled : row.settled ?? null,
lastPaymentAt: latestPayment, remark: current?.remark || row.remark || '',
createdBy: current?.createdBy || row.createdBy, createdByName: current?.createdByName || row.createdByName, createdAt: current?.createdAt || row.createdAt,
updatedBy: current?.updatedBy || row.updatedBy, updatedByName: current?.updatedByName || row.updatedByName, updatedAt: current?.updatedAt || row.updatedAt,
}
}
function stringFilter(key: string) { return String(appliedFilters.value[key] || '').trim().toLowerCase() }
function inDateRange(value: string, key: string) {
const range = appliedFilters.value[key]
if (!Array.isArray(range) || range.length < 2 || !value) return !Array.isArray(range) || range.length < 2
const date = value.slice(0, 10)
return date >= String(range[0]) && date <= String(range[1])
}
const filteredRows = computed(() => rows.value.filter((row) => {
const status = stringFilter('status')
const billNo = stringFilter('billNo')
const settled = stringFilter('settled')
const containerNo = stringFilter('containerNo')
const current = detailCache.get(row.id)
return (!status || String(row.status).toLowerCase() === status)
&& (!billNo || row.billNo.toLowerCase().includes(billNo))
&& (!settled || String(row.settled) === settled)
&& (!containerNo || Boolean(current?.lines.some((line) => line.containerNo?.toLowerCase().includes(containerNo))))
&& inDateRange(row.periodStart, 'periodStartRange')
&& inDateRange(row.periodEnd, 'periodEndRange')
&& inDateRange(row.lastPaymentAt, 'lastPaymentRange')
&& inDateRange(row.createdAt || '', 'createdRange')
}))
const paginatedRows = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
})
async function loadCustomers() {
if (!canListCustomers.value) return
customerError.value = null
try {
const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1
do { const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages)
customers.value = records
} catch (cause) {
customerError.value = getApiErrorDetails(cause, '客户列表加载失败')
}
}
async function load(model?: QueryModel) {
// The list response already contains every normal filter field. Only the
// container-number filter still depends on statement lines from the detail API.
// Avoid loading every statement detail for an ordinary search (N+1 requests).
const hydrateDetails = Boolean(String(model?.containerNo || '').trim())
if (model) {
filters.value = { ...model }
appliedFilters.value = { ...model }
page.value = 1
}
rows.value = []
selectedId.value = ''
detail.value = null
detailCache.clear()
error.value = null
loading.value = true
try {
const statements = await listFinanceStatements(String(appliedFilters.value.customerId || '').trim() || undefined)
const detailErrors: ApiErrorDetails[] = []
const hydrated = await Promise.all(statements.map(async (row) => {
if (!hydrateDetails) return detailToRow(row)
try {
const current = await getReconciliationStatement(row.id)
detailCache.set(row.id, current)
return detailToRow(row, current)
} catch (cause) {
detailErrors.push(getApiErrorDetails(cause, `对账单 ${row.billNo || row.id} 的筛选字段加载失败`))
return detailToRow(row)
}
}))
if (detailErrors.length) {
error.value = detailErrors[0]!
return
}
rows.value = hydrated
} catch (cause) {
error.value = getApiErrorDetails(cause, '客户对账单加载失败,请重试')
} finally {
loading.value = false
}
}
async function fetchDetail(statementId: string, force = false) {
if (!force && detailCache.has(statementId)) return detailCache.get(statementId)!
const current = await getReconciliationStatement(statementId)
detailCache.set(statementId, current)
return current
}
async function loadDetail(statementId: string) {
selectedId.value = statementId
detail.value = null
detailError.value = null
detailLoading.value = true
try {
detail.value = await fetchDetail(statementId, true)
} catch (cause) {
detailError.value = getApiErrorDetails(cause, '对账单明细加载失败,请重试')
} finally {
detailLoading.value = false
}
}
async function openDetail(row: FinanceStatementReadModel) {
await router.push({ name: 'finance-customer-statement-detail', params: { statementId: row.id } })
}
function retrySelectedDetail() {
if (selectedId.value) void loadDetail(selectedId.value)
}
async function returnToList() {
await router.push({ name: 'finance-customer-statements' })
}
function paymentMethodLabel(method: string) {
return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[method] || method || '—'
}
function revisionActionLabel(action: string) {
return ({ SUBMIT: '提交审核', APPROVE: '审核通过', REJECT: '审核驳回', ISSUE: '出账', ADJUST: '调整', VOID: '作废', REOPEN: '重开' } as Record<string, string>)[action] || action || '—'
}
function revisionTime(actions: string[]) {
return detail.value?.revisions.filter((item) => actions.includes(item.action)).map((item) => item.occurredAt).sort().at(-1) || ''
}
function detailSourceAmount() {
return detail.value?.lines.reduce((total, line) => total + (line.sourceAmount ?? line.amount), 0) ?? 0
}
async function exportStatements() {
const customerId = String(filters.value.customerId || '').trim()
if (customerId && !/^[1-9]\d*$/.test(customerId)) return ElMessage.error('客户业务标识无效,无法创建导出任务')
const parameters: Record<string, string | boolean> = {}
const putText = (key: string) => {
const value = String(filters.value[key] || '').trim()
if (value) parameters[key] = value
}
const putRange = (sourceKey: string, fromKey: string, toKey: string) => {
const value = filters.value[sourceKey]
if (!Array.isArray(value)) return
if (value[0]) parameters[fromKey] = String(value[0])
if (value[1]) parameters[toKey] = String(value[1])
}
if (customerId) parameters.customerId = customerId
putText('status')
putText('billNo')
putText('containerNo')
putRange('periodStartRange', 'periodStartFrom', 'periodStartTo')
putRange('periodEndRange', 'periodEndFrom', 'periodEndTo')
putRange('lastPaymentRange', 'lastPaymentFrom', 'lastPaymentTo')
putRange('createdRange', 'createdFrom', 'createdTo')
const settled = String(filters.value.settled || '')
if (settled) parameters.settled = settled === 'true'
exportBusy.value = true
try {
const task = await createPlatformExportTask({
exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST',
format: 'XLSX',
taskName: '客户对账单',
parameters,
})
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo, dense: true })
if (openDownloadCenter) await router.push({ name: 'download-center' })
} catch (cause) {
ElMessage.error(getApiErrorMessage(cause, '客户对账单导出任务创建失败'))
} finally {
exportBusy.value = false
}
}
function defaultCreateForm() {
return {
generationMode: 'BY_CUTOFF' as 'BY_CUTOFF' | 'SELECTED',
customerId: '',
periodStart: new Date().toISOString().slice(0, 8) + '01',
periodEnd: new Date().toISOString().slice(0, 10),
feeSourceIds: [] as string[],
remark: '',
}
}
function openCreate() {
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
createForm.value = defaultCreateForm()
createSourceOptions.value = []
createSourcesError.value = null
createError.value = null
createOpen.value = true
}
async function loadEligibleCreateSources(preferredIds: string[] = createForm.value.feeSourceIds) {
const sequence = ++createSourceLoadSequence
createSourcesError.value = null
createSourceOptions.value = []
if (createForm.value.generationMode !== 'SELECTED' || !createForm.value.customerId
|| !createForm.value.periodStart || !createForm.value.periodEnd || !canSelectSources.value) {
createForm.value.feeSourceIds = []
return
}
createSourcesLoading.value = true
try {
const records: FinanceFeeSourceSummary[] = []
let currentPage = 1
let pages = 1
do {
const result = await listFinanceFeeSources({
page: currentPage,
size: 200,
customerId: createForm.value.customerId,
direction: 'RECEIVABLE',
status: 'AVAILABLE',
reviewStatus: 'APPROVED',
statementGenerated: false,
businessDateFrom: createForm.value.periodStart,
businessDateTo: createForm.value.periodEnd,
})
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
if (sequence !== createSourceLoadSequence) return
createSourceOptions.value = records.filter((item) => item.direction === 'RECEIVABLE'
&& item.reviewStatus === 'APPROVED' && item.status === 'AVAILABLE'
&& !item.statementGenerated && item.amount > 0)
const availableIds = new Set(createSourceOptions.value.map((item) => item.feeSourceId))
createForm.value.feeSourceIds = preferredIds.filter((id) => availableIds.has(id))
if (preferredIds.length && createForm.value.feeSourceIds.length !== preferredIds.length) {
ElMessage.warning('部分带入费用已被占用或不再符合生成条件,请重新核对')
}
} catch (cause) {
if (sequence !== createSourceLoadSequence) return
createSourcesError.value = getApiErrorDetails(cause, '可生成费用加载失败')
createForm.value.feeSourceIds = []
} finally {
if (sequence === createSourceLoadSequence) createSourcesLoading.value = false
}
}
function queryText(value: unknown) {
return Array.isArray(value) ? String(value[0] || '') : String(value || '')
}
async function openSelectedCreateFromRoute() {
if (route.name !== 'finance-customer-statements' || queryText(route.query.createMode) !== 'SELECTED') return
if (!canGenerate.value || !canSelectSources.value) {
ElMessage.warning('缺少客户或费用来源权限,无法生成所选对账单')
return
}
const feeSourceIds = queryText(route.query.feeSourceIds).split(',').map((value) => value.trim()).filter((value) => /^[1-9]\d*$/.test(value))
const customerId = queryText(route.query.customerId)
const periodStart = queryText(route.query.periodStart)
const periodEnd = queryText(route.query.periodEnd)
if (!customerId || !feeSourceIds.length || !/^\d{4}-\d{2}-\d{2}$/.test(periodStart) || !/^\d{4}-\d{2}-\d{2}$/.test(periodEnd)) {
ElMessage.warning('带入的对账单生成参数不完整,请重新选择费用')
return
}
if (!activeCustomers.value.some((item) => String(item.id) === customerId)) {
ElMessage.warning('带入费用所属客户当前不可用,请重新选择费用')
return
}
createForm.value = { generationMode: 'SELECTED', customerId, periodStart, periodEnd, feeSourceIds: [], remark: '' }
await loadEligibleCreateSources(feeSourceIds)
createOpen.value = true
await router.replace({ name: 'finance-customer-statements' })
}
async function createStatement() {
if (createBusy.value) return
if (!createForm.value.customerId) return ElMessage.warning('请先选择客户')
if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期')
if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期')
if (createForm.value.generationMode === 'SELECTED' && !createForm.value.feeSourceIds.length) return ElMessage.warning('请选择至少一笔应收费用')
createBusy.value = true
try {
const created = await createReconciliationStatement({
customerId: String(createForm.value.customerId),
currency: 'CNY',
generationMode: createForm.value.generationMode,
feeSourceIds: createForm.value.generationMode === 'SELECTED' ? createForm.value.feeSourceIds : undefined,
periodStart: createForm.value.periodStart,
periodEnd: createForm.value.periodEnd,
remark: createForm.value.remark || undefined,
})
createOpen.value = false
ElMessage.success('对账单已生成')
await router.push({ name: 'finance-customer-statement-detail', params: { statementId: created.id } })
} catch (cause) {
const details = getApiErrorDetails(cause, '创建对账单失败')
const noEligibleSources = details.code === 'FIN_409' && /没有符合结算条件|没有可生成/.test(details.message)
createError.value = noEligibleSources
? { ...details, message: '当前客户在所选账期内没有可生成的应收费用,请调整账期或选择其他客户后重试' }
: details
}
finally { createBusy.value = false }
}
async function performAction(row: FinanceStatementReadModel, actionName: ReconciliationActionPayload['action']) {
const title = ({ REJECT: '驳回对账单', VOID: '作废对账单', REOPEN: '重开对账单' } as Partial<Record<ReconciliationActionPayload['action'], string>>)[actionName] || '对账单操作'
const reason = ['REJECT', 'VOID', 'REOPEN'].includes(actionName)
? await ElMessageBox.prompt('请输入操作原因', `${title} · ${row.billNo || '—'}`, { customClass: 'admin-density-confirm admin-workbench', inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
: undefined
if (reason === null) return
actionBusy.value = true
try {
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
ElMessage.success('对账单状态已更新')
await load(appliedFilters.value)
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
finally { actionBusy.value = false }
}
async function openAdjustment(row: FinanceStatementReadModel) {
adjustError.value = null
adjustBusy.value = true
try {
const current = await fetchDetail(row.id, true)
adjustStatement.value = { ...row, version: current.version }
adjustReason.value = ''
adjustLines.value = current.lines.flatMap((line) => line.statementLineId ? [{ selected: false, statementLineId: line.statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : [])
adjustOpen.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
finally { adjustBusy.value = false }
}
function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
return [
{ key: 'detail', label: '详情', priority: 300, disabled: loading.value },
{ key: 'submit', label: '提交', priority: 250, visible: row.status === 'DRAFT' && canCreate.value, loading: actionBusy.value },
{ key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
{ key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value },
{ key: 'adjust', label: '调整', priority: 200, visible: row.status === 'ISSUED' && canAdjust.value, loading: adjustBusy.value },
{ key: 'reopen', label: '重开', priority: 150, visible: ['REJECTED', 'VOIDED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW', 'APPROVED', 'ISSUED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
]
}
function handleStatementAction(command: string, row: FinanceStatementReadModel) {
if (command === 'detail') void openDetail(row)
else if (command === 'adjust') void openAdjustment(row)
else {
const actions: Record<string, ReconciliationActionPayload['action']> = {
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID',
}
const action = actions[command]
if (action) void performAction(row, action)
}
}
async function submitAdjustment() {
if (adjustBusy.value) return
const statement = adjustStatement.value
const selected = adjustLines.value.filter((line) => line.selected)
if (!statement || !adjustReason.value.trim() || !selected.length) return ElMessage.warning('请填写调整原因并至少选择一条明细')
if (selected.some((line) => line.adjustedAmount <= 0 || !line.reason.trim())) return ElMessage.warning('请填写所选明细的新金额和调整说明')
adjustBusy.value = true
adjustError.value = null
try {
await actionReconciliationStatement(statement.id, {
action: 'ADJUST', reason: adjustReason.value.trim(), expectedVersion: statement.version,
adjustmentLines: selected.map((line) => ({ statementLineId: line.statementLineId, adjustedAmount: line.adjustedAmount, reason: line.reason.trim() })),
})
adjustOpen.value = false
ElMessage.success('对账单调整已保存')
await load(appliedFilters.value)
await openDetail(statement)
} catch (cause) { adjustError.value = getApiErrorDetails(cause, '对账单调整失败'); error.value = adjustError.value }
finally { adjustBusy.value = false }
}
onMounted(async () => {
if (canListCustomers.value) await loadCustomers()
await openSelectedCreateFromRoute()
})
watch(
() => [createOpen.value, createForm.value.generationMode, createForm.value.customerId, createForm.value.periodStart, createForm.value.periodEnd],
([open, mode]) => {
if (!open || mode !== 'SELECTED') return
void loadEligibleCreateSources()
},
)
watch(detailRouteId, (statementId) => {
if (statementId) void loadDetail(statementId)
else {
selectedId.value = ''
detail.value = null
detailError.value = null
void load()
}
}, { immediate: true })
</script>
<template>
<AdminPage as="div" class="finance-statements-page admin-density admin-workbench" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" />
<DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page finance-density-detail workbench-panel is-flush is-page-surface" aria-label="客户对账单详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button><h1 class="density-detail-title">{{ detail?.billNo || '—' }}</h1><StatusBadge v-if="detail" :status="financeStatusLabel(detail.status)" /></div></div></template>
<template v-if="detail" #summary>
<dl class="density-detail-overview"><div><dt>客户</dt><dd>{{ customerDisplayName(detail.customerId, detail.customerName) }}</dd></div><div><dt>期间</dt><dd>{{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</dd></div></dl>
<FormSection class="statement-summary-section density-form-section" title="对账单概要" aria-label="对账单摘要">
<dl class="density-fields statement-summary-grid">
<div class="statement-summary-item"><dt>总应收金额</dt><dd>{{ detail.totalAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>已收金额</dt><dd>{{ detail.paidAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>未收金额</dt><dd class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>账期</dt><dd>{{ detail.creditDays }} 天</dd></div>
<div class="statement-summary-item"><dt>费用明细 / 来源</dt><dd>{{ detail.lines.length }} / {{ new Set(detail.lines.map((line) => line.sourceFeeId)).size }}</dd></div>
<div class="statement-summary-item"><dt>调整前金额</dt><dd>{{ detailSourceAmount().toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>调整金额</dt><dd>{{ (detail.totalAmount - detailSourceAmount()).toFixed(2) }} CNY</dd></div>
<div class="statement-summary-item"><dt>货币</dt><dd>{{ detail.currency }}</dd></div>
<div class="statement-summary-item"><dt>是否结清</dt><dd>{{ detail.settled ? '是' : '否' }}</dd></div>
<div class="statement-summary-item"><dt>创建人</dt><dd>{{ operatorDisplayName(detail.createdByName) }}</dd></div>
<div class="statement-summary-item"><dt>创建时间</dt><dd>{{ formatDateTime(detail.createdAt) }}</dd></div>
<div class="statement-summary-item"><dt>提交时间</dt><dd>{{ formatDateTime(revisionTime(['SUBMIT'])) }}</dd></div>
<div class="statement-summary-item"><dt>审核时间</dt><dd>{{ formatDateTime(revisionTime(['APPROVE', 'REJECT'])) }}</dd></div>
<div class="statement-summary-item"><dt>出账时间</dt><dd>{{ formatDateTime(revisionTime(['ISSUE'])) }}</dd></div>
<div class="statement-summary-item"><dt>客户确认状态</dt><dd>{{ financeStatusLabel(detail.customerConfirmationStatus || 'NOT_REQUIRED') }}</dd></div>
<div class="statement-summary-item"><dt>客户确认人</dt><dd>{{ operatorDisplayName(detail.customerConfirmedByName) }}</dd></div>
<div class="statement-summary-item"><dt>客户确认时间</dt><dd>{{ formatDateTime(detail.customerConfirmedAt) }}</dd></div>
<div class="statement-summary-item"><dt>更新人</dt><dd>{{ operatorDisplayName(detail.updatedByName) }}</dd></div>
<div class="statement-summary-item"><dt>更新时间</dt><dd>{{ formatDateTime(detail.updatedAt) }}</dd></div>
<div class="statement-summary-item statement-summary-item--remark"><dt>备注</dt><dd>{{ detail.remark || '—' }}</dd></div>
</dl>
</FormSection>
</template>
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs density-element-tabs density-contained-tabs">
<el-tab-pane name="lines"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>账单明细表</span></template>
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
<FormSection class="density-form-section" v-else-if="detail" title="账单明细" :bordered="false">
<div class="detail-table-scroll workbench-table">
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
<template #direction="{ row }">{{ row.direction === 'PAYABLE' ? '应付' : '应收' }}</template>
<template #sourceType="{ row }">{{ financeSourceTypeLabel(row.sourceType) }}</template>
<template #orderId="{ row }">{{ row.orderId ? `订单 ${row.orderId}` : '—' }}</template>
<template #inboundCode="{ row }">{{ row.inboundCode || '—' }}</template>
<template #containerNo="{ row }">{{ row.containerNo || '—' }}</template>
<template #businessDate="{ row }">{{ row.businessDate || '—' }}</template>
<template #quantity="{ row }">{{ row.quantity ?? '—' }}</template>
<template #unitPrice="{ row }">{{ row.unitPrice ?? '—' }}</template>
<template #billNo>{{ detail.billNo }}</template>
<template #periodStart>{{ detail.periodStart || '—' }}</template>
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
</DataTable>
</div>
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><div class="workbench-table"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column label="操作人" min-width="140"><template #default="{ row }">{{ operatorDisplayName(row.operatorName) }}</template></el-table-column><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></div></el-collapse-item></el-collapse>
</FormSection>
</el-tab-pane>
<el-tab-pane name="payments"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>账单支付历史表</span></template>
<FormSection class="density-form-section" v-if="detail" title="支付历史" :bordered="false">
<div class="detail-table-scroll workbench-table">
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
<template #method="{ row }">{{ paymentMethodLabel(row.method) }}</template>
<template #voucherFileIds="{ row }">{{ row.voucherFileIds.length ? `${row.voucherFileIds.length} 个附件` : (row.referenceNo || '—') }}</template>
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
</DataTable>
</div>
</FormSection>
</el-tab-pane>
</el-tabs>
</DetailPageLayout>
<ListPageLayout v-else aria-label="客户对账单">
<section class="workbench-table is-page-surface"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!customerError && !error" v-model:page="page" v-model:page-size="pageSize" :data="paginatedRows" :columns="columns" :loading="loading" :total="filteredRows.length" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" @row-click="openDetail">
<template #toolbar-start><h1>客户对账单</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="客户对账单筛选" :fields="fields" :loading="loading" preference-key="finance-statements-query" :search-on-clear="false" @search="load" /></template>
<template #notices><template v-if="customerError || error"><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template></template>
<template #toolbar-actions>
<el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportStatements"><Download :size="15" />导出</el-button>
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
</template>
<template #actions="{ row }"><RowActions text-actions :actions="statementActions(row as FinanceStatementReadModel)" :more-label="`更多对账单操作 · ${(row as FinanceStatementReadModel).billNo}`" @action="handleStatementAction($event, row as FinanceStatementReadModel)" /></template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #customerConfirmationStatus="{ row }"><el-tag :type="financeStatusTone(row.customerConfirmationStatus || 'NOT_REQUIRED')">{{ financeStatusLabel(row.customerConfirmationStatus || 'NOT_REQUIRED') }}</el-tag></template>
<template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
<template #lastPaymentAt="{ row }">{{ formatDateTime(row.lastPaymentAt) }}</template>
<template #settled="{ row }"><el-tag v-if="row.settled !== null" :type="row.settled ? 'success' : 'warning'">{{ row.settled ? '是' : '否' }}</el-tag><span v-else>—</span></template>
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
<template #submittedAt="{ row }">{{ formatDateTime(row.submittedAt) }}</template>
<template #reviewedAt="{ row }">{{ formatDateTime(row.reviewedAt) }}</template>
<template #issuedAt="{ row }">{{ formatDateTime(row.issuedAt) }}</template>
<template #customerConfirmedByName="{ row }">{{ row.customerConfirmationStatus === 'CONFIRMED' ? operatorDisplayName(row.customerConfirmedByName) : '—' }}</template>
<template #customerConfirmedAt="{ row }">{{ formatDateTime(row.customerConfirmedAt) }}</template>
<template #updatedByName="{ row }">{{ operatorDisplayName(row.updatedByName) }}</template>
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" title="生成客户对账单" width="min(680px, 94vw)" fullscreenable :confirm-loading="createBusy" @confirm="createStatement">
<el-alert v-if="createError" type="warning" :closable="true" show-icon title="暂时无法生成对账单" @close="createError = null"><p>{{ createError.message }}</p></el-alert>
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="对账单生成设置">
<h3 class="density-dialog-section__title">生成设置 <InlineHelp title="对账单生成说明">按账期自动汇总,或指定审核通过、已发布且未出账的应收费用;生成币种为CNY。</InlineHelp></h3>
<el-form class="density-form" label-position="left" label-width="90px" :disabled="createBusy"><table class="density-table density-table--editable statement-create-table"><tbody>
<tr><td colspan="2"><el-form-item label="生成方式"><el-radio-group v-model="createForm.generationMode"><el-radio-button value="BY_CUTOFF">按账期自动汇总</el-radio-button><el-radio-button value="SELECTED" :disabled="!canSelectSources">指定费用生成</el-radio-button></el-radio-group></el-form-item></td></tr>
<tr><td><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></td><td><el-form-item label="币种"><span>CNY</span></el-form-item></td></tr>
<tr><td><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item></td></tr>
<tr v-if="createForm.generationMode === 'SELECTED'"><td colspan="2">
<el-alert v-if="!canSelectSources" type="warning" :closable="false" title="当前账号没有查看费用来源的权限,不能指定费用生成。" />
<el-alert v-else-if="createSourcesError" type="error" :closable="false"><template #title>可生成费用加载失败</template><p>{{ createSourcesError.message }}</p><ApiErrorMeta :details="createSourcesError" /><el-button size="small" @click="loadEligibleCreateSources()">重试</el-button></el-alert>
<el-form-item label="应收费用"><el-select v-model="createForm.feeSourceIds" multiple filterable collapse-tags :max-collapse-tags="3" :loading="createSourcesLoading" placeholder="请选择审核通过、已发布且未出账的费用"><el-option v-for="item in createSourceOptions" :key="item.feeSourceId" :label="`${item.feeItemName} · ${item.businessDate} · ${Number(item.amount).toFixed(2)} CNY`" :value="item.feeSourceId" /></el-select><div class="density-dialog-note selected-source-summary">已选 {{ createForm.feeSourceIds.length }} 笔,合计 {{ selectedCreateAmount.toFixed(2) }} CNY</div></el-form-item>
</td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr>
</tbody></table></el-form>
</section>
</AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="adjustOpen" :title="`调整客户对账单 · ${adjustStatement?.billNo || '—'}`" width="min(900px, 96vw)" fullscreenable :confirm-loading="adjustBusy" @confirm="submitAdjustment">
<OperationErrorToast :details="adjustError" />
<el-form class="density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="adjustBusy">
<section v-if="adjustStatement" class="density-dialog-section workbench-evidence" data-tone="summary" aria-label="当前对账单"><h3 class="density-dialog-section__title">当前对账单</h3><table class="density-table statement-dialog-facts"><tbody><tr><th scope="row">对账单号</th><td>{{ adjustStatement.billNo || '—' }}</td><th scope="row">客户</th><td>{{ customerDisplayName(adjustStatement.customerId, adjustStatement.customerName) }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="work" aria-label="对账单调整登记"><h3 class="density-dialog-section__title">调整登记</h3><div class="statement-adjust-editor">
<div class="workbench-evidence"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit placeholder="请输入整体调整原因" /></el-form-item></td></tr></tbody></table></div>
<div class="workbench-table"><el-table :data="adjustLines" size="small" border stripe row-key="statementLineId"><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" :aria-label="`选择${row.feeItemName || '费用明细'}`" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" show-overflow-tooltip /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" placeholder="请输入新金额" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" placeholder="请输入明细说明" /></template></el-table-column></el-table></div>
</div></section>
</el-form>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.finance-statements-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.selected-source-summary { width: 100%; margin-top: var(--admin-space-2); text-align: right; }
.statement-create-table, .statement-dialog-facts { table-layout: fixed; }
.statement-create-table td:not([colspan]) { width: 50%; }
.statement-dialog-facts th { width: var(--admin-label-width); }
.statement-adjust-editor { display: grid; gap: var(--admin-space-5); }
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
.statement-summary-section { padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid var(--workbench-border); }
.statement-outstanding { color: var(--el-color-warning-dark-2); }
.statement-detail-tabs { min-width: 0; }
.statement-detail-table { min-width: 0; }
.detail-table-scroll { min-width: 0; overflow-x: auto; }
.statement-line-table { min-width: 1840px; }
.statement-payment-table { min-width: 1300px; }
.revision-collapse { margin-top: var(--admin-space-5); }
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
@media (min-width: 1920px) {
.statement-summary-section {
padding: var(--admin-space-3) var(--admin-space-5);
}
.detail-table-scroll {
margin-bottom: var(--admin-space-3);
}
.revision-collapse {
margin-top: var(--admin-space-3);
}
}
@media (max-width: 640px) {
.statement-detail-page__header { align-items: stretch; flex-direction: column; }
.statement-summary-grid { grid-template-columns: 1fr; }
.statement-summary-item--remark { grid-column: auto; }
}
</style>