f3353bbce5
Co-authored-by: Cursor <cursoragent@cursor.com>
395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import {
|
||
Button,
|
||
DatePicker,
|
||
Descriptions,
|
||
Drawer,
|
||
Form,
|
||
Input,
|
||
Modal,
|
||
Select,
|
||
Space,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import type { Dayjs } from 'dayjs';
|
||
import { STORE_WITHDRAW_STATUS_LABELS, type StoreWithdrawStatus } from '@dukang/shared-types';
|
||
import { request, type Paginated } from '../lib/api';
|
||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||
import { useAdminList } from '../lib/useAdminList';
|
||
|
||
type Row = {
|
||
id: string;
|
||
withdrawNo: string;
|
||
amount: number;
|
||
payoutCount: number;
|
||
status: StoreWithdrawStatus;
|
||
appliedAt: string;
|
||
reviewedAt?: string | null;
|
||
paidAt?: string | null;
|
||
paymentRef?: string | null;
|
||
rejectReason?: string | null;
|
||
overdue?: boolean;
|
||
store?: { id: string; name: string; cityName: string; phone?: string };
|
||
};
|
||
|
||
type StoreOption = { id: string; name: string; phone: string };
|
||
|
||
const STATUS_COLORS: Record<string, string> = {
|
||
PENDING_REVIEW: 'orange',
|
||
REJECTED: 'red',
|
||
PAID: 'green',
|
||
};
|
||
|
||
export default function StoreWithdrawalsPage() {
|
||
const [form] = Form.useForm();
|
||
const [filters, setFilters] = useState<Record<string, string>>({ status: 'PENDING_REVIEW' });
|
||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||
'/admin/store-withdrawals',
|
||
() => {
|
||
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 [overdueSummary, setOverdueSummary] = useState<{
|
||
pendingCount: number;
|
||
overdueCount: number;
|
||
} | null>(null);
|
||
|
||
useEffect(() => {
|
||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||
.then((res) => setStores(res.items))
|
||
.catch(() => {});
|
||
void request<{ pendingCount: number; overdueCount: number }>(
|
||
'/admin/store-withdrawals/overdue-summary',
|
||
)
|
||
.then(setOverdueSummary)
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
async function openDetail(id: string) {
|
||
const d = await request<Record<string, unknown>>(`/admin/store-withdrawals/${id}`);
|
||
setDetail(d);
|
||
setDrawerOpen(true);
|
||
}
|
||
|
||
function approve(id: string) {
|
||
let paymentRef = '';
|
||
Modal.confirm({
|
||
title: '审核通过并标记已结算?',
|
||
content: (
|
||
<Input
|
||
placeholder="打款凭证号(可选)"
|
||
onChange={(e) => {
|
||
paymentRef = e.target.value;
|
||
}}
|
||
/>
|
||
),
|
||
okText: '通过并结算',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
await request(`/admin/store-withdrawals/${id}/approve`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
|
||
});
|
||
message.success('已通过并标记已结算');
|
||
setDrawerOpen(false);
|
||
reload();
|
||
void request<{ pendingCount: number; overdueCount: number }>(
|
||
'/admin/store-withdrawals/overdue-summary',
|
||
)
|
||
.then(setOverdueSummary)
|
||
.catch(() => {});
|
||
},
|
||
});
|
||
}
|
||
|
||
function reject(id: string) {
|
||
let reason = '';
|
||
Modal.confirm({
|
||
title: '驳回提现申请?',
|
||
content: (
|
||
<Input.TextArea
|
||
rows={3}
|
||
placeholder="请填写驳回理由"
|
||
onChange={(e) => {
|
||
reason = e.target.value;
|
||
}}
|
||
/>
|
||
),
|
||
okText: '确认驳回',
|
||
okButtonProps: { danger: true },
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
if (!reason.trim()) {
|
||
message.error('请填写驳回理由');
|
||
throw new Error('reason required');
|
||
}
|
||
await request(`/admin/store-withdrawals/${id}/reject`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ reason: reason.trim() }),
|
||
});
|
||
message.success('已驳回');
|
||
setDrawerOpen(false);
|
||
reload();
|
||
},
|
||
});
|
||
}
|
||
|
||
const columns: ColumnsType<Row> = [
|
||
{
|
||
title: '提现单号',
|
||
dataIndex: 'withdrawNo',
|
||
width: 180,
|
||
render: (v, row) => (
|
||
<Space>
|
||
<Typography.Link onClick={() => void openDetail(row.id)}>{v}</Typography.Link>
|
||
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '门店',
|
||
dataIndex: ['store', 'name'],
|
||
width: 160,
|
||
render: (_, row) => (
|
||
<div>
|
||
<div>{row.store?.name || '—'}</div>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{row.store?.cityName} {row.store?.phone}
|
||
</Typography.Text>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '金额',
|
||
dataIndex: 'amount',
|
||
width: 110,
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{ title: '明细笔数', dataIndex: 'payoutCount', width: 90 },
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 100,
|
||
render: (v: StoreWithdrawStatus) => (
|
||
<Tag color={STATUS_COLORS[v]}>{STORE_WITHDRAW_STATUS_LABELS[v] ?? v}</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '申请时间',
|
||
dataIndex: 'appliedAt',
|
||
width: 170,
|
||
render: (v) => fmtTime(v),
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'actions',
|
||
width: 180,
|
||
fixed: 'right',
|
||
render: (_, row) => (
|
||
<Space>
|
||
<Button size="small" onClick={() => void openDetail(row.id)}>
|
||
详情
|
||
</Button>
|
||
{row.status === 'PENDING_REVIEW' ? (
|
||
<>
|
||
<Button size="small" type="primary" onClick={() => approve(row.id)}>
|
||
通过
|
||
</Button>
|
||
<Button size="small" danger onClick={() => reject(row.id)}>
|
||
驳回
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
const detailItems = (detail?.items as Array<Record<string, unknown>> | undefined) ?? [];
|
||
const storeAccount = detail?.storeAccount as
|
||
| {
|
||
name?: string;
|
||
phone?: string;
|
||
bankAccountName?: string;
|
||
bankAccountNo?: string;
|
||
bankBranch?: string;
|
||
}
|
||
| undefined;
|
||
|
||
return (
|
||
<div>
|
||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||
门店提现审
|
||
</Typography.Title>
|
||
{overdueSummary ? (
|
||
<Typography.Paragraph type="secondary">
|
||
待审 {overdueSummary.pendingCount} 笔
|
||
{overdueSummary.overdueCount > 0 ? (
|
||
<Typography.Text type="danger">
|
||
{' '}
|
||
· 超时未审 {overdueSummary.overdueCount} 笔(FIN-003)
|
||
</Typography.Text>
|
||
) : null}
|
||
</Typography.Paragraph>
|
||
) : null}
|
||
|
||
<Form
|
||
form={form}
|
||
layout="inline"
|
||
style={{ marginBottom: 16, gap: 8 }}
|
||
initialValues={{ status: 'PENDING_REVIEW' }}
|
||
onFinish={(v) => {
|
||
const range = v.range as [Dayjs, Dayjs] | undefined;
|
||
setFilters({
|
||
status: v.status || '',
|
||
storeId: v.storeId || '',
|
||
dateFrom: range?.[0]?.format('YYYY-MM-DD') || '',
|
||
dateTo: range?.[1]?.format('YYYY-MM-DD') || '',
|
||
});
|
||
}}
|
||
>
|
||
<Form.Item name="status" label="状态">
|
||
<Select
|
||
allowClear
|
||
style={{ width: 140 }}
|
||
options={[
|
||
{ value: 'PENDING_REVIEW', label: '待审核' },
|
||
{ value: 'PAID', label: '已结算' },
|
||
{ value: 'REJECTED', label: '已驳回' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="storeId" label="门店">
|
||
<Select
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
style={{ width: 220 }}
|
||
options={stores.map((s) => ({
|
||
value: s.id,
|
||
label: `${s.name} (${s.phone})`,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="range" label="申请日">
|
||
<DatePicker.RangePicker />
|
||
</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: 1100 }}
|
||
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
|
||
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)}
|
||
extra={
|
||
detail?.status === 'PENDING_REVIEW' ? (
|
||
<Space>
|
||
<Button type="primary" onClick={() => approve(String(detail.id))}>
|
||
通过
|
||
</Button>
|
||
<Button danger onClick={() => reject(String(detail.id))}>
|
||
驳回
|
||
</Button>
|
||
</Space>
|
||
) : null
|
||
}
|
||
>
|
||
{detail ? (
|
||
<>
|
||
<Descriptions column={1} size="small" bordered>
|
||
<Descriptions.Item label="提现单号">{String(detail.withdrawNo)}</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
<Tag color={STATUS_COLORS[String(detail.status)]}>
|
||
{STORE_WITHDRAW_STATUS_LABELS[detail.status as StoreWithdrawStatus] ??
|
||
String(detail.status)}
|
||
</Tag>
|
||
{detail.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="金额">¥{Number(detail.amount).toFixed(2)}</Descriptions.Item>
|
||
<Descriptions.Item label="明细笔数">{String(detail.payoutCount)}</Descriptions.Item>
|
||
<Descriptions.Item label="申请时间">{fmtTime(String(detail.appliedAt))}</Descriptions.Item>
|
||
{detail.rejectReason ? (
|
||
<Descriptions.Item label="驳回理由">{String(detail.rejectReason)}</Descriptions.Item>
|
||
) : null}
|
||
{detail.paymentRef ? (
|
||
<Descriptions.Item label="打款凭证">{String(detail.paymentRef)}</Descriptions.Item>
|
||
) : null}
|
||
<Descriptions.Item label="收款户名">
|
||
{storeAccount?.bankAccountName || '—'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="收款账号">
|
||
{storeAccount?.bankAccountNo || '—'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="开户行">{storeAccount?.bankBranch || '—'}</Descriptions.Item>
|
||
</Descriptions>
|
||
|
||
<Typography.Title level={5} style={{ marginTop: 24 }}>
|
||
关联结算明细
|
||
</Typography.Title>
|
||
<Table
|
||
size="small"
|
||
rowKey={(r) => String((r as { id?: string }).id)}
|
||
pagination={false}
|
||
dataSource={detailItems}
|
||
columns={[
|
||
{
|
||
title: '核销单号',
|
||
render: (_, r) => {
|
||
const payout = (r as { storePayout?: { redeemRecord?: { redeemNo?: string } } })
|
||
.storePayout;
|
||
return payout?.redeemRecord?.redeemNo || '—';
|
||
},
|
||
},
|
||
{
|
||
title: '结算额',
|
||
render: (_, r) => {
|
||
const payout = (r as { storePayout?: { payoutAmount?: number } }).storePayout;
|
||
return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`;
|
||
},
|
||
},
|
||
]}
|
||
/>
|
||
</>
|
||
) : null}
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
}
|