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

395 lines
12 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 { 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>
);
}