Files
dukang/apps/admin-web/src/pages/BenefitCouponsPage.tsx
T
jacy cd206d6baa
CI / verify (pull_request) Has been cancelled
feat(ops): show redeem records on benefit coupon detail
Reuse the same coupon redeem trace as order detail (primary/secondary, allocations).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:52:48 +08:00

466 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
};
type CouponRedeemRecord = {
id: string;
redeemNo: string;
amount: number;
settleAmount: number;
couponAmount?: number;
role?: 'PRIMARY' | 'SECONDARY';
createdAt: string;
store?: { id: string; name: string; cityName?: string | null } | null;
};
type CouponRedeemSummary = {
couponNo: string;
totalAmount: number;
usedAmount: number;
balance: number;
status: string;
redeemCount: number;
redeemRecordSum: number;
};
type CouponDetail = Row & {
redeemSummary?: CouponRedeemSummary | null;
redeemRecords?: CouponRedeemRecord[];
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<CouponDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true);
setRedeemDrawerOpen(true);
try {
const res = await request<Record<string, unknown>>(`/admin/redeem-records/${redeemId}`);
setRedeemDetail(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载核销详情失败');
setRedeemDrawerOpen(false);
} finally {
setRedeemDetailLoading(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={720}
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="券号">{detail.couponNo}</Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">{detail.order?.orderNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">
{COUPON_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
权益核销
</Typography.Title>
{detail.redeemSummary ? (
<>
<Descriptions column={2} bordered size="small" style={{ marginBottom: 12 }}>
<Descriptions.Item label="权益券号">{detail.redeemSummary.couponNo}</Descriptions.Item>
<Descriptions.Item label="券状态">{detail.redeemSummary.status}</Descriptions.Item>
<Descriptions.Item label="权益总额">
¥{Number(detail.redeemSummary.totalAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="已核销">
<Typography.Text type="danger" strong>
¥{Number(detail.redeemSummary.usedAmount).toFixed(2)}
</Typography.Text>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{detail.redeemSummary.redeemCount} 笔核销单)
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="剩余余额">
¥{Number(detail.redeemSummary.balance).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="核销单合计额">
¥{Number(detail.redeemSummary.redeemRecordSum).toFixed(2)}
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
(本券分摊合计)
</Typography.Text>
</Descriptions.Item>
</Descriptions>
<Table
size="small"
rowKey="id"
pagination={false}
locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []}
columns={[
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
{
title: '角色',
dataIndex: 'role',
width: 70,
render: (v: string | undefined) =>
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
},
{
title: '门店',
dataIndex: ['store', 'name'],
ellipsis: true,
render: (v: string | undefined, row: CouponRedeemRecord) =>
v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—',
},
{
title: '本券分摊',
dataIndex: 'couponAmount',
width: 95,
render: (v: number | undefined, row: CouponRedeemRecord) =>
${Number(v ?? row.amount).toFixed(2)}`,
},
{
title: '核销总额',
dataIndex: 'amount',
width: 90,
render: (v: number) => ${Number(v).toFixed(2)}`,
},
{
title: '结算额',
dataIndex: 'settleAmount',
width: 90,
render: (v: number) => ${Number(v).toFixed(2)}`,
},
{
title: '时间',
dataIndex: 'createdAt',
width: 150,
render: (v: string) => fmtTime(v),
},
{
title: '操作',
width: 70,
render: (_: unknown, row: CouponRedeemRecord) => (
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
详情
</Button>
),
},
]}
/>
</>
) : (
<Typography.Text type="secondary">暂无核销摘要</Typography.Text>
)}
</>
)}
</Drawer>
<Drawer
title="核销单详情"
width={520}
open={redeemDrawerOpen}
onClose={() => {
setRedeemDrawerOpen(false);
setRedeemDetail(null);
}}
destroyOnClose
>
{redeemDetailLoading ? (
<Typography.Text type="secondary">加载中…</Typography.Text>
) : redeemDetail ? (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
<Descriptions.Item label="核销额">
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="结算额">
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
<Descriptions.Item label="用户">
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
: ''}
</Descriptions.Item>
<Descriptions.Item label="门店">
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
? `${(redeemDetail.store as { cityName?: string }).cityName}`
: ''}
</Descriptions.Item>
<Descriptions.Item label="门店地址">
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="合伙人">
{String(
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
?.partnerAccount?.companyName ?? '—',
)}
</Descriptions.Item>
<Descriptions.Item label="权益券号">
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="关联订单">
{String(
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
'—',
)}
</Descriptions.Item>
{Array.isArray(redeemDetail.allocations) &&
(redeemDetail.allocations as unknown[]).length > 0 ? (
<Descriptions.Item label="券分摊">
{(
redeemDetail.allocations as Array<{
couponNo?: string;
orderNo?: string | null;
amount?: number;
sortOrder?: number;
}>
)
.map((a, idx) => {
const role = idx === 0 ? '主' : '次';
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
a.orderNo ? `(订单 ${a.orderNo}` : ''
}`;
})
.join('')}
</Descriptions.Item>
) : null}
{redeemDetail.payout ? (
<Descriptions.Item label="门店结算单">
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
{' / '}
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
</Descriptions.Item>
) : null}
{redeemDetail.rating ? (
<Descriptions.Item label="评价">
服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} / 环境{' '}
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'}
</Descriptions.Item>
) : null}
</Descriptions>
) : null}
</Drawer>
</div>
);
}