54fca99208
总部财务以门店账单为唯一入口;新增 store-settlements API,去掉独立提现审菜单并更新企微引导文案。 Co-authored-by: Cursor <cursoragent@cursor.com>
609 lines
20 KiB
TypeScript
609 lines
20 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
import {
|
||
Button,
|
||
Card,
|
||
DatePicker,
|
||
Descriptions,
|
||
Drawer,
|
||
Form,
|
||
Input,
|
||
Modal,
|
||
Select,
|
||
Space,
|
||
Statistic,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import type { Dayjs } from 'dayjs';
|
||
import {
|
||
STORE_SETTLEMENT_KIND_LABELS,
|
||
STORE_SETTLEMENT_STATUS_LABELS,
|
||
storeSettlementStatusLabel,
|
||
type StoreSettlementKind,
|
||
type StoreSettlementStatus,
|
||
} from '@dukang/shared-types';
|
||
import { request, type Paginated } from '../lib/api';
|
||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||
import { useAdminList } from '../lib/useAdminList';
|
||
|
||
type Row = {
|
||
kind: StoreSettlementKind;
|
||
id: string;
|
||
billNo: string;
|
||
amount: number;
|
||
status: StoreSettlementStatus;
|
||
date: string;
|
||
overdue?: boolean;
|
||
redeemCount?: number | null;
|
||
redeemAmount?: number | null;
|
||
settlementRate?: number | null;
|
||
payoutCount?: number | null;
|
||
store?: { id: string; name: string; cityName: string; phone?: string | null };
|
||
};
|
||
|
||
type StoreOption = { id: string; name: string; phone: string };
|
||
|
||
const STATUS_COLORS: Record<string, string> = {
|
||
UNPAID: 'red',
|
||
PAID: 'green',
|
||
PENDING_REVIEW: 'orange',
|
||
REJECTED: 'red',
|
||
};
|
||
|
||
const KIND_COLORS: Record<StoreSettlementKind, string> = {
|
||
T1_BILL: 'blue',
|
||
WITHDRAW: 'purple',
|
||
};
|
||
|
||
export default function StoreBillsPage() {
|
||
const [searchParams] = useSearchParams();
|
||
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||
const [form] = Form.useForm();
|
||
const [filters, setFilters] = useState<Record<string, string>>({
|
||
kind: initialKind,
|
||
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
|
||
});
|
||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||
'/admin/store-settlements',
|
||
() => {
|
||
const qs = new URLSearchParams();
|
||
if (filters.kind) qs.set('kind', filters.kind);
|
||
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 [detailKind, setDetailKind] = useState<StoreSettlementKind | null>(null);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [exporting, setExporting] = useState(false);
|
||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||
const [batchLoading, setBatchLoading] = useState(false);
|
||
const [overdueSummary, setOverdueSummary] = useState<{
|
||
pendingCount: number;
|
||
overdueCount: number;
|
||
} | null>(null);
|
||
|
||
useEffect(() => {
|
||
form.setFieldsValue({
|
||
kind: filters.kind || undefined,
|
||
status: filters.status || undefined,
|
||
});
|
||
}, [filters.kind, filters.status, form]);
|
||
|
||
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(() => {});
|
||
}, []);
|
||
|
||
function confirmPay(ids: string[], amountHint?: number) {
|
||
Modal.confirm({
|
||
title: '确认打款?',
|
||
content: `将确认 ${ids.length} 笔 T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||
okText: '确认打款',
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
if (ids.length === 1) {
|
||
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||
} else {
|
||
setBatchLoading(true);
|
||
try {
|
||
await request('/admin/store-bills/batch-confirm', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ ids }),
|
||
});
|
||
} finally {
|
||
setBatchLoading(false);
|
||
}
|
||
}
|
||
message.success('已确认打款');
|
||
setSelectedKeys([]);
|
||
reload();
|
||
},
|
||
});
|
||
}
|
||
|
||
function approveWithdraw(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 rejectWithdraw(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();
|
||
},
|
||
});
|
||
}
|
||
|
||
async function openDetail(row: Row) {
|
||
const path =
|
||
row.kind === 'WITHDRAW' ? `/admin/store-withdrawals/${row.id}` : `/admin/store-bills/${row.id}`;
|
||
const d = await request<Record<string, unknown>>(path);
|
||
setDetail(d);
|
||
setDetailKind(row.kind);
|
||
setDrawerOpen(true);
|
||
}
|
||
|
||
async function exportExcel() {
|
||
setExporting(true);
|
||
try {
|
||
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);
|
||
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
|
||
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||
message.success(`已导出 ${result.count} 条 T+1 账单`);
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
}
|
||
|
||
const summary = data?.summary;
|
||
const selectedRows = (data?.items ?? []).filter(
|
||
(r) =>
|
||
selectedKeys.includes(`${r.kind}-${r.id}`) &&
|
||
r.kind === 'T1_BILL' &&
|
||
r.status === 'UNPAID',
|
||
);
|
||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0);
|
||
|
||
const columns: ColumnsType<Row> = [
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'kind',
|
||
width: 100,
|
||
render: (k: StoreSettlementKind) => (
|
||
<Tag color={KIND_COLORS[k]}>{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '单号',
|
||
dataIndex: 'billNo',
|
||
width: 180,
|
||
ellipsis: true,
|
||
render: (v, row) => (
|
||
<Space>
|
||
<span>{v}</span>
|
||
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '日期',
|
||
dataIndex: 'date',
|
||
width: 160,
|
||
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
|
||
},
|
||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
|
||
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||
{
|
||
title: '笔数',
|
||
width: 80,
|
||
render: (_, row) =>
|
||
row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'),
|
||
},
|
||
{
|
||
title: '应付金额',
|
||
dataIndex: 'amount',
|
||
width: 110,
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 100,
|
||
render: (s: string, row) => (
|
||
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 200,
|
||
fixed: 'right',
|
||
render: (_, row) => (
|
||
<Space size={0}>
|
||
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||
明细
|
||
</Button>
|
||
{row.kind === 'T1_BILL' && row.status === 'UNPAID' && (
|
||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.amount))}>
|
||
确认打款
|
||
</Button>
|
||
)}
|
||
{row.kind === 'WITHDRAW' && row.status === 'PENDING_REVIEW' && (
|
||
<>
|
||
<Button type="link" size="small" onClick={() => approveWithdraw(row.id)}>
|
||
通过
|
||
</Button>
|
||
<Button type="link" size="small" danger onClick={() => rejectWithdraw(row.id)}>
|
||
驳回
|
||
</Button>
|
||
</>
|
||
)}
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
const withdrawDetailItems =
|
||
(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>
|
||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||
门店账单
|
||
</Typography.Title>
|
||
<Typography.Text type="secondary">
|
||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口
|
||
</Typography.Text>
|
||
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
||
<Typography.Text type="danger">
|
||
手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount})
|
||
</Typography.Text>
|
||
) : null}
|
||
</Space>
|
||
|
||
{summary && (
|
||
<Card size="small" style={{ marginBottom: 16 }}>
|
||
<Space size="large" wrap>
|
||
<Statistic title="记录数" value={summary.count} />
|
||
<Statistic title="核销金额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
|
||
<Statistic title="应付合计" value={summary.payoutAmount ?? summary.totalAmount ?? 0} prefix="¥" precision={2} />
|
||
</Space>
|
||
</Card>
|
||
)}
|
||
|
||
<Form
|
||
form={form}
|
||
layout="inline"
|
||
style={{ marginBottom: 16 }}
|
||
initialValues={{
|
||
kind: filters.kind || undefined,
|
||
status: filters.status || undefined,
|
||
}}
|
||
onFinish={(v: {
|
||
kind?: string;
|
||
storeId?: string;
|
||
status?: string;
|
||
range?: [Dayjs, Dayjs];
|
||
}) => {
|
||
setFilters({
|
||
kind: v.kind || '',
|
||
storeId: v.storeId || '',
|
||
status: v.status || '',
|
||
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
||
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
|
||
});
|
||
setPage(1);
|
||
setSelectedKeys([]);
|
||
}}
|
||
>
|
||
<Form.Item name="kind" label="类型">
|
||
<Select
|
||
allowClear
|
||
style={{ width: 140 }}
|
||
options={Object.entries(STORE_SETTLEMENT_KIND_LABELS).map(([value, label]) => ({
|
||
value,
|
||
label,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="storeId" label="门店">
|
||
<Select
|
||
allowClear
|
||
showSearch
|
||
placeholder="全部门店"
|
||
style={{ width: 200 }}
|
||
optionFilterProp="label"
|
||
options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="status" label="状态">
|
||
<Select
|
||
allowClear
|
||
style={{ width: 120 }}
|
||
options={Object.entries(STORE_SETTLEMENT_STATUS_LABELS).map(([value, label]) => ({
|
||
value,
|
||
label:
|
||
value === 'PAID'
|
||
? '已打款/已结算'
|
||
: label,
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="range" label="日期">
|
||
<DatePicker.RangePicker />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit">
|
||
查询
|
||
</Button>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button
|
||
onClick={() => {
|
||
form.resetFields();
|
||
setFilters({});
|
||
setPage(1);
|
||
setSelectedKeys([]);
|
||
}}
|
||
>
|
||
重置
|
||
</Button>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button loading={exporting} onClick={() => void exportExcel()}>
|
||
导出 T+1 Excel
|
||
</Button>
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button
|
||
type="primary"
|
||
disabled={!selectedRows.length}
|
||
loading={batchLoading}
|
||
onClick={() => confirmPay(selectedRows.map((r) => r.id), selectedAmount)}
|
||
>
|
||
批量确认打款 ({selectedRows.length})
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
|
||
<Table
|
||
rowKey={(r) => `${r.kind}-${r.id}`}
|
||
className="admin-table-nowrap"
|
||
loading={loading}
|
||
columns={columns}
|
||
dataSource={data?.items ?? []}
|
||
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
|
||
rowSelection={{
|
||
selectedRowKeys: selectedKeys,
|
||
onChange: setSelectedKeys,
|
||
getCheckboxProps: (r) => ({
|
||
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
|
||
}),
|
||
}}
|
||
scroll={{ x: 1300 }}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total: data?.total ?? 0,
|
||
showSizeChanger: true,
|
||
onChange: (p, ps) => {
|
||
setPage(p);
|
||
setPageSize(ps);
|
||
},
|
||
}}
|
||
/>
|
||
|
||
<Drawer
|
||
title={detailKind === 'WITHDRAW' ? '手动提现明细' : 'T+1 对账单明细'}
|
||
open={drawerOpen}
|
||
onClose={() => setDrawerOpen(false)}
|
||
width={detailKind === 'WITHDRAW' ? 640 : 520}
|
||
>
|
||
{detail && detailKind === 'T1_BILL' && (
|
||
<>
|
||
<Descriptions column={1} size="small" bordered>
|
||
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
|
||
<Descriptions.Item label="账单日">
|
||
{String(detail.billDate || '').slice(0, 10)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="应付">
|
||
¥{Number(detail.payoutAmount ?? 0).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
{storeSettlementStatusLabel('T1_BILL', String(detail.status))}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="打款时间">
|
||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||
核销明细
|
||
</Typography.Title>
|
||
<Table
|
||
size="small"
|
||
rowKey="id"
|
||
pagination={false}
|
||
dataSource={(detail.payouts as Array<Record<string, unknown>>) || []}
|
||
columns={[
|
||
{
|
||
title: '核销单号',
|
||
render: (_, r) =>
|
||
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
||
},
|
||
{
|
||
title: '金额',
|
||
dataIndex: 'payoutAmount',
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
render: (s) => (s === 'PAID' ? '已打款' : '未打款'),
|
||
},
|
||
]}
|
||
/>
|
||
</>
|
||
)}
|
||
{detail && detailKind === 'WITHDRAW' && (
|
||
<>
|
||
<Descriptions column={1} size="small" bordered>
|
||
<Descriptions.Item label="提现单号">{String(detail.withdrawNo)}</Descriptions.Item>
|
||
<Descriptions.Item label="金额">
|
||
¥{Number(detail.amount ?? 0).toFixed(2)}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
{storeSettlementStatusLabel('WITHDRAW', String(detail.status))}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="申请时间">
|
||
{detail.appliedAt ? fmtTime(String(detail.appliedAt)) : '—'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="驳回理由">
|
||
{detail.rejectReason ? String(detail.rejectReason) : '—'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="打款凭证">
|
||
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
{storeAccount ? (
|
||
<>
|
||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||
收款账户
|
||
</Typography.Title>
|
||
<Descriptions column={1} size="small" bordered>
|
||
<Descriptions.Item label="户名">
|
||
{storeAccount.bankAccountName || '—'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="账号">
|
||
{storeAccount.bankAccountNo || '—'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="开户行">
|
||
{storeAccount.bankBranch || '—'}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
</>
|
||
) : null}
|
||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||
提现明细
|
||
</Typography.Title>
|
||
<Table
|
||
size="small"
|
||
rowKey="id"
|
||
pagination={false}
|
||
dataSource={withdrawDetailItems}
|
||
columns={[
|
||
{
|
||
title: '核销单号',
|
||
render: (_, r) => {
|
||
const payout = r.storePayout as
|
||
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
|
||
| undefined;
|
||
return String(payout?.redeemRecord?.redeemNo || '—');
|
||
},
|
||
},
|
||
{
|
||
title: '应付',
|
||
render: (_, r) => {
|
||
const payout = r.storePayout as { payoutAmount?: number } | undefined;
|
||
return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`;
|
||
},
|
||
},
|
||
]}
|
||
/>
|
||
{String(detail.status) === 'PENDING_REVIEW' ? (
|
||
<Space style={{ marginTop: 16 }}>
|
||
<Button type="primary" onClick={() => approveWithdraw(String(detail.id))}>
|
||
通过
|
||
</Button>
|
||
<Button danger onClick={() => rejectWithdraw(String(detail.id))}>
|
||
驳回
|
||
</Button>
|
||
</Space>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
}
|