1812 lines
62 KiB
TypeScript
1812 lines
62 KiB
TypeScript
import {
|
||
CheckCircleOutlined,
|
||
PlusOutlined,
|
||
ReloadOutlined,
|
||
SwapOutlined,
|
||
WalletOutlined,
|
||
} from '@ant-design/icons'
|
||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import {
|
||
Alert,
|
||
App,
|
||
Button,
|
||
Card,
|
||
Checkbox,
|
||
Col,
|
||
DatePicker,
|
||
Descriptions,
|
||
Drawer,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Statistic,
|
||
Tabs,
|
||
Tag,
|
||
Typography,
|
||
} from 'antd'
|
||
import PercentTable from '@/components/common/PercentTable'
|
||
import type { TableColumnsType } from 'antd'
|
||
import dayjs from 'dayjs'
|
||
import type { Dayjs } from 'dayjs'
|
||
import { useState } from 'react'
|
||
|
||
import { PlatformCreditPanel } from './PlatformCreditPanel'
|
||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||
import ImageUpload from '@/components/files/ImageUpload'
|
||
import PageHeader from '@/components/admin/PageHeader'
|
||
import {
|
||
confirmAdminFundReconciliation,
|
||
createAdminFundAccount,
|
||
createAdminFundExpense,
|
||
createAdminFundFunding,
|
||
createAdminFundIssue,
|
||
createAdminFundReconciliation,
|
||
createAdminFundReturn,
|
||
ensureAdminFundAgentAccounts,
|
||
fetchAdminFundAccountDetail,
|
||
fetchAdminFundAccounts,
|
||
fetchAdminFundExpenses,
|
||
fetchAdminFundOverview,
|
||
fetchAdminFundReconciliations,
|
||
fetchAdminFundSetupOptions,
|
||
updateAdminFundAccount,
|
||
} from '@/services/admin'
|
||
import type {
|
||
AdminFundAccount,
|
||
AdminFundEntry,
|
||
AdminFundExpense,
|
||
AdminFundExpenseCategory,
|
||
AdminFundPayeeType,
|
||
AdminFundReconciliation,
|
||
AdminFundSetupUser,
|
||
} from '@/types/admin'
|
||
import type { UploadedFile } from '@/types/worker-platform'
|
||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||
import { buildAdminTablePagination } from '@/utils/admin-pagination'
|
||
import {
|
||
FUND_ACCOUNT_STATUS_LABELS,
|
||
FUND_CATEGORY_LABELS,
|
||
FUND_ENTRY_COLORS,
|
||
FUND_ENTRY_LABELS,
|
||
FUND_PAYEE_LABELS,
|
||
FUND_RECONCILIATION_STATUS_COLORS,
|
||
FUND_RECONCILIATION_STATUS_LABELS,
|
||
formatFundAmount,
|
||
fromFen,
|
||
toFen,
|
||
} from './fund-labels'
|
||
|
||
type ManagementRole = 'admin' | 'operator' | 'finance'
|
||
|
||
type IssueFormValues = {
|
||
agentAccountId: number
|
||
supervisorAccountId?: number
|
||
amountYuan: number
|
||
occurredAt: Dayjs
|
||
evidence: UploadedFile[]
|
||
note?: string
|
||
}
|
||
|
||
type FundingFormValues = {
|
||
supervisorAccountId: number
|
||
amountYuan: number
|
||
occurredAt: Dayjs
|
||
evidence: UploadedFile[]
|
||
note?: string
|
||
}
|
||
|
||
type ExpenseFormValues = {
|
||
agentAccountId: number
|
||
amountYuan: number
|
||
category: AdminFundExpenseCategory
|
||
payeeType: AdminFundPayeeType
|
||
payeeName: string
|
||
payeeAccount?: string
|
||
platformName?: string
|
||
platformAccount?: string
|
||
orderId?: number
|
||
paidAt: Dayjs
|
||
evidence: UploadedFile[]
|
||
note?: string
|
||
allowDuplicate?: boolean
|
||
}
|
||
|
||
type ReturnFormValues = {
|
||
agentAccountId: number
|
||
supervisorAccountId?: number
|
||
amountYuan: number
|
||
occurredAt: Dayjs
|
||
evidence: UploadedFile[]
|
||
note?: string
|
||
}
|
||
|
||
type ReconcileFormValues = {
|
||
accountId: number
|
||
period: [Dayjs, Dayjs]
|
||
actualBalanceYuan: number
|
||
reason?: string
|
||
evidence: UploadedFile[]
|
||
}
|
||
|
||
type ConfirmReconcileFormValues = {
|
||
reason?: string
|
||
evidence: UploadedFile[]
|
||
}
|
||
|
||
type CreateAccountFormValues = {
|
||
accountType: 'supervisor' | 'agent'
|
||
ownerAdminUserId: number
|
||
responsibleSupervisorAccountId?: number
|
||
openingBalanceYuan?: number
|
||
}
|
||
|
||
type BulkAgentsFormValues = {
|
||
responsibleSupervisorAccountId: number
|
||
openingBalanceYuan?: number
|
||
}
|
||
|
||
export default function FundManagementView({ role }: { role: ManagementRole }) {
|
||
const { message } = App.useApp()
|
||
const queryClient = useQueryClient()
|
||
const isAdmin = role === 'admin'
|
||
const canIssue = role === 'admin' || role === 'operator'
|
||
const canReconcile = role === 'admin' || role === 'operator' || role === 'finance'
|
||
const canExpense = role === 'admin'
|
||
const canManageCredits = role === 'admin' || role === 'finance'
|
||
|
||
const [activeTab, setActiveTab] = useState('overview')
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [issueOpen, setIssueOpen] = useState(false)
|
||
const [fundingOpen, setFundingOpen] = useState(false)
|
||
const [expenseOpen, setExpenseOpen] = useState(false)
|
||
const [returnOpen, setReturnOpen] = useState(false)
|
||
const [reconcileOpen, setReconcileOpen] = useState(false)
|
||
const [confirmReconcileOpen, setConfirmReconcileOpen] = useState(false)
|
||
const [createAccountOpen, setCreateAccountOpen] = useState(false)
|
||
const [bulkAgentsOpen, setBulkAgentsOpen] = useState(false)
|
||
const [detailAccount, setDetailAccount] = useState<AdminFundAccount | null>(null)
|
||
const [confirmReconciliation, setConfirmReconciliation] =
|
||
useState<AdminFundReconciliation | null>(null)
|
||
|
||
const [expensePage, setExpensePage] = useState(1)
|
||
const [expensePageSize, setExpensePageSize] = useState(20)
|
||
const [expenseKeyword, setExpenseKeyword] = useState('')
|
||
const [expenseCategory, setExpenseCategory] = useState('')
|
||
const [expenseRange, setExpenseRange] = useState<[Dayjs, Dayjs] | null>(null)
|
||
|
||
const [reconciliationPage, setReconciliationPage] = useState(1)
|
||
const [reconciliationPageSize, setReconciliationPageSize] = useState(20)
|
||
const [reconciliationStatus, setReconciliationStatus] = useState('')
|
||
|
||
const [issueForm] = Form.useForm<IssueFormValues>()
|
||
const [fundingForm] = Form.useForm<FundingFormValues>()
|
||
const [expenseForm] = Form.useForm<ExpenseFormValues>()
|
||
const [returnForm] = Form.useForm<ReturnFormValues>()
|
||
const [reconcileForm] = Form.useForm<ReconcileFormValues>()
|
||
const [confirmReconcileForm] = Form.useForm<ConfirmReconcileFormValues>()
|
||
const [createAccountForm] = Form.useForm<CreateAccountFormValues>()
|
||
const [bulkAgentsForm] = Form.useForm<BulkAgentsFormValues>()
|
||
|
||
const createAccountType = Form.useWatch('accountType', createAccountForm) || 'agent'
|
||
|
||
const overviewQuery = useQuery({
|
||
queryKey: ['admin-fund-overview'],
|
||
queryFn: fetchAdminFundOverview,
|
||
})
|
||
const agentsQuery = useQuery({
|
||
queryKey: ['admin-fund-accounts', 'agent'],
|
||
queryFn: () => fetchAdminFundAccounts({ accountType: 'agent', pageSize: 100 }),
|
||
})
|
||
const supervisorsQuery = useQuery({
|
||
queryKey: ['admin-fund-accounts', 'supervisor'],
|
||
queryFn: () => fetchAdminFundAccounts({ accountType: 'supervisor', pageSize: 100 }),
|
||
})
|
||
const expensesQuery = useQuery({
|
||
queryKey: [
|
||
'admin-fund-expenses',
|
||
expensePage,
|
||
expensePageSize,
|
||
expenseKeyword,
|
||
expenseCategory,
|
||
expenseRange?.[0]?.format('YYYY-MM-DD HH:mm:ss') || '',
|
||
expenseRange?.[1]?.format('YYYY-MM-DD HH:mm:ss') || '',
|
||
],
|
||
queryFn: () =>
|
||
fetchAdminFundExpenses({
|
||
page: expensePage,
|
||
pageSize: expensePageSize,
|
||
keyword: expenseKeyword || undefined,
|
||
category: expenseCategory || undefined,
|
||
dateFrom: expenseRange?.[0]?.format('YYYY-MM-DD HH:mm:ss'),
|
||
dateTo: expenseRange?.[1]?.format('YYYY-MM-DD HH:mm:ss'),
|
||
}),
|
||
})
|
||
const reconciliationsQuery = useQuery({
|
||
queryKey: [
|
||
'admin-fund-reconciliations',
|
||
reconciliationPage,
|
||
reconciliationPageSize,
|
||
reconciliationStatus,
|
||
],
|
||
queryFn: () =>
|
||
fetchAdminFundReconciliations({
|
||
page: reconciliationPage,
|
||
pageSize: reconciliationPageSize,
|
||
status: reconciliationStatus || undefined,
|
||
}),
|
||
})
|
||
const setupQuery = useQuery({
|
||
queryKey: ['admin-fund-options'],
|
||
queryFn: fetchAdminFundSetupOptions,
|
||
enabled: isAdmin,
|
||
})
|
||
const detailQuery = useQuery({
|
||
queryKey: ['admin-fund-account-detail', detailAccount?.id],
|
||
queryFn: () => fetchAdminFundAccountDetail(detailAccount!.id, { page: 1, pageSize: 100 }),
|
||
enabled: Boolean(detailAccount?.id),
|
||
})
|
||
|
||
const overview = overviewQuery.data?.data
|
||
const agentAccounts = agentsQuery.data?.data.items || []
|
||
const supervisorAccounts = supervisorsQuery.data?.data.items || []
|
||
const expenses = expensesQuery.data?.data.items || []
|
||
const expensePagination = expensesQuery.data?.data.pagination
|
||
const reconciliations = reconciliationsQuery.data?.data.items || []
|
||
const reconciliationPagination = reconciliationsQuery.data?.data.pagination
|
||
const setup = setupQuery.data?.data
|
||
|
||
const agentOptions = agentAccounts.map((account) => ({
|
||
value: account.id,
|
||
label: `${account.ownerName}(余额 ${formatFundAmount(account.balanceFen)})`,
|
||
}))
|
||
const supervisorOptions = supervisorAccounts.map((account) => ({
|
||
value: account.id,
|
||
label: `${account.ownerName}(余额 ${formatFundAmount(account.balanceFen)})`,
|
||
}))
|
||
|
||
function refreshAll() {
|
||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-overview'] })
|
||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
|
||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] })
|
||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-reconciliations'] })
|
||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-account-detail'] })
|
||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-options'] })
|
||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] })
|
||
}
|
||
|
||
function openIssue(account?: AdminFundAccount) {
|
||
issueForm.resetFields()
|
||
issueForm.setFieldsValue({
|
||
agentAccountId:
|
||
account?.accountType === 'agent'
|
||
? account.id
|
||
: (agentAccounts[0]?.id as number | undefined),
|
||
supervisorAccountId:
|
||
account?.accountType === 'supervisor'
|
||
? account.id
|
||
: (supervisorAccounts[0]?.id as number | undefined),
|
||
occurredAt: dayjs(),
|
||
evidence: [],
|
||
})
|
||
setIssueOpen(true)
|
||
}
|
||
|
||
function openFunding(account?: AdminFundAccount) {
|
||
fundingForm.resetFields()
|
||
fundingForm.setFieldsValue({
|
||
supervisorAccountId: account?.id || (supervisorAccounts[0]?.id as number | undefined),
|
||
occurredAt: dayjs(),
|
||
evidence: [],
|
||
})
|
||
setFundingOpen(true)
|
||
}
|
||
|
||
function openExpense(account?: AdminFundAccount) {
|
||
expenseForm.resetFields()
|
||
expenseForm.setFieldsValue({
|
||
agentAccountId:
|
||
account?.accountType === 'agent'
|
||
? account.id
|
||
: (agentAccounts[0]?.id as number | undefined),
|
||
category: 'platform_recharge',
|
||
payeeType: 'platform',
|
||
paidAt: dayjs(),
|
||
evidence: [],
|
||
allowDuplicate: false,
|
||
})
|
||
setExpenseOpen(true)
|
||
}
|
||
|
||
function openReturn(account?: AdminFundAccount) {
|
||
returnForm.resetFields()
|
||
returnForm.setFieldsValue({
|
||
agentAccountId:
|
||
account?.accountType === 'agent'
|
||
? account.id
|
||
: (agentAccounts[0]?.id as number | undefined),
|
||
supervisorAccountId:
|
||
account?.accountType === 'supervisor'
|
||
? account.id
|
||
: (supervisorAccounts[0]?.id as number | undefined),
|
||
occurredAt: dayjs(),
|
||
evidence: [],
|
||
})
|
||
setReturnOpen(true)
|
||
}
|
||
|
||
function openReconcile(account?: AdminFundAccount) {
|
||
reconcileForm.resetFields()
|
||
reconcileForm.setFieldsValue({
|
||
accountId:
|
||
account?.accountType === 'agent'
|
||
? account.id
|
||
: (agentAccounts[0]?.id as number | undefined),
|
||
period: [dayjs().subtract(7, 'day'), dayjs()],
|
||
actualBalanceYuan: account ? fromFen(account.balanceFen) : 0,
|
||
evidence: [],
|
||
})
|
||
setReconcileOpen(true)
|
||
}
|
||
|
||
function openConfirmReconcile(reconciliation: AdminFundReconciliation) {
|
||
setConfirmReconciliation(reconciliation)
|
||
confirmReconcileForm.resetFields()
|
||
confirmReconcileForm.setFieldsValue({
|
||
reason: reconciliation.reason || '',
|
||
evidence: [],
|
||
})
|
||
setConfirmReconcileOpen(true)
|
||
}
|
||
|
||
async function submitIssue(values: IssueFormValues) {
|
||
setSubmitting(true)
|
||
try {
|
||
await createAdminFundIssue(values.agentAccountId, {
|
||
agentAccountId: values.agentAccountId,
|
||
supervisorAccountId: values.supervisorAccountId,
|
||
amountFen: toFen(values.amountYuan),
|
||
occurredAt: values.occurredAt.toISOString(),
|
||
evidence: values.evidence || [],
|
||
note: values.note || '',
|
||
})
|
||
message.success('备用金已发放')
|
||
setIssueOpen(false)
|
||
refreshAll()
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '发放备用金失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function submitFunding(values: FundingFormValues) {
|
||
setSubmitting(true)
|
||
try {
|
||
await createAdminFundFunding(values.supervisorAccountId, {
|
||
supervisorAccountId: values.supervisorAccountId,
|
||
amountFen: toFen(values.amountYuan),
|
||
occurredAt: values.occurredAt.toISOString(),
|
||
evidence: values.evidence || [],
|
||
note: values.note || '',
|
||
})
|
||
message.success('主管资金池已拨付')
|
||
setFundingOpen(false)
|
||
refreshAll()
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '拨付失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function submitExpense(values: ExpenseFormValues) {
|
||
setSubmitting(true)
|
||
try {
|
||
await createAdminFundExpense(values.agentAccountId, {
|
||
amountFen: toFen(values.amountYuan),
|
||
category: values.category,
|
||
payeeType: values.payeeType,
|
||
payeeName: values.payeeName,
|
||
payeeAccount: values.payeeAccount || '',
|
||
platformName: values.platformName || '',
|
||
platformAccount: values.platformAccount || '',
|
||
orderId: values.orderId || null,
|
||
paidAt: values.paidAt.toISOString(),
|
||
evidence: values.evidence || [],
|
||
note: values.note || '',
|
||
allowDuplicate: values.allowDuplicate === true,
|
||
})
|
||
message.success('支出已登记')
|
||
setExpenseOpen(false)
|
||
refreshAll()
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '登记支出失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function submitReturn(values: ReturnFormValues) {
|
||
setSubmitting(true)
|
||
try {
|
||
await createAdminFundReturn(values.agentAccountId, {
|
||
agentAccountId: values.agentAccountId,
|
||
supervisorAccountId: values.supervisorAccountId,
|
||
amountFen: toFen(values.amountYuan),
|
||
occurredAt: values.occurredAt.toISOString(),
|
||
evidence: values.evidence || [],
|
||
note: values.note || '',
|
||
})
|
||
message.success('备用金已缴回')
|
||
setReturnOpen(false)
|
||
refreshAll()
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '缴回失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function submitReconcile(values: ReconcileFormValues) {
|
||
setSubmitting(true)
|
||
try {
|
||
await createAdminFundReconciliation({
|
||
accountId: values.accountId,
|
||
periodStart: values.period[0].toISOString(),
|
||
periodEnd: values.period[1].toISOString(),
|
||
actualBalanceFen: toFen(values.actualBalanceYuan),
|
||
reason: values.reason || '',
|
||
evidence: values.evidence || [],
|
||
})
|
||
message.success('对账已发起')
|
||
setReconcileOpen(false)
|
||
refreshAll()
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '发起对账失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function submitConfirmReconcile(values: ConfirmReconcileFormValues) {
|
||
if (!confirmReconciliation) return
|
||
setSubmitting(true)
|
||
try {
|
||
await confirmAdminFundReconciliation(confirmReconciliation.id, {
|
||
reason: values.reason || '',
|
||
evidence: values.evidence || [],
|
||
})
|
||
message.success('对账已确认')
|
||
setConfirmReconcileOpen(false)
|
||
setConfirmReconciliation(null)
|
||
refreshAll()
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '确认对账失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function submitCreateAccount(values: CreateAccountFormValues) {
|
||
setSubmitting(true)
|
||
try {
|
||
await createAdminFundAccount({
|
||
accountType: values.accountType,
|
||
ownerAdminUserId: values.ownerAdminUserId,
|
||
responsibleSupervisorAccountId: values.responsibleSupervisorAccountId,
|
||
openingBalanceFen: toFen(values.openingBalanceYuan || 0),
|
||
})
|
||
message.success('备用金账户已创建')
|
||
setCreateAccountOpen(false)
|
||
refreshAll()
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '创建账户失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function submitBulkAgents(values: BulkAgentsFormValues) {
|
||
setSubmitting(true)
|
||
try {
|
||
const result = await ensureAdminFundAgentAccounts({
|
||
responsibleSupervisorAccountId: values.responsibleSupervisorAccountId,
|
||
openingBalanceFen: toFen(values.openingBalanceYuan || 0),
|
||
})
|
||
message.success(`已创建 ${result.data.createdCount} 个客服账户`)
|
||
setBulkAgentsOpen(false)
|
||
refreshAll()
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '批量初始化失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
async function toggleAccountStatus(account: AdminFundAccount) {
|
||
setSubmitting(true)
|
||
try {
|
||
await updateAdminFundAccount(account.id, {
|
||
status: account.status === 'active' ? 'frozen' : 'active',
|
||
})
|
||
message.success(account.status === 'active' ? '账户已冻结' : '账户已启用')
|
||
refreshAll()
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '更新账户状态失败')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
function buildAccountColumns(type: 'agent' | 'supervisor'): TableColumnsType<AdminFundAccount> {
|
||
return [
|
||
{ title: '账户编号', dataIndex: 'accountNo', width: 150 },
|
||
{ title: '姓名', dataIndex: 'ownerName', width: 120 },
|
||
...(type === 'agent'
|
||
? [
|
||
{
|
||
title: '责任主管',
|
||
dataIndex: 'responsibleSupervisorName',
|
||
width: 120,
|
||
render: (value: string) => value || '-',
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
title: '当前余额',
|
||
dataIndex: 'balanceFen',
|
||
width: 130,
|
||
render: (value: number) => (
|
||
<Typography.Text strong>{formatFundAmount(value)}</Typography.Text>
|
||
),
|
||
},
|
||
{
|
||
title: '本月流入',
|
||
dataIndex: 'monthInFen',
|
||
width: 120,
|
||
render: (value: number) => formatFundAmount(value),
|
||
},
|
||
{
|
||
title: '本月流出',
|
||
dataIndex: 'monthOutFen',
|
||
width: 120,
|
||
render: (value: number) => formatFundAmount(value),
|
||
},
|
||
{
|
||
title: '最后流水',
|
||
dataIndex: 'lastEntryAt',
|
||
width: 170,
|
||
render: (value: string | null) => (value ? formatAdminDateTime(value) : '-'),
|
||
},
|
||
{
|
||
title: '最后对账',
|
||
dataIndex: 'lastReconciledAt',
|
||
width: 170,
|
||
render: (value: string | null) => (value ? formatAdminDateTime(value) : '-'),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 90,
|
||
render: (value: string) => (
|
||
<Tag color={value === 'active' ? 'green' : value === 'frozen' ? 'orange' : 'default'}>
|
||
{FUND_ACCOUNT_STATUS_LABELS[value] || value}
|
||
</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'actions',
|
||
width: 260,
|
||
fixed: 'right',
|
||
render: (_value, record) => (
|
||
<Space size={4} wrap>
|
||
<Button size="small" onClick={() => setDetailAccount(record)}>
|
||
详情
|
||
</Button>
|
||
{isAdmin && type === 'supervisor' ? (
|
||
<Button size="small" onClick={() => openFunding(record)}>
|
||
拨付
|
||
</Button>
|
||
) : null}
|
||
{canIssue ? (
|
||
<Button size="small" onClick={() => openIssue(record)}>
|
||
发放
|
||
</Button>
|
||
) : null}
|
||
{type === 'agent' && canIssue ? (
|
||
<Button size="small" onClick={() => openReturn(record)}>
|
||
缴回
|
||
</Button>
|
||
) : null}
|
||
{type === 'agent' && canReconcile ? (
|
||
<Button size="small" onClick={() => openReconcile(record)}>
|
||
对账
|
||
</Button>
|
||
) : null}
|
||
{isAdmin ? (
|
||
<Button
|
||
size="small"
|
||
danger={record.status === 'active'}
|
||
onClick={() => toggleAccountStatus(record)}
|
||
>
|
||
{record.status === 'active' ? '冻结' : '启用'}
|
||
</Button>
|
||
) : null}
|
||
</Space>
|
||
),
|
||
},
|
||
]
|
||
}
|
||
|
||
const expenseColumns: TableColumnsType<AdminFundExpense> = [
|
||
{
|
||
title: '付款时间',
|
||
dataIndex: 'paidAt',
|
||
width: 170,
|
||
render: (value: string) => formatAdminDateTime(value),
|
||
},
|
||
{ title: '支出单号', dataIndex: 'expenseNo', width: 150 },
|
||
{ title: '客服', dataIndex: 'agentName', width: 110 },
|
||
{
|
||
title: '分类',
|
||
dataIndex: 'category',
|
||
width: 110,
|
||
render: (value: AdminFundExpenseCategory) => FUND_CATEGORY_LABELS[value] || value,
|
||
},
|
||
{
|
||
title: '收款方',
|
||
dataIndex: 'payeeName',
|
||
width: 180,
|
||
render: (_value: string, row) => (
|
||
<Space direction="vertical" size={0}>
|
||
<span>{row.payeeName}</span>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{FUND_PAYEE_LABELS[row.payeeType]}
|
||
{row.payeeAccount ? ` · ${row.payeeAccount}` : ''}
|
||
</Typography.Text>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '平台/账号',
|
||
dataIndex: 'platformName',
|
||
width: 180,
|
||
render: (_value: string, row) =>
|
||
row.platformName || row.platformAccount
|
||
? `${row.platformName || '-'}${row.platformAccount ? ` / ${row.platformAccount}` : ''}`
|
||
: '-',
|
||
},
|
||
{
|
||
title: '关联订单',
|
||
dataIndex: 'orderId',
|
||
width: 100,
|
||
render: (value: number | null) => (value ? `#${value}` : '-'),
|
||
},
|
||
{
|
||
title: '金额',
|
||
dataIndex: 'amountFen',
|
||
width: 120,
|
||
render: (value: number) => (
|
||
<Typography.Text type="danger">{formatFundAmount(value)}</Typography.Text>
|
||
),
|
||
},
|
||
{
|
||
title: '凭证',
|
||
dataIndex: 'evidence',
|
||
width: 100,
|
||
render: (value: UploadedFile[]) => (
|
||
<ImagePreviewList files={value} size={36} maxVisible={2} />
|
||
),
|
||
},
|
||
{ title: '说明', dataIndex: 'note', render: (value: string) => value || '-' },
|
||
]
|
||
|
||
const reconciliationColumns: TableColumnsType<AdminFundReconciliation> = [
|
||
{
|
||
title: '对账区间',
|
||
dataIndex: 'periodStart',
|
||
width: 230,
|
||
render: (_value: string, row) =>
|
||
`${formatAdminDateTime(row.periodStart)} ~ ${formatAdminDateTime(row.periodEnd)}`,
|
||
},
|
||
{ title: '对账单号', dataIndex: 'reconciliationNo', width: 150 },
|
||
{ title: '客服', dataIndex: 'agentName', width: 110 },
|
||
{
|
||
title: '系统余额',
|
||
dataIndex: 'systemBalanceFen',
|
||
width: 120,
|
||
render: (value: number) => formatFundAmount(value),
|
||
},
|
||
{
|
||
title: '实际余额',
|
||
dataIndex: 'actualBalanceFen',
|
||
width: 120,
|
||
render: (value: number) => formatFundAmount(value),
|
||
},
|
||
{
|
||
title: '差异',
|
||
dataIndex: 'differenceFen',
|
||
width: 120,
|
||
render: (value: number) => (
|
||
<Typography.Text type={value === 0 ? 'success' : 'danger'}>
|
||
{formatFundAmount(value, true)}
|
||
</Typography.Text>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 100,
|
||
render: (value: AdminFundReconciliation['status']) => (
|
||
<Tag color={FUND_RECONCILIATION_STATUS_COLORS[value]}>
|
||
{FUND_RECONCILIATION_STATUS_LABELS[value]}
|
||
</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '原因',
|
||
dataIndex: 'reason',
|
||
render: (value: string) => value || '-',
|
||
},
|
||
{
|
||
title: '确认人',
|
||
dataIndex: 'confirmedByUsername',
|
||
width: 110,
|
||
render: (value: string, row) =>
|
||
value
|
||
? `${value}${row.confirmedAt ? ` · ${formatAdminDateTime(row.confirmedAt)}` : ''}`
|
||
: '-',
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'actions',
|
||
width: 100,
|
||
fixed: 'right',
|
||
render: (_value, row) =>
|
||
row.status === 'confirmed' ? (
|
||
<Tag color="green">已确认</Tag>
|
||
) : (
|
||
<Button size="small" type="primary" onClick={() => openConfirmReconcile(row)}>
|
||
确认
|
||
</Button>
|
||
),
|
||
},
|
||
]
|
||
|
||
const detailEntries = detailQuery.data?.data.items || []
|
||
|
||
const detailEntryColumns: TableColumnsType<AdminFundEntry> = [
|
||
{
|
||
title: '时间',
|
||
dataIndex: 'occurredAt',
|
||
width: 170,
|
||
render: (value: string) => formatAdminDateTime(value),
|
||
},
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'entryType',
|
||
width: 110,
|
||
render: (value: AdminFundEntry['entryType']) => (
|
||
<Tag color={FUND_ENTRY_COLORS[value]}>{FUND_ENTRY_LABELS[value]}</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '金额',
|
||
dataIndex: 'amountFen',
|
||
width: 120,
|
||
render: (value: number) => (
|
||
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
|
||
{formatFundAmount(value, true)}
|
||
</Typography.Text>
|
||
),
|
||
},
|
||
{
|
||
title: '余额',
|
||
dataIndex: 'balanceAfterFen',
|
||
width: 120,
|
||
render: (value: number) => formatFundAmount(value),
|
||
},
|
||
{
|
||
title: '对方账户',
|
||
dataIndex: 'counterpartyName',
|
||
width: 130,
|
||
render: (value: string) => value || '-',
|
||
},
|
||
{
|
||
title: '凭证',
|
||
dataIndex: 'evidence',
|
||
width: 100,
|
||
render: (value: UploadedFile[]) => (
|
||
<ImagePreviewList files={value} size={36} maxVisible={2} />
|
||
),
|
||
},
|
||
{ title: '说明', dataIndex: 'note', render: (value: string) => value || '-' },
|
||
]
|
||
|
||
const setupSupportUsersWithoutAccount =
|
||
setup?.users.filter(
|
||
(user) =>
|
||
user.role === 'support' &&
|
||
!setup.agentAccounts.some((account) => account.ownerAdminUserId === user.id),
|
||
) || []
|
||
const setupSupervisorUsersWithoutAccount =
|
||
setup?.users.filter(
|
||
(user) =>
|
||
(user.role === 'operator' || user.role === 'admin') &&
|
||
!setup.supervisorAccounts.some((account) => account.ownerAdminUserId === user.id),
|
||
) || []
|
||
|
||
function openCreateAccount(prefill?: Partial<CreateAccountFormValues>) {
|
||
createAccountForm.resetFields()
|
||
createAccountForm.setFieldsValue({
|
||
accountType: 'agent',
|
||
openingBalanceYuan: 0,
|
||
...prefill,
|
||
})
|
||
setCreateAccountOpen(true)
|
||
}
|
||
|
||
function openBulkAgents() {
|
||
bulkAgentsForm.resetFields()
|
||
bulkAgentsForm.setFieldsValue({
|
||
responsibleSupervisorAccountId: supervisorAccounts[0]?.id,
|
||
openingBalanceYuan: 0,
|
||
})
|
||
setBulkAgentsOpen(true)
|
||
}
|
||
|
||
const createAccountUserOptions =
|
||
createAccountType === 'supervisor'
|
||
? (setup?.users || []).filter(
|
||
(user) =>
|
||
(user.role === 'operator' || user.role === 'admin') &&
|
||
!setup?.supervisorAccounts.some((account) => account.ownerAdminUserId === user.id),
|
||
)
|
||
: (setup?.users || []).filter(
|
||
(user) =>
|
||
user.role === 'support' &&
|
||
!setup?.agentAccounts.some((account) => account.ownerAdminUserId === user.id),
|
||
)
|
||
|
||
return (
|
||
<section className="page-stack">
|
||
<PageHeader
|
||
title="钱包管理"
|
||
description="统管客服的南航 / 乐玩 / ROCK 积分钱包与备用金现金;可分配积分、发放现金、查看流水与对账。"
|
||
extra={
|
||
<Button icon={<ReloadOutlined />} loading={overviewQuery.isFetching} onClick={refreshAll}>
|
||
刷新
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
{overviewQuery.error ? (
|
||
<Alert
|
||
type="error"
|
||
showIcon
|
||
message={
|
||
overviewQuery.error instanceof Error
|
||
? overviewQuery.error.message
|
||
: '读取备用金总览失败'
|
||
}
|
||
/>
|
||
) : null}
|
||
|
||
<Tabs
|
||
activeKey={activeTab}
|
||
onChange={setActiveTab}
|
||
items={[
|
||
{
|
||
key: 'overview',
|
||
label: '钱包总览',
|
||
children: (
|
||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||
<Row gutter={[16, 16]}>
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="备用金总额"
|
||
value={(overview?.totalBalanceFen || 0) / 100}
|
||
precision={2}
|
||
prefix="¥"
|
||
suffix={<WalletOutlined />}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="主管池余额"
|
||
value={(overview?.supervisorBalanceFen || 0) / 100}
|
||
precision={2}
|
||
prefix="¥"
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="客服余额"
|
||
value={(overview?.agentBalanceFen || 0) / 100}
|
||
precision={2}
|
||
prefix="¥"
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="本月支出"
|
||
value={(overview?.monthExpenseFen || 0) / 100}
|
||
precision={2}
|
||
prefix="¥"
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="本月发放"
|
||
value={(overview?.monthIssuedFen || 0) / 100}
|
||
precision={2}
|
||
prefix="¥"
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="本月缴回"
|
||
value={(overview?.monthReturnedFen || 0) / 100}
|
||
precision={2}
|
||
prefix="¥"
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="待对账"
|
||
value={overview?.pendingReconciliationCount || 0}
|
||
suffix="笔"
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={24} sm={12} lg={6}>
|
||
<Card>
|
||
<Statistic
|
||
title="对账差异"
|
||
value={overview?.discrepancyCount || 0}
|
||
suffix="笔"
|
||
valueStyle={{
|
||
color: (overview?.discrepancyCount || 0) > 0 ? '#cf1322' : undefined,
|
||
}}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Card title="快捷操作">
|
||
<Space wrap>
|
||
{canIssue ? (
|
||
<Button type="primary" icon={<SwapOutlined />} onClick={() => openIssue()}>
|
||
发放备用金
|
||
</Button>
|
||
) : null}
|
||
{canManageCredits ? (
|
||
<Button
|
||
icon={<WalletOutlined />}
|
||
onClick={() => setActiveTab('platform-credits')}
|
||
>
|
||
分配客服积分
|
||
</Button>
|
||
) : null}
|
||
{canExpense ? (
|
||
<Button icon={<PlusOutlined />} onClick={() => openExpense()}>
|
||
登记支出
|
||
</Button>
|
||
) : null}
|
||
{canReconcile ? (
|
||
<Button
|
||
icon={<CheckCircleOutlined />}
|
||
onClick={() => setActiveTab('reconciliations')}
|
||
>
|
||
对账中心
|
||
</Button>
|
||
) : null}
|
||
{isAdmin ? <Button onClick={() => openFunding()}>公司拨付</Button> : null}
|
||
</Space>
|
||
</Card>
|
||
|
||
{(overview?.unreconciledAgentCount || 0) > 0 ? (
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
message={`有 ${overview?.unreconciledAgentCount} 个客服超过 7 天未对账`}
|
||
description="请在“对账”页签发起对账,确认系统余额与实际余额是否一致。"
|
||
/>
|
||
) : null}
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
key: 'agents',
|
||
label: '客服现金钱包',
|
||
children: (
|
||
<Card
|
||
title="客服备用金账户"
|
||
extra={
|
||
canIssue ? (
|
||
<Button type="primary" onClick={() => openIssue()}>
|
||
发放备用金
|
||
</Button>
|
||
) : null
|
||
}
|
||
>
|
||
<PercentTable<AdminFundAccount>
|
||
rowKey="id"
|
||
size="small"
|
||
columns={buildAccountColumns('agent')}
|
||
dataSource={agentAccounts}
|
||
loading={agentsQuery.isLoading}
|
||
pagination={false}
|
||
scroll={{ x: 1250 }}
|
||
/>
|
||
</Card>
|
||
),
|
||
},
|
||
{
|
||
key: 'supervisors',
|
||
label: '现金资金池',
|
||
children: (
|
||
<Card
|
||
title="主管资金池"
|
||
extra={
|
||
isAdmin ? (
|
||
<Button type="primary" onClick={() => openFunding()}>
|
||
公司拨付
|
||
</Button>
|
||
) : null
|
||
}
|
||
>
|
||
<PercentTable<AdminFundAccount>
|
||
rowKey="id"
|
||
size="small"
|
||
columns={buildAccountColumns('supervisor')}
|
||
dataSource={supervisorAccounts}
|
||
loading={supervisorsQuery.isLoading}
|
||
pagination={false}
|
||
scroll={{ x: 1150 }}
|
||
/>
|
||
</Card>
|
||
),
|
||
},
|
||
{
|
||
key: 'platform-credits',
|
||
label: '客服积分钱包',
|
||
children: <PlatformCreditPanel />,
|
||
},
|
||
{
|
||
key: 'expenses',
|
||
label: '现金支出',
|
||
children: (
|
||
<Card
|
||
title="备用金支出"
|
||
extra={
|
||
canExpense ? (
|
||
<Button type="primary" onClick={() => openExpense()}>
|
||
登记支出
|
||
</Button>
|
||
) : null
|
||
}
|
||
>
|
||
<Space wrap style={{ marginBottom: 16 }}>
|
||
<Input
|
||
allowClear
|
||
placeholder="支出单号/收款方/平台/客服/订单"
|
||
value={expenseKeyword}
|
||
onChange={(event) => {
|
||
setExpensePage(1)
|
||
setExpenseKeyword(event.target.value)
|
||
}}
|
||
style={{ width: 260 }}
|
||
/>
|
||
<Select
|
||
allowClear
|
||
placeholder="支出分类"
|
||
value={expenseCategory || undefined}
|
||
onChange={(value) => {
|
||
setExpensePage(1)
|
||
setExpenseCategory(value || '')
|
||
}}
|
||
style={{ width: 140 }}
|
||
options={Object.entries(FUND_CATEGORY_LABELS).map(([value, label]) => ({
|
||
value,
|
||
label,
|
||
}))}
|
||
/>
|
||
<DatePicker.RangePicker
|
||
showTime
|
||
value={expenseRange}
|
||
onChange={(value) => {
|
||
setExpensePage(1)
|
||
setExpenseRange(value as [Dayjs, Dayjs] | null)
|
||
}}
|
||
/>
|
||
</Space>
|
||
<PercentTable<AdminFundExpense>
|
||
rowKey="id"
|
||
size="small"
|
||
columns={expenseColumns}
|
||
dataSource={expenses}
|
||
loading={expensesQuery.isLoading}
|
||
pagination={buildAdminTablePagination({
|
||
current: expensePage,
|
||
pageSize: expensePageSize,
|
||
total: expensePagination?.total || 0,
|
||
onChange: (page, pageSize) => {
|
||
setExpensePage(page || 1)
|
||
setExpensePageSize(pageSize || 20)
|
||
},
|
||
})}
|
||
scroll={{ x: 1250 }}
|
||
/>
|
||
</Card>
|
||
),
|
||
},
|
||
{
|
||
key: 'reconciliations',
|
||
label: '对账中心',
|
||
children: (
|
||
<Card
|
||
title="现金对账"
|
||
extra={
|
||
canReconcile ? (
|
||
<Button type="primary" onClick={() => openReconcile()}>
|
||
发起对账
|
||
</Button>
|
||
) : null
|
||
}
|
||
>
|
||
<Space wrap style={{ marginBottom: 16 }}>
|
||
<Select
|
||
allowClear
|
||
placeholder="对账状态"
|
||
value={reconciliationStatus || undefined}
|
||
onChange={(value) => {
|
||
setReconciliationPage(1)
|
||
setReconciliationStatus(value || '')
|
||
}}
|
||
style={{ width: 140 }}
|
||
options={Object.entries(FUND_RECONCILIATION_STATUS_LABELS).map(
|
||
([value, label]) => ({ value, label }),
|
||
)}
|
||
/>
|
||
</Space>
|
||
<PercentTable<AdminFundReconciliation>
|
||
rowKey="id"
|
||
size="small"
|
||
columns={reconciliationColumns}
|
||
dataSource={reconciliations}
|
||
loading={reconciliationsQuery.isLoading}
|
||
pagination={buildAdminTablePagination({
|
||
current: reconciliationPage,
|
||
pageSize: reconciliationPageSize,
|
||
total: reconciliationPagination?.total || 0,
|
||
onChange: (page, pageSize) => {
|
||
setReconciliationPage(page || 1)
|
||
setReconciliationPageSize(pageSize || 20)
|
||
},
|
||
})}
|
||
scroll={{ x: 1250 }}
|
||
/>
|
||
</Card>
|
||
),
|
||
},
|
||
...(isAdmin
|
||
? [
|
||
{
|
||
key: 'setup',
|
||
label: '账户初始化',
|
||
children: (
|
||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||
<Card
|
||
title="主管资金池"
|
||
extra={
|
||
<Space>
|
||
<Button
|
||
onClick={() => openCreateAccount({ accountType: 'supervisor' })}
|
||
>
|
||
新增主管账户
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
<PercentTable<AdminFundAccount>
|
||
rowKey="id"
|
||
size="small"
|
||
columns={buildAccountColumns('supervisor')}
|
||
dataSource={setup?.supervisorAccounts || []}
|
||
loading={setupQuery.isLoading}
|
||
pagination={false}
|
||
scroll={{ x: 1150 }}
|
||
/>
|
||
{setupSupervisorUsersWithoutAccount.length > 0 ? (
|
||
<Alert
|
||
style={{ marginTop: 12 }}
|
||
type="info"
|
||
showIcon
|
||
message={`还有 ${setupSupervisorUsersWithoutAccount.length} 个运营/管理员未开通主管资金池`}
|
||
description={setupSupervisorUsersWithoutAccount
|
||
.map((user) => user.displayName || user.username)
|
||
.join('、')}
|
||
/>
|
||
) : null}
|
||
</Card>
|
||
|
||
<Card
|
||
title="客服备用金账户"
|
||
extra={
|
||
<Space>
|
||
<Button onClick={() => openCreateAccount({ accountType: 'agent' })}>
|
||
新增客服账户
|
||
</Button>
|
||
<Button type="primary" onClick={openBulkAgents}>
|
||
批量初始化客服
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
<PercentTable<AdminFundAccount>
|
||
rowKey="id"
|
||
size="small"
|
||
columns={buildAccountColumns('agent')}
|
||
dataSource={setup?.agentAccounts || []}
|
||
loading={setupQuery.isLoading}
|
||
pagination={false}
|
||
scroll={{ x: 1250 }}
|
||
/>
|
||
{setupSupportUsersWithoutAccount.length > 0 ? (
|
||
<Alert
|
||
style={{ marginTop: 12 }}
|
||
type="warning"
|
||
showIcon
|
||
message={`还有 ${setupSupportUsersWithoutAccount.length} 个客服未开通备用金账户`}
|
||
description={setupSupportUsersWithoutAccount
|
||
.map((user) => user.displayName || user.username)
|
||
.join('、')}
|
||
/>
|
||
) : null}
|
||
</Card>
|
||
</Space>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
]}
|
||
/>
|
||
|
||
<Modal
|
||
title="发放备用金"
|
||
open={issueOpen}
|
||
onCancel={() => setIssueOpen(false)}
|
||
onOk={() => issueForm.submit()}
|
||
confirmLoading={submitting}
|
||
destroyOnHidden
|
||
width={720}
|
||
>
|
||
<Form<IssueFormValues> form={issueForm} layout="vertical" onFinish={submitIssue}>
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="agentAccountId"
|
||
label="客服备用金账户"
|
||
rules={[{ required: true, message: '请选择客服账户' }]}
|
||
>
|
||
<Select options={agentOptions} showSearch optionFilterProp="label" />
|
||
</Form.Item>
|
||
</Col>
|
||
{isAdmin ? (
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="supervisorAccountId"
|
||
label="发放主管"
|
||
rules={[{ required: true, message: '请选择主管资金池' }]}
|
||
>
|
||
<Select options={supervisorOptions} showSearch optionFilterProp="label" />
|
||
</Form.Item>
|
||
</Col>
|
||
) : null}
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="amountYuan"
|
||
label="发放金额(元)"
|
||
rules={[{ required: true, message: '请输入金额' }]}
|
||
>
|
||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="occurredAt"
|
||
label="发放时间"
|
||
rules={[{ required: true, message: '请选择时间' }]}
|
||
>
|
||
<DatePicker showTime style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Form.Item name="evidence" label="转账/交接凭证">
|
||
<ImageUpload scene="admin_fund_issue" scope="admin" maxCount={4} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Form.Item name="note" label="备注">
|
||
<Input.TextArea rows={2} placeholder="发放说明;无凭证时请填写交接说明" />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="公司拨付主管资金池"
|
||
open={fundingOpen}
|
||
onCancel={() => setFundingOpen(false)}
|
||
onOk={() => fundingForm.submit()}
|
||
confirmLoading={submitting}
|
||
destroyOnHidden
|
||
>
|
||
<Form<FundingFormValues> form={fundingForm} layout="vertical" onFinish={submitFunding}>
|
||
<Form.Item
|
||
name="supervisorAccountId"
|
||
label="主管资金池"
|
||
rules={[{ required: true, message: '请选择主管资金池' }]}
|
||
>
|
||
<Select options={supervisorOptions} showSearch optionFilterProp="label" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="amountYuan"
|
||
label="拨付金额(元)"
|
||
rules={[{ required: true, message: '请输入金额' }]}
|
||
>
|
||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="occurredAt"
|
||
label="拨付时间"
|
||
rules={[{ required: true, message: '请选择时间' }]}
|
||
>
|
||
<DatePicker showTime style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="evidence" label="转账/交接凭证">
|
||
<ImageUpload scene="admin_fund_funding" scope="admin" maxCount={4} />
|
||
</Form.Item>
|
||
<Form.Item name="note" label="备注">
|
||
<Input.TextArea rows={2} placeholder="公司拨付说明" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="登记备用金支出"
|
||
open={expenseOpen}
|
||
onCancel={() => setExpenseOpen(false)}
|
||
onOk={() => expenseForm.submit()}
|
||
confirmLoading={submitting}
|
||
destroyOnHidden
|
||
width={760}
|
||
>
|
||
<Form<ExpenseFormValues> form={expenseForm} layout="vertical" onFinish={submitExpense}>
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="agentAccountId"
|
||
label="客服备用金账户"
|
||
rules={[{ required: true, message: '请选择客服账户' }]}
|
||
>
|
||
<Select options={agentOptions} showSearch optionFilterProp="label" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="amountYuan"
|
||
label="支出金额(元)"
|
||
rules={[{ required: true, message: '请输入金额' }]}
|
||
>
|
||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="category"
|
||
label="支出分类"
|
||
rules={[{ required: true, message: '请选择分类' }]}
|
||
>
|
||
<Select
|
||
options={Object.entries(FUND_CATEGORY_LABELS).map(([value, label]) => ({
|
||
value,
|
||
label,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="payeeType"
|
||
label="收款方类型"
|
||
rules={[{ required: true, message: '请选择收款方类型' }]}
|
||
>
|
||
<Select
|
||
options={Object.entries(FUND_PAYEE_LABELS).map(([value, label]) => ({
|
||
value,
|
||
label,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="payeeName"
|
||
label="收款方名称"
|
||
rules={[{ required: true, message: '请填写收款方' }]}
|
||
>
|
||
<Input placeholder="平台/供应商/客户名称" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item name="payeeAccount" label="收款账号">
|
||
<Input placeholder="微信号/支付宝/银行卡/平台账号" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item name="platformName" label="充值/消费平台">
|
||
<Input placeholder="例如:91卡券、快手、游戏平台" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item name="platformAccount" label="平台账号">
|
||
<Input placeholder="充值时填写" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item name="orderId" label="关联订单 ID(可选)">
|
||
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="paidAt"
|
||
label="付款时间"
|
||
rules={[{ required: true, message: '请选择付款时间' }]}
|
||
>
|
||
<DatePicker showTime style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Form.Item
|
||
name="evidence"
|
||
label="付款凭证"
|
||
rules={[{ required: true, message: '请上传付款截图' }]}
|
||
>
|
||
<ImageUpload scene="admin_fund_expense" scope="admin" maxCount={6} showPasteHint />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Form.Item name="note" label="用途/原因">
|
||
<Input.TextArea rows={2} placeholder="例如:给订单充值、赔偿客户、补差价" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Form.Item name="allowDuplicate" valuePropName="checked">
|
||
<Checkbox>我已确认这不是重复支出(同一客服、金额、收款方 24 小时内)</Checkbox>
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="缴回备用金"
|
||
open={returnOpen}
|
||
onCancel={() => setReturnOpen(false)}
|
||
onOk={() => returnForm.submit()}
|
||
confirmLoading={submitting}
|
||
destroyOnHidden
|
||
width={680}
|
||
>
|
||
<Form<ReturnFormValues> form={returnForm} layout="vertical" onFinish={submitReturn}>
|
||
<Row gutter={16}>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="agentAccountId"
|
||
label="客服备用金账户"
|
||
rules={[{ required: true, message: '请选择客服账户' }]}
|
||
>
|
||
<Select options={agentOptions} showSearch optionFilterProp="label" />
|
||
</Form.Item>
|
||
</Col>
|
||
{isAdmin ? (
|
||
<Col span={12}>
|
||
<Form.Item name="supervisorAccountId" label="收回主管">
|
||
<Select
|
||
allowClear
|
||
options={supervisorOptions}
|
||
showSearch
|
||
optionFilterProp="label"
|
||
placeholder="默认该客服责任主管"
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
) : null}
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="amountYuan"
|
||
label="缴回金额(元)"
|
||
rules={[{ required: true, message: '请输入金额' }]}
|
||
>
|
||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="occurredAt"
|
||
label="缴回时间"
|
||
rules={[{ required: true, message: '请选择时间' }]}
|
||
>
|
||
<DatePicker showTime style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Form.Item name="evidence" label="转账/交接凭证">
|
||
<ImageUpload scene="admin_fund_return" scope="admin" maxCount={4} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Form.Item name="note" label="备注">
|
||
<Input.TextArea rows={2} placeholder="缴回原因或交接说明" />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="发起备用金对账"
|
||
open={reconcileOpen}
|
||
onCancel={() => setReconcileOpen(false)}
|
||
onOk={() => reconcileForm.submit()}
|
||
confirmLoading={submitting}
|
||
destroyOnHidden
|
||
width={680}
|
||
>
|
||
<Form<ReconcileFormValues>
|
||
form={reconcileForm}
|
||
layout="vertical"
|
||
onFinish={submitReconcile}
|
||
>
|
||
<Form.Item
|
||
name="accountId"
|
||
label="客服备用金账户"
|
||
rules={[{ required: true, message: '请选择账户' }]}
|
||
>
|
||
<Select options={agentOptions} showSearch optionFilterProp="label" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="period"
|
||
label="对账区间"
|
||
rules={[{ required: true, message: '请选择对账区间' }]}
|
||
>
|
||
<DatePicker.RangePicker showTime style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="actualBalanceYuan"
|
||
label="实际余额(元)"
|
||
rules={[{ required: true, message: '请输入实际余额' }]}
|
||
>
|
||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name="reason" label="差异原因">
|
||
<Input.TextArea rows={2} placeholder="有差异时必填;无差异可留空" />
|
||
</Form.Item>
|
||
<Form.Item name="evidence" label="盘点/差异凭证">
|
||
<ImageUpload scene="admin_fund_reconcile" scope="admin" maxCount={4} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="确认对账"
|
||
open={confirmReconcileOpen}
|
||
onCancel={() => {
|
||
setConfirmReconcileOpen(false)
|
||
setConfirmReconciliation(null)
|
||
}}
|
||
onOk={() => confirmReconcileForm.submit()}
|
||
confirmLoading={submitting}
|
||
destroyOnHidden
|
||
>
|
||
{confirmReconciliation ? (
|
||
<Descriptions column={1} size="small" style={{ marginBottom: 16 }}>
|
||
<Descriptions.Item label="客服">{confirmReconciliation.agentName}</Descriptions.Item>
|
||
<Descriptions.Item label="系统余额">
|
||
{formatFundAmount(confirmReconciliation.systemBalanceFen)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="实际余额">
|
||
{formatFundAmount(confirmReconciliation.actualBalanceFen)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="差异">
|
||
<Typography.Text
|
||
type={confirmReconciliation.differenceFen === 0 ? 'success' : 'danger'}
|
||
>
|
||
{formatFundAmount(confirmReconciliation.differenceFen, true)}
|
||
</Typography.Text>
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
) : null}
|
||
<Form<ConfirmReconcileFormValues>
|
||
form={confirmReconcileForm}
|
||
layout="vertical"
|
||
onFinish={submitConfirmReconcile}
|
||
>
|
||
<Form.Item name="reason" label="差异原因/确认说明">
|
||
<Input.TextArea rows={2} placeholder="有差异时必填,确认后生成调整流水" />
|
||
</Form.Item>
|
||
<Form.Item name="evidence" label="差异凭证">
|
||
<ImageUpload scene="admin_fund_reconcile_confirm" scope="admin" maxCount={4} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="新增备用金账户"
|
||
open={createAccountOpen}
|
||
onCancel={() => setCreateAccountOpen(false)}
|
||
onOk={() => createAccountForm.submit()}
|
||
confirmLoading={submitting}
|
||
destroyOnHidden
|
||
>
|
||
<Form<CreateAccountFormValues>
|
||
form={createAccountForm}
|
||
layout="vertical"
|
||
onFinish={submitCreateAccount}
|
||
>
|
||
<Form.Item
|
||
name="accountType"
|
||
label="账户类型"
|
||
rules={[{ required: true, message: '请选择账户类型' }]}
|
||
>
|
||
<Select
|
||
options={[
|
||
{ value: 'supervisor', label: '主管资金池' },
|
||
{ value: 'agent', label: '客服备用金账户' },
|
||
]}
|
||
onChange={() => createAccountForm.setFieldsValue({ ownerAdminUserId: undefined })}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="ownerAdminUserId"
|
||
label={createAccountType === 'supervisor' ? '主管账号' : '客服账号'}
|
||
rules={[{ required: true, message: '请选择账号' }]}
|
||
>
|
||
<Select
|
||
showSearch
|
||
optionFilterProp="label"
|
||
options={createAccountUserOptions.map((user: AdminFundSetupUser) => ({
|
||
value: user.id,
|
||
label: `${user.displayName || user.username}(${user.username} · ${user.role})`,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
{createAccountType === 'agent' ? (
|
||
<Form.Item
|
||
name="responsibleSupervisorAccountId"
|
||
label="责任主管"
|
||
rules={[{ required: true, message: '请选择责任主管' }]}
|
||
>
|
||
<Select options={supervisorOptions} showSearch optionFilterProp="label" />
|
||
</Form.Item>
|
||
) : null}
|
||
<Form.Item name="openingBalanceYuan" label="期初余额(元)">
|
||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="批量初始化客服备用金账户"
|
||
open={bulkAgentsOpen}
|
||
onCancel={() => setBulkAgentsOpen(false)}
|
||
onOk={() => bulkAgentsForm.submit()}
|
||
confirmLoading={submitting}
|
||
destroyOnHidden
|
||
>
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 16 }}
|
||
message="会给所有未开户的 support 角色客服创建账户"
|
||
description="已开户的客服不会被覆盖;期初余额由主管确认后填写。"
|
||
/>
|
||
<Form<BulkAgentsFormValues>
|
||
form={bulkAgentsForm}
|
||
layout="vertical"
|
||
onFinish={submitBulkAgents}
|
||
>
|
||
<Form.Item
|
||
name="responsibleSupervisorAccountId"
|
||
label="责任主管"
|
||
rules={[{ required: true, message: '请选择责任主管' }]}
|
||
>
|
||
<Select options={supervisorOptions} showSearch optionFilterProp="label" />
|
||
</Form.Item>
|
||
<Form.Item name="openingBalanceYuan" label="统一期初余额(元)">
|
||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Drawer
|
||
title={detailAccount ? `备用金账户 · ${detailAccount.ownerName}` : '备用金账户'}
|
||
open={Boolean(detailAccount)}
|
||
onClose={() => setDetailAccount(null)}
|
||
width={1000}
|
||
destroyOnHidden
|
||
>
|
||
{detailQuery.data?.data.account ? (
|
||
<>
|
||
<Descriptions column={2} size="small" bordered>
|
||
<Descriptions.Item label="账户编号">
|
||
{detailQuery.data.data.account.accountNo}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="类型">
|
||
{detailQuery.data.data.account.accountType === 'supervisor'
|
||
? '主管资金池'
|
||
: '客服备用金'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="当前余额">
|
||
{formatFundAmount(detailQuery.data.data.account.balanceFen)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="责任主管">
|
||
{detailQuery.data.data.account.responsibleSupervisorName || '-'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="本月流入">
|
||
{formatFundAmount(detailQuery.data.data.account.monthInFen)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="本月流出">
|
||
{formatFundAmount(detailQuery.data.data.account.monthOutFen)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="最后流水">
|
||
{detailQuery.data.data.account.lastEntryAt
|
||
? formatAdminDateTime(detailQuery.data.data.account.lastEntryAt)
|
||
: '-'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="最后对账">
|
||
{detailQuery.data.data.account.lastReconciledAt
|
||
? formatAdminDateTime(detailQuery.data.data.account.lastReconciledAt)
|
||
: '-'}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
<Card title="资金流水" style={{ marginTop: 16 }}>
|
||
<PercentTable<AdminFundEntry>
|
||
rowKey="id"
|
||
size="small"
|
||
columns={detailEntryColumns}
|
||
dataSource={detailEntries}
|
||
loading={detailQuery.isLoading}
|
||
pagination={false}
|
||
scroll={{ x: 1000 }}
|
||
/>
|
||
</Card>
|
||
</>
|
||
) : null}
|
||
</Drawer>
|
||
</section>
|
||
)
|
||
}
|