390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
||
import {
|
||
Button,
|
||
Descriptions,
|
||
Drawer,
|
||
Form,
|
||
Image,
|
||
Input,
|
||
message,
|
||
Modal,
|
||
Radio,
|
||
Space,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
} from 'antd';
|
||
import type { TableProps } from 'antd';
|
||
import { UnorderedListOutlined } from '@ant-design/icons';
|
||
import XinTable from '@/components/XinTable';
|
||
import type {
|
||
XinTableColumn,
|
||
XinTableInstance,
|
||
XinTableProps,
|
||
} from '@/components/XinTable/typings.ts';
|
||
import type IPayment from '@/domain/iPayment.ts';
|
||
import type { IPaymentBill, IPaymentDetail } from '@/domain/iPayment.ts';
|
||
import { PAY_METHOD_MAP, PAYMENT_STATUS_MAP } from '@/domain/iPayment.ts';
|
||
import { BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
||
import { getPaymentDetail, auditPayment } from '@/api/recon/payment.ts';
|
||
import { getStoreOptions } from '@/api/customer/store.ts';
|
||
import type IStore from '@/domain/iStore.ts';
|
||
import AuthButton from '@/components/AuthButton';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
/** 审核表单 */
|
||
interface AuditFormValues {
|
||
result: 'pass' | 'reject';
|
||
audit_remark?: string;
|
||
}
|
||
|
||
/**
|
||
* 支付记录(小程序合并付款提交汇款凭证;审核通过后关联账单全部置已支付,拒绝则释放账单)
|
||
*/
|
||
const PaymentPage: React.FC = () => {
|
||
const tableRef = useRef<XinTableInstance<IPayment>>(null);
|
||
const [stores, setStores] = useState<IStore[]>([]);
|
||
|
||
const [detailOpen, setDetailOpen] = useState(false);
|
||
const [detail, setDetail] = useState<IPaymentDetail | null>(null);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
|
||
// 审核弹窗
|
||
const [auditTarget, setAuditTarget] = useState<IPayment | null>(null);
|
||
const [auditSaving, setAuditSaving] = useState(false);
|
||
const [auditForm] = Form.useForm<AuditFormValues>();
|
||
const watchAuditResult = Form.useWatch('result', auditForm);
|
||
|
||
useEffect(() => {
|
||
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
||
}, []);
|
||
|
||
const openDetail = async (id: number) => {
|
||
setDetailOpen(true);
|
||
setDetailLoading(true);
|
||
try {
|
||
const res = await getPaymentDetail(id);
|
||
setDetail(res.data.data ?? null);
|
||
} finally {
|
||
setDetailLoading(false);
|
||
}
|
||
};
|
||
|
||
/** 打开审核弹窗 */
|
||
const openAudit = (record: IPayment) => {
|
||
setAuditTarget(record);
|
||
auditForm.setFieldsValue({ result: 'pass', audit_remark: '' });
|
||
};
|
||
|
||
/** 提交审核:通过 → 账单批量置已支付;拒绝 → 释放账单 */
|
||
const handleAuditSave = async (values: AuditFormValues) => {
|
||
if (!auditTarget?.id) {
|
||
return;
|
||
}
|
||
setAuditSaving(true);
|
||
try {
|
||
const res = await auditPayment(auditTarget.id, values);
|
||
message.success(res.data.msg ?? '审核完成');
|
||
setAuditTarget(null);
|
||
await tableRef.current?.reload();
|
||
if (detail && detail.payment.id === auditTarget.id) {
|
||
await openDetail(auditTarget.id);
|
||
}
|
||
} finally {
|
||
setAuditSaving(false);
|
||
}
|
||
};
|
||
|
||
/** 合并账单列 */
|
||
const billColumns: TableProps<IPaymentBill>['columns'] = [
|
||
{
|
||
title: '账单号',
|
||
dataIndex: 'bill_no',
|
||
align: 'center',
|
||
render: (v) => <Text copyable={{ text: v }}>{v}</Text>,
|
||
},
|
||
{ title: '账单日期', dataIndex: 'bill_date', align: 'center' },
|
||
{
|
||
title: '商品金额',
|
||
dataIndex: 'product_amount',
|
||
align: 'center',
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '配送费',
|
||
dataIndex: 'delivery_fee',
|
||
align: 'center',
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '附加金额',
|
||
dataIndex: 'added_amount',
|
||
align: 'center',
|
||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '账单总金额',
|
||
dataIndex: 'total_amount',
|
||
align: 'center',
|
||
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
|
||
},
|
||
{
|
||
title: '支付状态',
|
||
dataIndex: 'status',
|
||
align: 'center',
|
||
render: (v) => {
|
||
const item = BILL_STATUS_MAP[Number(v ?? 0)];
|
||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||
},
|
||
},
|
||
];
|
||
|
||
const columns: XinTableColumn<IPayment>[] = [
|
||
{
|
||
title: '支付单号',
|
||
dataIndex: 'payment_no',
|
||
valueType: 'text',
|
||
hideInForm: true,
|
||
width: 210,
|
||
render: (_, record) => <Text copyable={{ text: record.payment_no }}>{record.payment_no}</Text>,
|
||
},
|
||
{
|
||
title: '门店',
|
||
dataIndex: 'store_id',
|
||
valueType: 'select',
|
||
hideInForm: true,
|
||
fieldProps: {
|
||
options: stores.map((s) => ({ label: s.name, value: s.id })),
|
||
showSearch: true,
|
||
optionFilterProp: 'label',
|
||
},
|
||
render: (_, record) => record.store?.name ?? `门店#${record.store_id}`,
|
||
},
|
||
{
|
||
title: '支付金额',
|
||
dataIndex: 'amount',
|
||
hideInForm: true,
|
||
hideInSearch: true,
|
||
align: 'center',
|
||
render: (_, record) => <Text strong type="danger">¥{record.amount}</Text>,
|
||
},
|
||
{
|
||
title: '支付方式',
|
||
dataIndex: 'pay_method',
|
||
valueType: 'select',
|
||
hideInForm: true,
|
||
align: 'center',
|
||
fieldProps: {
|
||
options: Object.entries(PAY_METHOD_MAP).map(([value, item]) => ({
|
||
value: Number(value),
|
||
label: item.text,
|
||
})),
|
||
},
|
||
render: (_, record) => {
|
||
const item = PAY_METHOD_MAP[record.pay_method ?? 0];
|
||
return <Tag color={item?.color}>{item?.text ?? '-'}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: '合并账单',
|
||
dataIndex: 'bills_count',
|
||
hideInForm: true,
|
||
hideInSearch: true,
|
||
align: 'center',
|
||
render: (_, record) => `${record.bills_count ?? 0} 张`,
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
valueType: 'select',
|
||
hideInForm: true,
|
||
align: 'center',
|
||
fieldProps: {
|
||
options: Object.entries(PAYMENT_STATUS_MAP).map(([value, item]) => ({
|
||
value: Number(value),
|
||
label: item.text,
|
||
})),
|
||
},
|
||
render: (_, record) => {
|
||
const item = PAYMENT_STATUS_MAP[record.status ?? 0];
|
||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||
},
|
||
},
|
||
{
|
||
title: '提交时间',
|
||
dataIndex: 'created_at',
|
||
hideInForm: true,
|
||
hideInSearch: true,
|
||
align: 'center',
|
||
},
|
||
{
|
||
title: '审核人',
|
||
dataIndex: 'auditor',
|
||
hideInForm: true,
|
||
hideInSearch: true,
|
||
align: 'center',
|
||
render: (_, record) => record.auditor?.nickname ?? '-',
|
||
},
|
||
];
|
||
|
||
const operateRender: XinTableProps<IPayment>['operateRender'] = (record) => [
|
||
<Button
|
||
key="detail"
|
||
size="small"
|
||
type="primary"
|
||
icon={<UnorderedListOutlined />}
|
||
onClick={() => openDetail(record.id!)}
|
||
/>,
|
||
record.status === 0 ? (
|
||
<AuthButton key="audit" auth="recon.payment.audit">
|
||
<Button size="small" variant="solid" color="orange" onClick={() => openAudit(record)}>
|
||
审核
|
||
</Button>
|
||
</AuthButton>
|
||
) : null,
|
||
];
|
||
|
||
const tableProps: XinTableProps<IPayment> = {
|
||
api: '/recon/payment',
|
||
columns,
|
||
rowKey: 'id',
|
||
accessName: 'recon.payment',
|
||
tableRef,
|
||
operateRender,
|
||
formProps: false,
|
||
actionBarRender: (dom) => [dom.search, dom.keywordSearch],
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div className="mb-5">
|
||
<Title level={3}>支付记录</Title>
|
||
<Text type="secondary">
|
||
门店在小程序端选择账单合并付款并上传汇款凭证;审核通过后关联账单全部置为已支付,拒绝则释放账单可重新付款。
|
||
</Text>
|
||
</div>
|
||
<XinTable<IPayment> {...tableProps} />
|
||
|
||
{/* 支付详情:支付信息 + 凭证 + 合并账单 */}
|
||
<Drawer
|
||
title={detail ? `支付单 ${detail.payment.payment_no}` : '支付记录详情'}
|
||
open={detailOpen}
|
||
onClose={() => setDetailOpen(false)}
|
||
size={1000}
|
||
loading={detailLoading}
|
||
footer={
|
||
detail && detail.payment.status === 0 ? (
|
||
<Space className="flex justify-end">
|
||
<AuthButton auth="recon.payment.audit">
|
||
<Button type="primary" onClick={() => openAudit(detail.payment)}>
|
||
审核
|
||
</Button>
|
||
</AuthButton>
|
||
</Space>
|
||
) : null
|
||
}
|
||
>
|
||
{detail ? (
|
||
<>
|
||
<Descriptions column={3} size="small" bordered>
|
||
<Descriptions.Item label="门店">{detail.payment.store?.name ?? `门店#${detail.payment.store_id}`}</Descriptions.Item>
|
||
<Descriptions.Item label="支付金额">
|
||
<Text strong type="danger">¥{detail.payment.amount}</Text>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="支付方式">
|
||
<Tag color={PAY_METHOD_MAP[detail.payment.pay_method ?? 0]?.color}>
|
||
{PAY_METHOD_MAP[detail.payment.pay_method ?? 0]?.text ?? '-'}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="状态">
|
||
<Tag color={PAYMENT_STATUS_MAP[detail.payment.status ?? 0]?.color}>
|
||
{PAYMENT_STATUS_MAP[detail.payment.status ?? 0]?.text}
|
||
</Tag>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="提交时间">{detail.payment.created_at}</Descriptions.Item>
|
||
<Descriptions.Item label="审核人">{detail.payment.auditor?.nickname ?? '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="审核时间">{detail.payment.audited_at ?? '-'}</Descriptions.Item>
|
||
<Descriptions.Item label="审核备注">{detail.payment.audit_remark || '-'}</Descriptions.Item>
|
||
{detail.payment.remark ? (
|
||
<Descriptions.Item label="门店备注" span={3}>{detail.payment.remark}</Descriptions.Item>
|
||
) : null}
|
||
</Descriptions>
|
||
|
||
<Title level={5} className="mt-6! mb-3!">
|
||
汇款凭证
|
||
</Title>
|
||
{(detail.payment.voucher_urls ?? []).length > 0 ? (
|
||
<Image.PreviewGroup>
|
||
<Space wrap size={12}>
|
||
{(detail.payment.voucher_urls ?? []).map((url, index) => (
|
||
<Image
|
||
key={index}
|
||
src={url}
|
||
width={120}
|
||
height={120}
|
||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||
/>
|
||
))}
|
||
</Space>
|
||
</Image.PreviewGroup>
|
||
) : (
|
||
<Text type="secondary">无凭证</Text>
|
||
)}
|
||
|
||
<Title level={5} className="mt-6! mb-3!">
|
||
合并付款账单({detail.bills.length} 张)
|
||
</Title>
|
||
<Table<IPaymentBill>
|
||
rowKey="id"
|
||
size="small"
|
||
bordered
|
||
columns={billColumns}
|
||
dataSource={detail.bills}
|
||
pagination={false}
|
||
/>
|
||
</>
|
||
) : null}
|
||
</Drawer>
|
||
|
||
{/* 审核弹窗 */}
|
||
<Modal
|
||
title={auditTarget ? `审核支付单 ${auditTarget.payment_no}` : '审核'}
|
||
open={auditTarget !== null}
|
||
onCancel={() => setAuditTarget(null)}
|
||
onOk={() => auditForm.submit()}
|
||
confirmLoading={auditSaving}
|
||
okText="提交审核"
|
||
destroyOnHidden
|
||
>
|
||
<div className="py-2 text-gray-500">
|
||
支付金额 <Text strong type="danger">¥{auditTarget?.amount ?? '0.00'}</Text>({auditTarget?.bills_count ?? 0} 张账单);
|
||
通过后关联账单全部置为「已支付」,拒绝则释放账单,门店可重新发起付款。
|
||
</div>
|
||
<Form form={auditForm} layout="vertical" onFinish={handleAuditSave}>
|
||
<Form.Item label="审核结果" name="result" rules={[{ required: true, message: '请选择审核结果' }]}>
|
||
<Radio.Group
|
||
options={[
|
||
{ value: 'pass', label: '通过(账单置为已支付)' },
|
||
{ value: 'reject', label: '拒绝(释放账单)' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
label={watchAuditResult === 'reject' ? '拒绝原因' : '审核备注'}
|
||
name="audit_remark"
|
||
rules={[
|
||
{ required: watchAuditResult === 'reject', message: '拒绝时请填写原因' },
|
||
{ max: 255 },
|
||
]}
|
||
>
|
||
<Input.TextArea rows={2} maxLength={255} placeholder="审核备注(拒绝时必填)" />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default PaymentPage;
|