Files
xin-procurement/web/pages/recon/payment.tsx
T
2026-08-14 01:58:57 +08:00

399 lines
13 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 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: 'user',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => record.user?.nickname ?? '-',
},
{
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.user?.nickname ?? '-'}</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;