Files
order_site/apps/frontend/src/pages/admin/AdminManualFulfillmentPage.tsx
T

344 lines
10 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.
/**
* 人工履约待办页:客服确认履约。
*
* 口径(2026-10-24 确认):
* * 渠道与消耗积分在成本中心提前配好,客服不选、不填;
* * 固定成本 → 点「确认履约」零输入;
* * 成本不固定(消耗积分 = 0)→ 只填本次实际成本;
* * 确认后自动扣对应平台的积分并写成本快照。
*/
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Space,
Statistic,
Tag,
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import PageHeader from '@/components/admin/PageHeader'
import { confirmAdminManualFulfillment, fetchAdminManualFulfillmentPending } from '@/services/admin'
import type { ManualFulfillmentPendingItem } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
type ConfirmFormValues = {
costYuan?: number
payeeName?: string
note?: string
}
const PLATFORM_TAG_COLORS: Record<string, string> = {
nanhang: 'blue',
lewan: 'purple',
rock: 'cyan',
fund_cash: 'orange',
}
export default function AdminManualFulfillmentPage() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<ConfirmFormValues>()
const [status, setStatus] = useState<'pending' | 'fulfilled' | ''>('pending')
const [keyword, setKeyword] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [confirming, setConfirming] = useState<ManualFulfillmentPendingItem | null>(null)
const [showCostInput, setShowCostInput] = useState(false)
const listQuery = useQuery({
queryKey: ['admin-manual-fulfillment-pending', status, keyword, page, pageSize],
queryFn: () =>
fetchAdminManualFulfillmentPending({
status: status || undefined,
keyword: keyword || undefined,
page,
pageSize,
}),
})
const confirmMutation = useMutation({
mutationFn: (values: ConfirmFormValues) =>
confirmAdminManualFulfillment({
orderItemId: confirming?.orderItemId || 0,
costFen: values.costYuan === undefined ? undefined : Math.round(values.costYuan * 100),
payeeName: values.payeeName || '',
note: values.note || '',
}),
onSuccess: (response) => {
const result = response.data
message.success(
`履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
)
setConfirming(null)
setShowCostInput(false)
void queryClient.invalidateQueries({
queryKey: ['admin-manual-fulfillment-pending'],
})
void queryClient.invalidateQueries({ queryKey: ['admin-fund-my'] })
void queryClient.invalidateQueries({ queryKey: ['admin-fund-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
},
onError: (error) => {
message.error(error instanceof Error ? error.message : '确认履约失败')
},
})
function openConfirm(item: ManualFulfillmentPendingItem) {
setConfirming(item)
setShowCostInput(item.costFen === null)
form.resetFields()
}
const columns: TableColumnsType<ManualFulfillmentPendingItem> = [
{
title: '商品',
key: 'sku',
width: 260,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong ellipsis={{ tooltip: row.skuName }}>
{row.skuName || row.skuCode}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.internalSku || row.skuCode}
</Typography.Text>
</div>
),
},
{
title: '平台单号',
dataIndex: 'platformOrderId',
width: 170,
render: (value: string) => (
<Typography.Text copyable={{ text: value }} style={{ fontSize: 12 }}>
{value}
</Typography.Text>
),
},
{
title: '履约渠道',
key: 'platform',
width: 110,
render: (_, row) => (
<Tag color={PLATFORM_TAG_COLORS[row.platformKey] || 'default'}>{row.platformLabel}</Tag>
),
},
{
title: '消耗',
key: 'credit',
width: 120,
render: (_, row) =>
row.isFundCash ? (
<Typography.Text type="secondary">—</Typography.Text>
) : (
<Typography.Text>{row.creditQuantity} 积分</Typography.Text>
),
},
{
title: '成本',
key: 'cost',
width: 120,
align: 'right',
render: (_, row) =>
row.costFen === null ? (
<Tag color="orange">待填写</Tag>
) : (
<Typography.Text strong>¥{(row.costFen / 100).toFixed(2)}</Typography.Text>
),
},
{
title: '订单金额',
key: 'revenue',
width: 100,
align: 'right',
render: (_, row) => `¥${(row.revenueFen / 100).toFixed(2)}`,
},
{
title: '支付时间',
dataIndex: 'paidAt',
width: 150,
render: (value: string) => formatAdminDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 110,
fixed: 'right',
render: (_, row) =>
row.fulfilled ? (
<Tag color="green">已履约</Tag>
) : (
<Button
type="primary"
size="small"
icon={<CheckOutlined />}
onClick={() => openConfirm(row)}
>
确认履约
</Button>
),
},
]
const total = listQuery.data?.data.pagination.total ?? 0
return (
<section>
<PageHeader
title="人工履约待办"
description="渠道与消耗积分已提前配好,确认即扣额度并计入成本。"
/>
<Alert
type="info"
showIcon
style={{ marginBottom: 14 }}
message="确认履约会从对应平台的积分钱包扣减,并写入成本快照"
description="固定成本的商品直接确认即可;成本不固定的商品需要填写本次实际成本。"
/>
<Space wrap style={{ marginBottom: 12 }}>
<Button icon={<ReloadOutlined />} onClick={() => void listQuery.refetch()}>
刷新
</Button>
<Button
type={status === 'pending' ? 'primary' : 'default'}
onClick={() => {
setStatus('pending')
setPage(1)
}}
>
待确认
</Button>
<Button
type={status === 'fulfilled' ? 'primary' : 'default'}
onClick={() => {
setStatus('fulfilled')
setPage(1)
}}
>
已履约
</Button>
<Button
type={status === '' ? 'primary' : 'default'}
onClick={() => {
setStatus('')
setPage(1)
}}
>
全部
</Button>
</Space>
<Card bordered={false}>
<PercentTable
rowKey="orderItemId"
size="small"
columns={columns}
dataSource={listQuery.data?.data.items || []}
loading={listQuery.isLoading}
scroll={{ x: 1080 }}
pagination={buildAdminTablePagination({
page,
pageSize,
total,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
if (nextPageSize !== pageSize) setPageSize(nextPageSize)
},
})}
/>
</Card>
<Modal
open={Boolean(confirming)}
title="确认履约"
okText="确认"
cancelText="取消"
confirmLoading={confirmMutation.isPending}
onOk={() => {
void form.validateFields().then((values) => confirmMutation.mutate(values))
}}
onCancel={() => {
setConfirming(null)
setShowCostInput(false)
}}
destroyOnHidden
width={520}
>
{confirming ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<div>
<Typography.Text type="secondary">商品:</Typography.Text>
<Typography.Text strong>{confirming.skuName || confirming.skuCode}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">内部 SKU:</Typography.Text>
<Typography.Text copyable={{ text: confirming.internalSku }}>
{confirming.internalSku}
</Typography.Text>
</div>
<div>
<Typography.Text type="secondary">履约渠道:</Typography.Text>
<Tag color={PLATFORM_TAG_COLORS[confirming.platformKey] || 'default'}>
{confirming.platformLabel}
</Tag>
{!confirming.isFundCash ? (
<Typography.Text>消耗 {confirming.creditQuantity} 积分</Typography.Text>
) : null}
</div>
{showCostInput ? (
<Alert type="warning" showIcon message="该商品成本不固定,请填写本次实际成本" />
) : (
<Statistic
title="本次成本"
value={(confirming.costFen || 0) / 100}
precision={2}
prefix="¥"
/>
)}
</Space>
) : null}
<Form<ConfirmFormValues> form={form} layout="vertical">
{showCostInput ? (
<Form.Item
name="costYuan"
label="实际成本(元)"
rules={[{ required: true, message: '请填写实际成本' }]}
>
<InputNumber
min={0.01}
precision={2}
style={{ width: '100%' }}
placeholder="例如 12.50"
prefix="¥"
/>
</Form.Item>
) : null}
<Form.Item name="payeeName" label="收款方 / 处理人">
<Input placeholder="例如:客服小王、上游商家" maxLength={120} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} maxLength={300} showCount />
</Form.Item>
</Form>
</Modal>
</section>
)
}