Files
order_site/apps/frontend/src/pages/admin/AdminFinancePage.tsx
T

341 lines
11 KiB
TypeScript

import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import {
Alert,
Button,
Card,
Col,
Form,
Row,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import PageHeader from '@/components/admin/PageHeader'
import dayjs from '@/lib/dayjs'
import {
fetchAdminFinanceSummary,
fetchAdminFinanceTransactions,
type AdminFinanceDateType,
} from '@/services/admin'
import type {
AdminFinanceChannelDailyItem,
AdminFinanceDailyItem,
AdminFinanceTransaction,
} from '@/types/admin/finance'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { formatMoney } from './panels/shared'
type FinanceFilterForm = { dateRange?: unknown; dateType?: AdminFinanceDateType }
const CATEGORY_OPTIONS = [
{ value: '', label: '全部账目' },
{ value: 'order_paid', label: '订单收款' },
{ value: 'order_refund', label: '订单退款' },
{ value: 'worker_reward', label: '打手报酬' },
{ value: 'withdraw_paid', label: '打手提现' },
{ value: 'recharge', label: '打手充值' },
{ value: 'worker_recovery', label: '打手资金回收' },
{ value: 'after_sales_refund', label: '售后追缴退还' },
]
export default function AdminFinancePage() {
const [form] = Form.useForm<FinanceFilterForm>()
const [filters, setFilters] = useState<{
dateFrom: string
dateTo: string
dateType: AdminFinanceDateType
}>({ dateFrom: '', dateTo: '', dateType: 'occurred' })
const [category, setCategory] = useState('')
const [channel, setChannel] = useState('')
const [channelSummaryFilter, setChannelSummaryFilter] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const summaryQuery = useQuery({
queryKey: ['admin-finance-summary', filters],
queryFn: () => fetchAdminFinanceSummary(filters),
})
const transactionsQuery = useQuery({
queryKey: ['admin-finance-transactions', filters, category, page, pageSize],
queryFn: () =>
fetchAdminFinanceTransactions({
...filters,
category: category || undefined,
channel: channel || undefined,
page,
pageSize,
}),
})
const overview = summaryQuery.data?.data.overview
const daily = summaryQuery.data?.data.daily || []
const channelDaily = (summaryQuery.data?.data.channelDaily || []).filter(
(item) => !channelSummaryFilter || item.channel === channelSummaryFilter,
)
const transactionData = transactionsQuery.data?.data
function applyFilters(values: FinanceFilterForm) {
const range = Array.isArray(values.dateRange) ? values.dateRange : []
const dateFrom =
range[0] && dayjs(range[0]).isValid() ? dayjs(range[0]).format('YYYY-MM-DD HH:mm:ss') : ''
const dateTo =
range[1] && dayjs(range[1]).isValid()
? dayjs(range[1]).second(59).format('YYYY-MM-DD HH:mm:ss')
: ''
setPage(1)
setFilters({ dateFrom, dateTo, dateType: values.dateType || 'occurred' })
}
function resetFilters() {
form.resetFields()
setCategory('')
setChannel('')
setChannelSummaryFilter('')
setPage(1)
setFilters({ dateFrom: '', dateTo: '', dateType: 'occurred' })
}
const dailyColumns: TableColumnsType<AdminFinanceDailyItem> = [
{ title: '日期', dataIndex: 'date', width: 120 },
{ title: '支付订单', dataIndex: 'paidOrderCount', width: 100 },
{ title: '收款', dataIndex: 'paidAmount', render: formatMoney },
{ title: '退款', dataIndex: 'refundAmount', render: formatMoney },
{ title: '打手报酬', dataIndex: 'workerRewardAmount', render: formatMoney },
{ title: '已提现', dataIndex: 'withdrawAmount', render: formatMoney },
{ title: '充值入账', dataIndex: 'rechargeAmount', render: formatMoney },
{ title: '售后退还', dataIndex: 'afterSalesRefundAmount', render: formatMoney },
{
title: '净现金流',
dataIndex: 'netCashFlow',
render: (value: number) => (
<Typography.Text type={value >= 0 ? 'success' : 'danger'}>
{formatMoney(value)}
</Typography.Text>
),
},
]
const transactionColumns: TableColumnsType<AdminFinanceTransaction> = [
{
title: '发生时间',
dataIndex: 'occurredAt',
width: 180,
render: (value) => formatAdminDateTime(value),
},
{
title: '类型',
dataIndex: 'label',
width: 150,
render: (value: string, row) => (
<Tag color={row.direction === 'in' ? 'green' : 'orange'}>{value}</Tag>
),
},
{ title: '渠道', dataIndex: 'channel', width: 150 },
{
title: '金额',
dataIndex: 'amount',
width: 140,
render: (value: number, row) => (
<Typography.Text type={row.direction === 'in' ? 'success' : 'danger'}>
{row.direction === 'in' ? '+' : '-'}
{formatMoney(value)}
</Typography.Text>
),
},
{ title: '来源编号', dataIndex: 'sourceId', width: 110 },
{
title: '关联单号/渠道',
dataIndex: 'referenceNo',
render: (value: string) => value || '-',
},
]
return (
<section className="page-stack">
<PageHeader title="财务" description="按日期查看订单收款、退款、打手结算和平台现金流。" />
<Card>
<Form<FinanceFilterForm>
form={form}
layout="inline"
className="filter-form"
onFinish={applyFilters}
>
<Form.Item name="dateType" label="时间类型" initialValue="occurred">
<Select
style={{ width: 160 }}
options={[
{ value: 'occurred', label: '账务发生时间' },
{ value: 'created', label: '打手申请时间' },
{ value: 'reviewed', label: '审核通过时间' },
]}
/>
</Form.Item>
<Form.Item name="dateRange" label="时间范围">
<AdminRangePicker showTime={{ format: 'HH:mm' }} format="YYYY-MM-DD HH:mm" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
查询
</Button>
<Button onClick={resetFilters}>重置</Button>
</Space>
</Form.Item>
</Form>
</Card>
{summaryQuery.error ? (
<Alert
type="error"
showIcon
message={
summaryQuery.error instanceof Error ? summaryQuery.error.message : '读取财务汇总失败'
}
/>
) : null}
<Row gutter={[16, 16]}>
<FinanceStatistic title="订单收款" value={overview?.paidAmount || 0} />
<FinanceStatistic title="订单退款" value={overview?.refundAmount || 0} />
<FinanceStatistic title="打手报酬" value={overview?.workerRewardAmount || 0} />
<FinanceStatistic title="已确认提现" value={overview?.withdrawAmount || 0} />
<FinanceStatistic title="净现金流" value={overview?.netCashFlow || 0} />
<FinanceStatistic title="待提现" value={overview?.pendingWithdrawAmount || 0} />
</Row>
<Card
title="每日账目"
extra={
<Button
icon={<ReloadOutlined />}
loading={summaryQuery.isFetching}
onClick={() => void summaryQuery.refetch()}
>
刷新
</Button>
}
>
<Table<AdminFinanceDailyItem>
rowKey="date"
loading={summaryQuery.isLoading}
dataSource={daily}
columns={dailyColumns}
pagination={false}
scroll={{ x: 1080 }}
/>
</Card>
<Card
title="渠道日报"
extra={
<Space wrap>
<Select
allowClear
value={channelSummaryFilter || undefined}
placeholder="全部渠道"
style={{ width: 180 }}
options={Array.from(
new Set((summaryQuery.data?.data.channelDaily || []).map((item) => item.channel)),
).map((value) => ({ value, label: value }))}
onChange={(value) => setChannelSummaryFilter(value || '')}
/>
<Typography.Text type="secondary">按日期、渠道和账目类型汇总</Typography.Text>
</Space>
}
>
<Table<AdminFinanceChannelDailyItem>
rowKey={(row) => `${row.date}-${row.channel}-${row.category}`}
loading={summaryQuery.isLoading}
dataSource={channelDaily}
pagination={false}
columns={[
{ title: '日期', dataIndex: 'date', width: 120 },
{ title: '渠道', dataIndex: 'channel', width: 180 },
{ title: '账目类型', dataIndex: 'category', width: 150, render: formatFinanceCategory },
{ title: '笔数', dataIndex: 'count', width: 90 },
{ title: '金额', dataIndex: 'amount', render: formatMoney },
]}
scroll={{ x: 760 }}
/>
</Card>
<Card
title="资金明细"
extra={
<Space wrap>
<Select
value={category}
options={CATEGORY_OPTIONS}
style={{ width: 160 }}
onChange={(value) => {
setCategory(value)
setPage(1)
}}
/>
<Select
allowClear
value={channel || undefined}
placeholder="全部渠道"
style={{ width: 180 }}
options={Array.from(
new Set((summaryQuery.data?.data.channelDaily || []).map((item) => item.channel)),
).map((value) => ({ value, label: value }))}
onChange={(value) => {
setChannel(value || '')
setPage(1)
}}
/>
<Button
icon={<ReloadOutlined />}
loading={transactionsQuery.isFetching}
onClick={() => void transactionsQuery.refetch()}
>
刷新
</Button>
</Space>
}
>
<Table<AdminFinanceTransaction>
rowKey={(row) => `${row.category}-${row.sourceId}-${row.occurredAt}`}
loading={transactionsQuery.isLoading}
dataSource={transactionData?.items || []}
columns={transactionColumns}
pagination={buildAdminTablePagination({
current: transactionData?.pagination.page || page,
pageSize: transactionData?.pagination.pageSize || pageSize,
total: transactionData?.pagination.total || 0,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
scroll={{ x: 780 }}
/>
</Card>
</section>
)
}
function formatFinanceCategory(value: string) {
return CATEGORY_OPTIONS.find((item) => item.value === value)?.label || value || '-'
}
function FinanceStatistic({ title, value }: { title: string; value: number }) {
return (
<Col xs={24} sm={12} xl={8}>
<Card>
<Statistic title={title} value={formatMoney(value)} />
</Card>
</Col>
)
}