Files
dukang/apps/admin-web/src/pages/BenefitCouponsPage.tsx
T

244 lines
7.9 KiB
TypeScript

import { useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
import { request } from '../lib/api';
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
couponNo: string;
totalAmount: number;
balance: number;
usedAmount: number;
status: string;
sourceProduct: string;
createdAt: string;
user?: { userNo: string; phone: string | null };
order?: { orderNo: string } | null;
};
export default function BenefitCouponsPage() {
const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [grantOpen, setGrantOpen] = useState(false);
const [granting, setGranting] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/benefit/coupons',
() => {
const qs = new URLSearchParams();
if (filters.couponNo) qs.set('couponNo', filters.couponNo);
if (filters.status) qs.set('status', filters.status);
if (filters.userId) qs.set('userId', filters.userId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const columns: ColumnsType<Row> = [
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
{
title: '订单',
dataIndex: ['order', 'orderNo'],
width: 180,
ellipsis: false,
render: (v) => v || '—',
},
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => ${v}` },
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => ${v}` },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
{ title: '来源', dataIndex: 'sourceProduct', ellipsis: true },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
>
详情
</Button>
),
},
];
async function handleGrant(values: AdminBenefitGrantRequest) {
setGranting(true);
try {
await request('/admin/benefit/coupons/grant', {
method: 'POST',
body: JSON.stringify({
phone: values.phone.trim(),
amount: values.amount,
remark: values.remark?.trim() || undefined,
}),
});
message.success('权益已发放');
setGrantOpen(false);
grantForm.resetFields();
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '发放失败');
} finally {
setGranting(false);
}
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
好客权益券
</Typography.Title>
<Button type="primary" onClick={() => setGrantOpen(true)}>
手动发放
</Button>
</div>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="couponNo" label="券号">
<Input allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(COUPON_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
查询
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title="手动发放好客权益"
open={grantOpen}
onCancel={() => setGrantOpen(false)}
onOk={() => grantForm.submit()}
confirmLoading={granting}
destroyOnClose
>
<Form form={grantForm} layout="vertical" onFinish={(v) => void handleGrant(v)}>
<Form.Item
name="phone"
label="用户手机号"
rules={[
{ required: true, message: '请输入用户手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的 11 位手机号' },
]}
>
<Input placeholder="已注册 C 端用户的手机号" maxLength={11} />
</Form.Item>
<Form.Item
name="amount"
label="权益金额(元)"
rules={[{ required: true, message: '请输入权益金额' }]}
>
<InputNumber
min={0.01}
max={999999.99}
precision={2}
style={{ width: '100%' }}
placeholder="发放金额"
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={200} placeholder="可选,默认「总部手动发放」" />
</Form.Item>
</Form>
</Modal>
<Drawer
title="权益券详情"
width={560}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail &&
detail.status !== 'VOID' && (
<Popconfirm
title="确认作废此券?"
onConfirm={async () => {
await request(`/admin/benefit/coupons/${detail.id}/void`, { method: 'POST' });
message.success('已作废');
setDrawerOpen(false);
void reload();
}}
>
<Button danger>作废</Button>
</Popconfirm>
)
}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="券号">{String(detail.couponNo)}</Descriptions.Item>
<Descriptions.Item label="用户">
{String((detail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="手机号">
{String((detail.user as { phone?: string } | undefined)?.phone ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="关联订单">
{String((detail.order as { orderNo?: string } | null | undefined)?.orderNo ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="总额">¥{String(detail.totalAmount)}</Descriptions.Item>
<Descriptions.Item label="余额">¥{String(detail.balance)}</Descriptions.Item>
<Descriptions.Item label="状态">
{COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)}
</Descriptions.Item>
<Descriptions.Item label="来源">{String(detail.sourceProduct)}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}