Files
dukang/apps/admin-web/src/pages/RedeemRecordsPage.tsx
T
jacy 3ef4432b2f feat(admin-web): widen benefit coupon detail and enrich redeem record drawer (v3.4.13)
Share redeem detail fields across benefit coupon and redeem records pages;
widen coupon drawer to avoid horizontal scroll.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:27:11 +08:00

169 lines
5.0 KiB
TypeScript

import { useState } from 'react';
import { Button, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
redeemNo: string;
amount: number;
settleAmount: number;
channel?: RedeemChannel;
createdAt: string;
user?: { userNo: string; phone: string | null; nickname?: string | null };
store?: { name: string; cityName: string };
coupon?: { couponNo: string };
};
function maskPhone(phone: string | null | undefined) {
if (!phone || phone.length < 7) return phone ?? '—';
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
export default function RedeemRecordsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/redeem-records',
() => {
const qs = new URLSearchParams();
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.channel) qs.set('channel', filters.channel);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
async function openDetail(id: string) {
setDetailLoading(true);
setDrawerOpen(true);
try {
setDetail(await request(`/admin/redeem-records/${id}`));
} finally {
setDetailLoading(false);
}
}
const columns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
{
title: '方式',
dataIndex: 'channel',
width: 110,
render: (v: RedeemChannel | undefined) => {
const channel = v === 'PHONE' ? 'PHONE' : 'SCAN';
return (
<Tag color={channel === 'PHONE' ? 'purple' : 'blue'}>
{REDEEM_CHANNEL_LABELS[channel]}
</Tag>
);
},
},
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
{
title: '用户昵称',
dataIndex: ['user', 'nickname'],
width: 100,
render: (v: string | null | undefined) => v || '—',
},
{
title: '用户手机',
dataIndex: ['user', 'phone'],
width: 120,
render: (v: string | null | undefined) => maskPhone(v),
},
{ title: '门店', dataIndex: ['store', 'name'] },
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => ${v}` },
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => ${v}` },
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
详情
</Button>
),
},
];
return (
<div>
<Typography.Title level={4}>核销记录</Typography.Title>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="redeemNo" label="核销号">
<Input allowClear />
</Form.Item>
<Form.Item name="storeId" label="门店ID">
<Input allowClear />
</Form.Item>
<Form.Item name="channel" label="方式">
<Select
allowClear
style={{ width: 140 }}
options={[
{ value: 'SCAN', label: '扫码核销' },
{ value: 'PHONE', label: '手机号核销' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
查询
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
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={640}
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
setDetail(null);
}}
destroyOnClose
>
{detailLoading ? (
<Typography.Text type="secondary">加载中…</Typography.Text>
) : detail ? (
<RedeemRecordDetailDescriptions detail={detail} />
) : null}
</Drawer>
</div>
);
}