132 lines
11 KiB
Vue
132 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { Eye, Pencil, Save } from '@lucide/vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
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 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 { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
|
||
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
|
||
|
||
const access = useAccessPreviewStore()
|
||
const filters = reactive<QueryModel>({ keyword: '', status: '' })
|
||
const rows = ref<FinanceAccountSummary[]>([])
|
||
const loading = ref(false)
|
||
const loadError = ref<ApiErrorDetails | null>(null)
|
||
const page = ref(1)
|
||
const pageSize = ref(20)
|
||
const total = ref(0)
|
||
const drawerOpen = ref(false)
|
||
const editing = ref(false)
|
||
const saving = ref(false)
|
||
const selected = ref<FinanceAccountSummary | null>(null)
|
||
const ledger = ref<FinanceLedgerEntry[]>([])
|
||
const ledgerLoading = ref(false)
|
||
const ledgerError = ref<ApiErrorDetails | null>(null)
|
||
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
|
||
const creditDays = ref(30)
|
||
|
||
const canView = computed(() => access.can('platform:finance:account:view'))
|
||
const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view'))
|
||
const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update'))
|
||
const queryFields: QueryField[] = [
|
||
{ key: 'keyword', type: 'input', label: '关键词', width: 260, placeholder: '客户编号/客户名称/联系人/手机号', props: { 'aria-label': '关键词' } },
|
||
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }] },
|
||
]
|
||
const columns: DataTableColumn[] = [
|
||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||
{ key: 'customerName', label: '客户', minWidth: 180 },
|
||
{ key: 'customerCode', label: '客户编号', width: 150 },
|
||
{ key: 'contactName', label: '联系人', width: 120 },
|
||
{ key: 'contactPhone', label: '联系电话', width: 140 },
|
||
{ key: 'outstandingReceivable', label: '待收(CNY)', width: 130 },
|
||
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
|
||
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
|
||
{ key: 'outstandingPayable', label: '待付(CNY)', width: 130 },
|
||
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
|
||
{ key: 'lastChangedAt', label: '最近变动', width: 170 },
|
||
]
|
||
|
||
async function load(model: QueryModel = filters) {
|
||
Object.assign(filters, model)
|
||
if (!canView.value) return
|
||
loading.value = true
|
||
loadError.value = null
|
||
try {
|
||
const result = await listFinanceAccounts({ page: page.value, size: pageSize.value, keyword: String(filters.keyword || '').trim(), status: String(filters.status || '') })
|
||
rows.value = result.records
|
||
total.value = result.total
|
||
} catch (error) { loadError.value = getApiErrorDetails(error, '客户账户加载失败,请重试') }
|
||
finally { loading.value = false }
|
||
}
|
||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||
async function openAccount(row: Record<string, unknown>, edit: boolean) {
|
||
selected.value = row as unknown as FinanceAccountSummary
|
||
editing.value = edit
|
||
drawerOpen.value = true
|
||
ledger.value = []
|
||
ledgerError.value = null
|
||
selectedTerms.value = null
|
||
if (edit) {
|
||
if (!canViewTerms.value) return
|
||
try { selectedTerms.value = await getFinanceCustomerTerms(selected.value.customerId); creditDays.value = selectedTerms.value.creditDays }
|
||
catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期加载失败,请重试') }
|
||
return
|
||
}
|
||
ledgerLoading.value = true
|
||
try {
|
||
const result = await listFinanceAccountLedger(selected.value.customerId, { page: 1, size: 200 })
|
||
ledger.value = result.records
|
||
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户流水加载失败,请重试') }
|
||
finally { ledgerLoading.value = false }
|
||
}
|
||
async function retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, editing.value) }
|
||
async function saveTerms() {
|
||
if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return }
|
||
saving.value = true
|
||
ledgerError.value = null
|
||
try { selectedTerms.value = await updateFinanceCustomerTerms(selected.value.customerId, { customerName: selected.value.customerName, creditDays: creditDays.value }); ElMessage.success('客户账期已保存') }
|
||
catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期保存失败,请重试'); ElMessage.error(ledgerError.value.message) }
|
||
finally { saving.value = false }
|
||
}
|
||
function money(value: unknown) { return Number(value || 0).toFixed(2) }
|
||
onMounted(() => void load())
|
||
</script>
|
||
|
||
<template>
|
||
<AdminPage as="div" class="finance-account-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-accounts-query" @search="search" /></template>
|
||
<template v-if="loadError" #state><LoadErrorState title="客户账户加载失败" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||
<section v-if="!loadError" class="surface-panel table-panel">
|
||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
|
||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdateTerms" link type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
|
||
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
|
||
<template #pagination-left>币种:CNY · 账户数据由对账单、收付款和费用来源派生</template>
|
||
</DataTable>
|
||
</section>
|
||
</ListPageLayout>
|
||
<AppDrawer v-model="drawerOpen" :title="editing ? '维护客户账期' : '客户账户详情'" size="min(760px, 94vw)" :loading="ledgerLoading" :confirm-loading="saving">
|
||
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
|
||
<template v-else-if="selected">
|
||
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
|
||
<FormSection v-if="!editing" title="账户流水" description="按后端正式流水 DTO 展示应收、应付及变动后未收金额。"><div v-for="entry in ledger" :key="entry.ledgerId" class="ledger-entry"><div><strong>{{ entry.documentNo || `单据 ${entry.documentId}` }}</strong><span>{{ entry.businessType }} · {{ entry.documentType }}</span></div><span>{{ entry.occurredAt }}</span><div class="ledger-money"><b>应收 {{ money(entry.receivableChange) }}</b><b>应付 {{ money(entry.payableChange) }}</b><span>变动后未收 {{ money(entry.outstandingReceivableAfter) }} CNY</span></div><small>{{ entry.operatorName }}{{ entry.remark ? ` · ${entry.remark}` : '' }}</small></div><el-empty v-if="!ledger.length" description="暂无账户流水" /></FormSection>
|
||
<FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></FormSection>
|
||
</template>
|
||
<template #footer><div class="drawer-footer"><el-button @click="drawerOpen = false">关闭</el-button><el-button v-if="editing && canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
|
||
</AppDrawer>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.finance-account-page{min-width:0}.row-actions,.drawer-footer{display:flex;align-items:center;gap:8px}.drawer-footer{justify-content:flex-end}.row-actions .el-button{margin-left:0}.account-detail{margin-bottom:16px}.term-unit{margin-left:8px;color:var(--cc-muted)}.ledger-entry{display:grid;grid-template-columns:minmax(160px,1fr) auto minmax(210px,auto);gap:8px 16px;padding:12px 0;border-bottom:1px solid var(--el-border-color-lighter)}.ledger-entry>div:first-child,.ledger-money{display:grid;gap:3px}.ledger-entry span,.ledger-entry small{color:var(--cc-muted)}.ledger-entry small{grid-column:1/-1}.ledger-money{text-align:right}.ledger-money b{white-space:nowrap}@media(max-width:720px){.drawer-footer .el-button{min-height:44px}.ledger-entry{grid-template-columns:1fr 1fr}.ledger-money{grid-column:1/-1;text-align:left}:deep(.account-detail .el-descriptions__body .el-descriptions__table){table-layout:fixed}}@media(max-width:480px){.drawer-footer .el-button{flex:1 1 120px}.ledger-entry{grid-template-columns:1fr}:deep(.account-detail .el-descriptions__cell){display:block;width:100%}}
|
||
</style>
|