b392c28787
CI / verify (pull_request) Has been cancelled
Add WineryBill/StoreBill tables, partner review-send-confirm flow, daily/monthly cron, and admin multi-select confirm with status filters. Co-authored-by: Cursor <cursoragent@cursor.com>
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
Button,
|
|
Card,
|
|
DatePicker,
|
|
Descriptions,
|
|
Drawer,
|
|
Form,
|
|
Modal,
|
|
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;
|
|
billNo: string;
|
|
billDate: string;
|
|
redeemCount: number;
|
|
redeemAmount: number;
|
|
settlementRate: number;
|
|
payoutAmount: number;
|
|
status: string;
|
|
paidAt?: string | null;
|
|
store?: { id: string; name: string; cityName: string; phone?: string };
|
|
};
|
|
|
|
type StoreOption = { id: string; name: string; phone: string };
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
UNPAID: '未打款',
|
|
PAID: '已打款',
|
|
};
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
UNPAID: 'red',
|
|
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-bills',
|
|
() => {
|
|
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);
|
|
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
|
const [batchLoading, setBatchLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
|
.then((res) => setStores(res.items))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
function confirmPay(ids: string[], amountHint?: number) {
|
|
Modal.confirm({
|
|
title: '确认打款?',
|
|
content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
|
okText: '确认打款',
|
|
cancelText: '取消',
|
|
onOk: async () => {
|
|
if (ids.length === 1) {
|
|
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
|
|
} else {
|
|
setBatchLoading(true);
|
|
try {
|
|
await request('/admin/store-bills/batch-confirm', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
});
|
|
} finally {
|
|
setBatchLoading(false);
|
|
}
|
|
}
|
|
message.success('已确认打款');
|
|
setSelectedKeys([]);
|
|
reload();
|
|
},
|
|
});
|
|
}
|
|
|
|
async function openDetail(id: string) {
|
|
const d = await request<Record<string, unknown>>(`/admin/store-bills/${id}`);
|
|
setDetail(d);
|
|
setDrawerOpen(true);
|
|
}
|
|
|
|
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-bills/export?${qs}`);
|
|
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
|
message.success(`已导出 ${result.count} 条`);
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
}
|
|
|
|
const summary = data?.summary;
|
|
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
|
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.payoutAmount), 0);
|
|
|
|
const columns: ColumnsType<Row> = [
|
|
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
|
{
|
|
title: '账单日',
|
|
dataIndex: 'billDate',
|
|
width: 110,
|
|
render: (v) => String(v || '').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: 'redeemCount', width: 90 },
|
|
{
|
|
title: '核销金额',
|
|
dataIndex: 'redeemAmount',
|
|
width: 110,
|
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
|
},
|
|
{
|
|
title: '结算比例',
|
|
dataIndex: 'settlementRate',
|
|
width: 90,
|
|
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
|
},
|
|
{
|
|
title: '应付金额',
|
|
dataIndex: 'payoutAmount',
|
|
width: 110,
|
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
|
},
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
width: 90,
|
|
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
|
},
|
|
{
|
|
title: '操作',
|
|
width: 160,
|
|
fixed: 'right',
|
|
render: (_, row) => (
|
|
<Space size={0}>
|
|
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
|
明细
|
|
</Button>
|
|
{row.status === 'UNPAID' && (
|
|
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}>
|
|
确认打款
|
|
</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">
|
|
按核销日汇总(每日 8:00 自动出账);未打款红色、已打款绿色
|
|
</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 ?? 0} prefix="¥" precision={2} />
|
|
</Space>
|
|
</Card>
|
|
)}
|
|
|
|
<Form
|
|
form={form}
|
|
layout="inline"
|
|
style={{ marginBottom: 16 }}
|
|
onFinish={(v: { storeId?: string; status?: string; range?: [Dayjs, Dayjs] }) => {
|
|
setFilters({
|
|
storeId: v.storeId || '',
|
|
status: v.status || '',
|
|
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
|
dateTo: v.range?.[1] ? v.range[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 || s.id }))}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="status" label="状态">
|
|
<Select
|
|
allowClear
|
|
style={{ width: 120 }}
|
|
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="range" 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.Item>
|
|
<Button
|
|
type="primary"
|
|
disabled={!selectedKeys.length}
|
|
loading={batchLoading}
|
|
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
|
|
>
|
|
批量确认打款 ({selectedKeys.length})
|
|
</Button>
|
|
</Form.Item>
|
|
</Form>
|
|
|
|
<Table
|
|
rowKey="id"
|
|
className="admin-table-nowrap"
|
|
loading={loading}
|
|
columns={columns}
|
|
dataSource={data?.items ?? []}
|
|
rowSelection={{
|
|
selectedRowKeys: selectedKeys,
|
|
onChange: setSelectedKeys,
|
|
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
|
}}
|
|
scroll={{ x: 1200 }}
|
|
pagination={{
|
|
current: page,
|
|
pageSize,
|
|
total: data?.total ?? 0,
|
|
showSizeChanger: true,
|
|
onChange: (p, ps) => {
|
|
setPage(p);
|
|
setPageSize(ps);
|
|
},
|
|
}}
|
|
/>
|
|
|
|
<Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
|
{detail && (
|
|
<>
|
|
<Descriptions column={1} size="small" bordered>
|
|
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
|
|
<Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item>
|
|
<Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
|
<Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item>
|
|
<Descriptions.Item label="打款时间">
|
|
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
|
核销明细
|
|
</Typography.Title>
|
|
<Table
|
|
size="small"
|
|
rowKey="id"
|
|
pagination={false}
|
|
dataSource={(detail.payouts as Array<Record<string, unknown>>) || []}
|
|
columns={[
|
|
{
|
|
title: '核销单号',
|
|
render: (_, r) => String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
|
},
|
|
{
|
|
title: '金额',
|
|
dataIndex: 'payoutAmount',
|
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
|
},
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
render: (s) => (s === 'PAID' ? '已打款' : '未打款'),
|
|
},
|
|
]}
|
|
/>
|
|
</>
|
|
)}
|
|
</Drawer>
|
|
</div>
|
|
);
|
|
}
|