299 lines
9.5 KiB
TypeScript
299 lines
9.5 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import {
|
||
Button,
|
||
Card,
|
||
DatePicker,
|
||
Descriptions,
|
||
Drawer,
|
||
Form,
|
||
Select,
|
||
Space,
|
||
Statistic,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import type { Dayjs } from 'dayjs';
|
||
import { request, type Paginated } from '../lib/api';
|
||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||
import { useAdminList } from '../lib/useAdminList';
|
||
|
||
type Row = {
|
||
id: string;
|
||
redeemAmount: number;
|
||
payoutAmount: number;
|
||
settlementRate: number;
|
||
status: string;
|
||
expectedPayAt: string;
|
||
paidAt?: string;
|
||
createdAt: string;
|
||
store?: { id: string; name: string; cityName: string; phone?: string };
|
||
redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
|
||
};
|
||
|
||
type StoreOption = { id: string; name: string; phone: string };
|
||
|
||
const PAYOUT_STATUS_LABELS: Record<string, string> = {
|
||
PENDING: '待打款',
|
||
PAID: '已打款',
|
||
};
|
||
|
||
const PAYOUT_STATUS_COLORS: Record<string, string> = {
|
||
PENDING: 'orange',
|
||
PAID: 'green',
|
||
};
|
||
|
||
export default function StoreBillsPage() {
|
||
const [form] = Form.useForm();
|
||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||
'/admin/store-payouts',
|
||
() => {
|
||
const qs = new URLSearchParams();
|
||
if (filters.status) qs.set('status', filters.status);
|
||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||
return qs;
|
||
},
|
||
[filters],
|
||
);
|
||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [exporting, setExporting] = useState(false);
|
||
|
||
useEffect(() => {
|
||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||
.then((res) => setStores(res.items))
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
async function confirmPayout(id: string) {
|
||
await request(`/admin/store-payouts/${id}/confirm`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ remark: '财务确认打款' }),
|
||
});
|
||
message.success('已确认打款');
|
||
reload();
|
||
}
|
||
|
||
async function exportExcel() {
|
||
setExporting(true);
|
||
try {
|
||
const qs = new URLSearchParams();
|
||
if (filters.status) qs.set('status', filters.status);
|
||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||
const result = await request<{ csv: string; count: number }>(
|
||
`/admin/store-payouts/export?${qs}`,
|
||
);
|
||
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||
message.success(`已导出 ${result.count} 条`);
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
}
|
||
|
||
const summary = data?.summary;
|
||
|
||
const columns: ColumnsType<Row> = [
|
||
{
|
||
title: '核销日期',
|
||
width: 110,
|
||
render: (_, r) =>
|
||
(r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
|
||
},
|
||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
|
||
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||
{
|
||
title: '核销单号',
|
||
dataIndex: ['redeemRecord', 'redeemNo'],
|
||
width: 160,
|
||
ellipsis: true,
|
||
render: (v) => v || '—',
|
||
},
|
||
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
|
||
{
|
||
title: '核销比例',
|
||
dataIndex: 'settlementRate',
|
||
width: 90,
|
||
render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'),
|
||
},
|
||
{
|
||
title: '应付门店',
|
||
dataIndex: 'payoutAmount',
|
||
width: 100,
|
||
render: (v) => `¥${v}`,
|
||
},
|
||
{
|
||
title: '打款状态',
|
||
dataIndex: 'status',
|
||
width: 100,
|
||
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
|
||
},
|
||
{ title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
|
||
{
|
||
title: '操作',
|
||
width: 140,
|
||
fixed: 'right',
|
||
render: (_, row) => (
|
||
<Space size={0}>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={async () => {
|
||
setDetail(await request(`/admin/store-payouts/${row.id}`));
|
||
setDrawerOpen(true);
|
||
}}
|
||
>
|
||
详情
|
||
</Button>
|
||
{row.status === 'PENDING' && (
|
||
<Button type="link" size="small" onClick={() => void confirmPayout(row.id)}>
|
||
确认打款
|
||
</Button>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||
门店账单
|
||
</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
T+1 结算:按日列出门店核销订单,应付 = 核销面额 × 门店核销比例
|
||
</Typography.Text>
|
||
</Space>
|
||
|
||
{summary && (
|
||
<Card size="small" style={{ marginBottom: 16 }}>
|
||
<Space size="large" wrap>
|
||
<Statistic title="明细笔数" value={summary.count} />
|
||
<Statistic title="核销面额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
|
||
<Statistic title="应付门店合计" value={summary.payoutAmount ?? summary.totalAmount} prefix="¥" precision={2} />
|
||
</Space>
|
||
</Card>
|
||
)}
|
||
|
||
<Form
|
||
form={form}
|
||
layout="inline"
|
||
style={{ marginBottom: 16 }}
|
||
onFinish={(v: {
|
||
storeId?: string;
|
||
status?: string;
|
||
dateRange?: [Dayjs, Dayjs];
|
||
}) => {
|
||
setFilters({
|
||
storeId: v.storeId || '',
|
||
status: v.status || '',
|
||
dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '',
|
||
dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '',
|
||
});
|
||
setPage(1);
|
||
}}
|
||
>
|
||
<Form.Item name="storeId" label="门店">
|
||
<Select
|
||
allowClear
|
||
showSearch
|
||
placeholder="全部门店"
|
||
style={{ width: 200 }}
|
||
optionFilterProp="label"
|
||
options={stores.map((s) => ({
|
||
value: s.id,
|
||
label: `${s.name}(${s.phone})`,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="status" label="打款状态">
|
||
<Select
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
placeholder="全部"
|
||
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="dateRange" label="核销日期">
|
||
<DatePicker.RangePicker />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit">
|
||
查询
|
||
</Button>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button
|
||
onClick={() => {
|
||
form.resetFields();
|
||
setFilters({});
|
||
setPage(1);
|
||
}}
|
||
>
|
||
重置
|
||
</Button>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||
导出 Excel
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
|
||
<Table
|
||
rowKey="id"
|
||
className="admin-table-nowrap"
|
||
loading={loading}
|
||
columns={columns}
|
||
dataSource={data?.items ?? []}
|
||
scroll={{ x: 1300 }}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total: data?.total ?? 0,
|
||
showSizeChanger: true,
|
||
onChange: (p, ps) => {
|
||
setPage(p);
|
||
setPageSize(ps);
|
||
},
|
||
}}
|
||
/>
|
||
|
||
<Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||
{detail && (
|
||
<Descriptions column={1} bordered size="small">
|
||
<Descriptions.Item label="门店">
|
||
{String((detail.store as { name?: string })?.name ?? '—')}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="核销单号">
|
||
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
|
||
<Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
|
||
<Descriptions.Item label="结算比例">
|
||
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
{PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
|
||
<Descriptions.Item label="实际打款">
|
||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
)}
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
}
|