Files
hfb_jysh/frontend/src/features/admin/AdminRefundReviewPage.tsx
T

295 lines
9.5 KiB
TypeScript

import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, App, Button, Modal, Radio, Space, Tag, Typography, type TableColumnsType } from 'antd'
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons'
import { approveAdminRefund, fetchPendingRefundOrders, rejectAdminRefund } from '@/features/admin/api'
import { AdminOrderDetailDrawer } from '@/features/admin/AdminOrderDetailDrawer'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { StatusTag } from '@/features/admin/components/StatusTag'
import {
handoffStatusMeta,
orderStatusMap,
readStatusMeta,
} from '@/features/admin/labels'
import type { AdminOrderDetail } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
type RejectMode = 'restore' | 'close'
function buildColumns(
onDetail: (row: AdminOrderDetail) => void,
onApprove: (row: AdminOrderDetail) => void,
onReject: (row: AdminOrderDetail) => void,
canClose: boolean,
canView: boolean
): TableColumnsType<AdminOrderDetail> {
return [
{
title: '订单',
dataIndex: 'order_no',
width: 220,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
{canView ? (
<button type="button" className="admin-order-link" onClick={() => onDetail(row)}>
{row.order_no}
</button>
) : (
<span className="admin-mono">{row.order_no}</span>
)}
<strong>{row.title}</strong>
<span>商品 {formatText(row.listing_no)}</span>
</div>
),
},
{
title: '交易双方',
key: 'parties',
width: 180,
render: (_, row) => (
<div className="admin-stack">
<span>租客 {formatText(row.renter_phone)}</span>
<span>号主 {formatText(row.owner_phone)}</span>
</div>
),
},
{
title: '退款金额',
dataIndex: 'refund_amount_cent',
width: 140,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.refund_amount_cent)}</strong>
<span>订单 {formatMoney(row.display_amount_cent)}</span>
<span>押金 {formatMoney(row.deposit_amount_cent)}</span>
</div>
),
},
{
title: '订单状态',
key: 'status',
width: 170,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<StatusTag meta={readStatusMeta(orderStatusMap, row.status)} />
<StatusTag meta={handoffStatusMeta(row.handoff_status, row)} />
</div>
),
},
{
title: '申请时间',
dataIndex: 'updated_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 190,
fixed: 'right',
render: (_, row) => (
<Space size={4} wrap>
{canView ? (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => onDetail(row)}>
详情
</Button>
) : null}
{canClose ? (
<>
<Button type="link" size="small" danger onClick={() => onApprove(row)}>
通过退款
</Button>
<Button type="link" size="small" onClick={() => onReject(row)}>
驳回
</Button>
</>
) : null}
</Space>
),
},
]
}
export function AdminRefundReviewPage() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const can = (code: string) => Boolean(permissions?.includes('*') || permissions?.includes(code))
const canClose = can('order:close')
const canView = can('order:view')
const [detailId, setDetailId] = useState<number | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [rejectOrder, setRejectOrder] = useState<AdminOrderDetail | null>(null)
const [rejectMode, setRejectMode] = useState<RejectMode>('restore')
const [submitting, setSubmitting] = useState(false)
const query = useQuery({
queryKey: ['admin-refund-pending'],
queryFn: fetchPendingRefundOrders,
})
const items = query.data ?? []
const refreshAll = async () => {
await query.refetch()
await queryClient.invalidateQueries({ queryKey: ['admin-orders'] })
await queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] })
}
const openDetail = (row: AdminOrderDetail) => {
setDetailId(row.id)
setDetailOpen(true)
}
const approve = (row: AdminOrderDetail) => {
modal.confirm({
title: '通过退款申请?',
content: `订单 ${row.order_no} 将原路退款 ${formatMoney(row.refund_amount_cent)},订单随后取消。`,
okText: '通过退款',
cancelText: '再核对一下',
okButtonProps: { danger: true },
onOk: async () => {
try {
await approveAdminRefund(row.id)
message.success('退款已通过并发起')
await refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '退款审核失败')
}
},
})
}
const openReject = (row: AdminOrderDetail) => {
setRejectOrder(row)
setRejectMode('restore')
}
const submitReject = async () => {
if (!rejectOrder) return
setSubmitting(true)
try {
await rejectAdminRefund(rejectOrder.id, rejectMode)
message.success(rejectMode === 'restore' ? '退款已驳回,订单恢复待交接' : '退款已驳回,订单已关闭')
setRejectOrder(null)
await refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '驳回退款失败')
} finally {
setSubmitting(false)
}
}
const columns = buildColumns(openDetail, approve, openReject, canClose, canView)
const errorText = query.error instanceof Error ? query.error.message : '请稍后重试或重新登录后台。'
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 交易"
title="退款审核"
description="核对退款金额与订单状态后,通过原路退款或驳回并恢复/关闭订单。"
meta={
<Space size={8} wrap>
<Tag color="red">待审核 {items.length} 单</Tag>
{query.isFetching ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()} loading={query.isFetching}>
刷新队列
</Button>
}
/>
{!canClose ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有退款审核权限"
description="需要 order:close 权限才能通过或驳回退款。"
/>
) : null}
{query.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="退款队列没有加载出来"
description={errorText}
action={
<Button size="small" onClick={() => void query.refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminOrderDetail>
title={<span>待审核退款</span>}
extra={
<Typography.Text type="secondary">
{query.isPending ? '加载中' : `共 ${items.length} 单`}
</Typography.Text>
}
rowKey="id"
columns={columns}
dataSource={items}
loading={query.isFetching}
emptyDescription="当前没有待审核退款"
onRowClick={canView ? row => openDetail(row) : undefined}
/>
{canView ? (
<AdminOrderDetailDrawer
orderId={detailId}
open={detailOpen}
onClose={() => setDetailOpen(false)}
/>
) : null}
<Modal
open={rejectOrder !== null}
title="驳回退款"
okText="确认驳回"
cancelText="取消"
confirmLoading={submitting}
okButtonProps={{ danger: true }}
onOk={() => void submitReject()}
onCancel={() => {
if (!submitting) setRejectOrder(null)
}}
>
<Typography.Paragraph type="secondary">
订单 {rejectOrder?.order_no},退款金额 {formatMoney(rejectOrder?.refund_amount_cent)}。
请选择驳回后的订单处理方式。
</Typography.Paragraph>
<Radio.Group
value={rejectMode}
onChange={event => setRejectMode(event.target.value as RejectMode)}
style={{ display: 'flex', flexDirection: 'column', gap: 12 }}
>
<Radio value="restore">
恢复订单至待交接
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
仅驳回退款,订单继续流转,商品保持占用。
</Typography.Text>
</Radio>
<Radio value="close">
驳回退款并关闭订单
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
关闭订单并归档商品,后续不可再操作。
</Typography.Text>
</Radio>
</Radio.Group>
</Modal>
</div>
)
}