b392c28787
CI / verify (pull_request) Has been cancelled
Add WineryBill/StoreBill tables, partner review-send-confirm flow, daily/monthly cron, and admin multi-select confirm with status filters. Co-authored-by: Cursor <cursoragent@cursor.com>
471 lines
15 KiB
TypeScript
471 lines
15 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import {
|
|
Button,
|
|
Card,
|
|
DatePicker,
|
|
Form,
|
|
Input,
|
|
Modal,
|
|
Select,
|
|
Space,
|
|
Statistic,
|
|
Table,
|
|
Tag,
|
|
Typography,
|
|
message,
|
|
} from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import dayjs, { type Dayjs } from 'dayjs';
|
|
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 = {
|
|
id: string;
|
|
billNo: string;
|
|
orderCommission: number;
|
|
redeemCommission: number;
|
|
totalAmount: number;
|
|
status: string;
|
|
periodStart: string;
|
|
periodEnd: string;
|
|
rejectReason?: string | null;
|
|
partner?: { companyName?: string; phone?: string };
|
|
partnerAccount?: { companyName?: string; phone?: string };
|
|
};
|
|
|
|
type PartnerOption = { id: string; companyName: string; phone?: string };
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
PENDING_REVIEW: '待审核',
|
|
AWAITING_CONFIRM: '待合伙人确认',
|
|
UNPAID: '未打款',
|
|
PAID: '已打款',
|
|
REJECTED: '已驳回',
|
|
};
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
PENDING_REVIEW: 'gold',
|
|
AWAITING_CONFIRM: 'blue',
|
|
UNPAID: 'red',
|
|
PAID: 'green',
|
|
REJECTED: 'red',
|
|
};
|
|
|
|
export default function PartnerBillsPage() {
|
|
const [form] = Form.useForm();
|
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
|
'/admin/partner-bills',
|
|
() => {
|
|
const qs = new URLSearchParams();
|
|
if (filters.status) qs.set('status', filters.status);
|
|
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
|
if (filters.year) qs.set('year', filters.year);
|
|
if (filters.month) qs.set('month', filters.month);
|
|
return qs;
|
|
},
|
|
[filters],
|
|
);
|
|
const [genOpen, setGenOpen] = useState(false);
|
|
const [genForm] = Form.useForm();
|
|
const [exporting, setExporting] = useState(false);
|
|
const [rejectOpen, setRejectOpen] = useState(false);
|
|
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
|
|
const [rejectForm] = Form.useForm();
|
|
const [rejecting, setRejecting] = useState(false);
|
|
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
|
|
|
useEffect(() => {
|
|
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
|
.then((res) => setPartners(res.items))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
async function generateBill(values: { partnerId?: string; month: Dayjs }) {
|
|
const year = values.month.year();
|
|
const month = values.month.month() + 1;
|
|
if (!values.partnerId) {
|
|
const result = await request<{ success: number; failed: number; total: number }>(
|
|
'/admin/partner-bills/generate-all',
|
|
{ method: 'POST', body: JSON.stringify({ year, month }) },
|
|
);
|
|
message.success(`已生成 ${result.success}/${result.total} 份账单${result.failed ? `,失败 ${result.failed}` : ''}`);
|
|
} else {
|
|
await request('/admin/partner-bills/generate', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
|
|
});
|
|
message.success('账单已生成(待审核)');
|
|
}
|
|
setGenOpen(false);
|
|
reload();
|
|
}
|
|
|
|
function sendBills(ids: string[]) {
|
|
Modal.confirm({
|
|
title: '发送给合伙人?',
|
|
content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`,
|
|
okText: '确认发送',
|
|
cancelText: '取消',
|
|
onOk: async () => {
|
|
if (ids.length === 1) {
|
|
await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' });
|
|
} else {
|
|
await request('/admin/partner-bills/batch-send', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
});
|
|
}
|
|
message.success('已发送');
|
|
setSelectedKeys([]);
|
|
reload();
|
|
},
|
|
});
|
|
}
|
|
|
|
function markPaid(ids: string[], amountHint?: number) {
|
|
Modal.confirm({
|
|
title: '确认打款?',
|
|
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。`,
|
|
okText: '确认打款',
|
|
cancelText: '取消',
|
|
onOk: async () => {
|
|
if (ids.length === 1) {
|
|
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
|
});
|
|
} else {
|
|
await request('/admin/partner-bills/batch-mark-paid', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
});
|
|
}
|
|
message.success('已标记打款');
|
|
setSelectedKeys([]);
|
|
reload();
|
|
},
|
|
});
|
|
}
|
|
|
|
function openReject(row: Row) {
|
|
Modal.confirm({
|
|
title: '驳回该账单?',
|
|
content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。',
|
|
okText: '继续填写理由',
|
|
cancelText: '取消',
|
|
onOk: () => {
|
|
setRejectTarget(row);
|
|
rejectForm.resetFields();
|
|
setRejectOpen(true);
|
|
},
|
|
});
|
|
}
|
|
|
|
async function submitReject(values: { reason: string }) {
|
|
if (!rejectTarget) return;
|
|
setRejecting(true);
|
|
try {
|
|
await request(`/admin/partner-bills/${rejectTarget.id}/reject`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason: values.reason.trim() }),
|
|
});
|
|
message.success('已驳回');
|
|
setRejectOpen(false);
|
|
setRejectTarget(null);
|
|
reload();
|
|
} finally {
|
|
setRejecting(false);
|
|
}
|
|
}
|
|
|
|
async function exportExcel() {
|
|
setExporting(true);
|
|
try {
|
|
const qs = new URLSearchParams();
|
|
if (filters.status) qs.set('status', filters.status);
|
|
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
|
if (filters.year) qs.set('year', filters.year);
|
|
if (filters.month) qs.set('month', filters.month);
|
|
const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`);
|
|
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
|
downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`);
|
|
message.success(`已导出 ${result.count} 条`);
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
}
|
|
|
|
const summary = data?.summary;
|
|
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
|
|
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
|
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
|
|
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
|
|
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
|
|
|
|
const columns: ColumnsType<Row> = [
|
|
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
|
|
{
|
|
title: '合伙人',
|
|
width: 160,
|
|
ellipsis: true,
|
|
render: (_, r) => partnerName(r),
|
|
},
|
|
{
|
|
title: '账期',
|
|
width: 200,
|
|
render: (_, r) =>
|
|
`${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}`,
|
|
},
|
|
{
|
|
title: '酒单佣金',
|
|
dataIndex: 'orderCommission',
|
|
width: 110,
|
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
|
},
|
|
{
|
|
title: '核销佣金',
|
|
dataIndex: 'redeemCommission',
|
|
width: 110,
|
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
|
},
|
|
{
|
|
title: '合计应付',
|
|
dataIndex: 'totalAmount',
|
|
width: 110,
|
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
|
},
|
|
{
|
|
title: '状态',
|
|
dataIndex: 'status',
|
|
width: 130,
|
|
render: (s, row) => (
|
|
<Space direction="vertical" size={0}>
|
|
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
|
|
{s === 'REJECTED' && row.rejectReason ? (
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
{row.rejectReason}
|
|
</Typography.Text>
|
|
) : null}
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: '操作',
|
|
width: 220,
|
|
fixed: 'right',
|
|
render: (_, row) => (
|
|
<Space size={0} wrap>
|
|
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
|
|
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
|
|
发送
|
|
</Button>
|
|
)}
|
|
{row.status === 'UNPAID' && (
|
|
<>
|
|
<Button type="link" size="small" onClick={() => markPaid([row.id], Number(row.totalAmount))}>
|
|
确认打款
|
|
</Button>
|
|
<Button type="link" size="small" danger onClick={() => openReject(row)}>
|
|
驳回
|
|
</Button>
|
|
</>
|
|
)}
|
|
{row.status === 'AWAITING_CONFIRM' && (
|
|
<Button type="link" size="small" danger onClick={() => openReject(row)}>
|
|
驳回
|
|
</Button>
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
合伙人账单
|
|
</Typography.Title>
|
|
<Typography.Text type="secondary">
|
|
每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款
|
|
</Typography.Text>
|
|
</Space>
|
|
|
|
{summary && (
|
|
<Card size="small" style={{ marginBottom: 16 }}>
|
|
<Space size="large" wrap>
|
|
<Statistic title="账单数" value={summary.count} />
|
|
<Statistic title="酒单佣金合计" value={summary.orderCommission ?? 0} prefix="¥" precision={2} />
|
|
<Statistic title="核销佣金合计" value={summary.redeemCommission ?? 0} prefix="¥" precision={2} />
|
|
<Statistic title="应付合计" value={summary.totalAmount} prefix="¥" precision={2} />
|
|
</Space>
|
|
</Card>
|
|
)}
|
|
|
|
<Form
|
|
form={form}
|
|
layout="inline"
|
|
style={{ marginBottom: 16 }}
|
|
onFinish={(v: { partnerId?: string; status?: string; month?: Dayjs }) => {
|
|
setFilters({
|
|
partnerId: v.partnerId || '',
|
|
status: v.status || '',
|
|
year: v.month ? String(v.month.year()) : '',
|
|
month: v.month ? String(v.month.month() + 1) : '',
|
|
});
|
|
setPage(1);
|
|
}}
|
|
>
|
|
<Form.Item name="partnerId" label="合伙人">
|
|
<Select
|
|
allowClear
|
|
showSearch
|
|
placeholder="全部合伙人"
|
|
style={{ width: 220 }}
|
|
optionFilterProp="label"
|
|
options={partners.map((p) => ({
|
|
value: p.id,
|
|
label: p.companyName || p.phone || p.id,
|
|
}))}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="month" label="账期月">
|
|
<DatePicker picker="month" />
|
|
</Form.Item>
|
|
<Form.Item name="status" label="状态">
|
|
<Select
|
|
allowClear
|
|
style={{ width: 150 }}
|
|
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item>
|
|
<Button type="primary" htmlType="submit">
|
|
查询
|
|
</Button>
|
|
</Form.Item>
|
|
<Form.Item>
|
|
<Button
|
|
onClick={() => {
|
|
form.resetFields();
|
|
setFilters({});
|
|
setPage(1);
|
|
}}
|
|
>
|
|
重置
|
|
</Button>
|
|
</Form.Item>
|
|
<Form.Item>
|
|
<Button type="primary" onClick={() => setGenOpen(true)}>
|
|
生成账单
|
|
</Button>
|
|
</Form.Item>
|
|
<Form.Item>
|
|
<Button loading={exporting} onClick={() => void exportExcel()}>
|
|
导出 Excel
|
|
</Button>
|
|
</Form.Item>
|
|
<Form.Item>
|
|
<Button disabled={!canSend.length} onClick={() => sendBills(canSend.map((r) => r.id))}>
|
|
批量发送 ({canSend.length})
|
|
</Button>
|
|
</Form.Item>
|
|
<Form.Item>
|
|
<Button
|
|
type="primary"
|
|
disabled={!canPay.length}
|
|
onClick={() => markPaid(canPay.map((r) => r.id), payAmount)}
|
|
>
|
|
批量打款 ({canPay.length})
|
|
</Button>
|
|
</Form.Item>
|
|
</Form>
|
|
|
|
<Table
|
|
rowKey="id"
|
|
className="admin-table-nowrap"
|
|
loading={loading}
|
|
columns={columns}
|
|
dataSource={data?.items ?? []}
|
|
rowSelection={{
|
|
selectedRowKeys: selectedKeys,
|
|
onChange: setSelectedKeys,
|
|
}}
|
|
scroll={{ x: 1280 }}
|
|
pagination={{
|
|
current: page,
|
|
pageSize,
|
|
total: data?.total ?? 0,
|
|
showSizeChanger: true,
|
|
onChange: (p, ps) => {
|
|
setPage(p);
|
|
setPageSize(ps);
|
|
},
|
|
}}
|
|
/>
|
|
|
|
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
|
<Form
|
|
form={genForm}
|
|
layout="vertical"
|
|
initialValues={{ month: dayjs().subtract(1, 'month') }}
|
|
onFinish={generateBill}
|
|
>
|
|
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}>
|
|
<DatePicker picker="month" style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="partnerId" label="指定合伙人(留空则全部主合伙人)">
|
|
<Select
|
|
allowClear
|
|
showSearch
|
|
optionFilterProp="label"
|
|
placeholder="全部主合伙人"
|
|
options={partners.map((p) => ({
|
|
value: p.id,
|
|
label: p.companyName || p.phone || p.id,
|
|
}))}
|
|
/>
|
|
</Form.Item>
|
|
<Button type="primary" htmlType="submit" block>
|
|
生成(待审核)
|
|
</Button>
|
|
</Form>
|
|
</Modal>
|
|
|
|
<Modal
|
|
title="驳回打款申请"
|
|
open={rejectOpen}
|
|
onCancel={() => {
|
|
setRejectOpen(false);
|
|
setRejectTarget(null);
|
|
}}
|
|
footer={null}
|
|
destroyOnClose
|
|
>
|
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
|
账单 {rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
|
|
</Typography.Paragraph>
|
|
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
|
|
<Form.Item
|
|
name="reason"
|
|
label="驳回理由"
|
|
rules={[
|
|
{ required: true, message: '请填写驳回理由' },
|
|
{ max: 500, message: '不超过 500 字' },
|
|
]}
|
|
>
|
|
<Input.TextArea rows={4} placeholder="请说明驳回原因" maxLength={500} showCount />
|
|
</Form.Item>
|
|
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
|
|
确认驳回
|
|
</Button>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|