增加售后相关的
This commit is contained in:
@@ -51,10 +51,12 @@ import {
|
||||
cancelAdminWorkOrder,
|
||||
cancelAdminWorkOrderShare,
|
||||
createAdminMockWorkOrder,
|
||||
createAdminAfterSalesCase,
|
||||
deductAdminWorkOrderPendingDeposit,
|
||||
deleteAdminWorkOrder,
|
||||
fetchAdminWorkCategories,
|
||||
fetchAdminWorkOrderSharing,
|
||||
fetchAdminAfterSalesCases,
|
||||
fetchAdminWorkerLevels,
|
||||
fetchAdminWorkerUsers,
|
||||
fetchAdminWorkOrders,
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
publishAdminWorkOrder,
|
||||
reopenAdminCancelledWorkOrder,
|
||||
resolveAdminProblemWorkOrder,
|
||||
resolveAdminAfterSalesCase,
|
||||
submitAdminWorkOrderMaterial,
|
||||
unpublishAdminWorkOrder,
|
||||
updateAdminWorkOrder,
|
||||
@@ -75,6 +78,7 @@ import type {
|
||||
WorkOrder,
|
||||
WorkOrderShare,
|
||||
WorkOrderStatistics,
|
||||
WorkerAfterSalesCase,
|
||||
} from '@/types/worker-platform'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
@@ -129,6 +133,18 @@ type CancelShareFormValues = {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type AfterSalesCreateFormValues = {
|
||||
shareId?: number
|
||||
problemType?: string
|
||||
complaintNote?: string
|
||||
}
|
||||
|
||||
type AfterSalesResolutionFormValues = {
|
||||
action?: 'not_upheld' | 'remedied' | 'upheld'
|
||||
resolutionNote?: string
|
||||
recoveryAmount?: number
|
||||
}
|
||||
|
||||
export default function WorkOrdersPanel() {
|
||||
const { message, modal } = App.useApp()
|
||||
const navigate = useNavigate()
|
||||
@@ -160,12 +176,17 @@ export default function WorkOrdersPanel() {
|
||||
const [editOrder, setEditOrder] = useState<WorkOrder | null>(null)
|
||||
const [deductOrder, setDeductOrder] = useState<WorkOrder | null>(null)
|
||||
const [cancelOrder, setCancelOrder] = useState<WorkOrder | null>(null)
|
||||
const [afterSalesOrder, setAfterSalesOrder] = useState<WorkOrder | null>(null)
|
||||
const [afterSalesCase, setAfterSalesCase] = useState<WorkerAfterSalesCase | null>(null)
|
||||
const [afterSalesFiles, setAfterSalesFiles] = useState<UploadedFile[]>([])
|
||||
const [problemForm] = Form.useForm()
|
||||
const [resolutionForm] = Form.useForm()
|
||||
const [materialForm] = Form.useForm<MaterialFormValues>()
|
||||
const [cancelForm] = Form.useForm<CancelOrderFormValues>()
|
||||
const [cancelShareForm] = Form.useForm<CancelShareFormValues>()
|
||||
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
|
||||
const [afterSalesCreateForm] = Form.useForm<AfterSalesCreateFormValues>()
|
||||
const [afterSalesResolutionForm] = Form.useForm<AfterSalesResolutionFormValues>()
|
||||
const [editForm] = Form.useForm<{
|
||||
productName?: string
|
||||
platformOrderId?: string
|
||||
@@ -258,6 +279,15 @@ export default function WorkOrdersPanel() {
|
||||
queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)),
|
||||
enabled: Boolean(sharingOrder),
|
||||
})
|
||||
const afterSalesSharingQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-after-sales-sharing', afterSalesOrder?.workOrderId],
|
||||
queryFn: () => fetchAdminWorkOrderSharing(Number(afterSalesOrder?.workOrderId)),
|
||||
enabled: Boolean(afterSalesOrder?.sharing?.enabled),
|
||||
})
|
||||
const afterSalesCasesQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-after-sales-cases'],
|
||||
queryFn: () => fetchAdminAfterSalesCases({ page: 1, pageSize: 100 }),
|
||||
})
|
||||
const assignWorkersQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-workers', 'assign', assignWorkerKeyword],
|
||||
queryFn: () =>
|
||||
@@ -414,6 +444,9 @@ export default function WorkOrdersPanel() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-summary'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-after-sales-cases'],
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -625,6 +658,60 @@ export default function WorkOrdersPanel() {
|
||||
})
|
||||
}
|
||||
|
||||
function openAfterSalesCreateModal(row: WorkOrder) {
|
||||
setAfterSalesOrder(row)
|
||||
setAfterSalesFiles([])
|
||||
afterSalesCreateForm.setFieldsValue({
|
||||
shareId: undefined,
|
||||
problemType: 'fake_evidence',
|
||||
complaintNote: '',
|
||||
})
|
||||
}
|
||||
|
||||
async function submitAfterSalesCreate(values: AfterSalesCreateFormValues) {
|
||||
if (!afterSalesOrder) return
|
||||
const succeeded = await runAction(
|
||||
() =>
|
||||
createAdminAfterSalesCase(afterSalesOrder.workOrderId, {
|
||||
shareId: values.shareId || null,
|
||||
problemType: values.problemType || 'fake_evidence',
|
||||
complaintNote: String(values.complaintNote || '').trim(),
|
||||
files: afterSalesFiles,
|
||||
}),
|
||||
'售后问题单已创建,已通知责任打手说明',
|
||||
)
|
||||
if (!succeeded) return
|
||||
setAfterSalesOrder(null)
|
||||
setAfterSalesFiles([])
|
||||
afterSalesCreateForm.resetFields()
|
||||
}
|
||||
|
||||
function openAfterSalesResolutionModal(item: WorkerAfterSalesCase) {
|
||||
setAfterSalesCase(item)
|
||||
afterSalesResolutionForm.setFieldsValue({
|
||||
action: 'upheld',
|
||||
resolutionNote: '',
|
||||
recoveryAmount: Number(item.recoveryAmount || 0) / 100,
|
||||
})
|
||||
}
|
||||
|
||||
async function submitAfterSalesResolution(values: AfterSalesResolutionFormValues) {
|
||||
const action = values.action
|
||||
if (!afterSalesCase || !action) return
|
||||
const succeeded = await runAction(
|
||||
() =>
|
||||
resolveAdminAfterSalesCase(afterSalesCase.caseId, {
|
||||
action,
|
||||
resolutionNote: String(values.resolutionNote || '').trim(),
|
||||
recoveryAmount: action === 'upheld' ? Number(values.recoveryAmount || 0) : 0,
|
||||
}),
|
||||
'售后问题单已处置',
|
||||
)
|
||||
if (!succeeded) return
|
||||
setAfterSalesCase(null)
|
||||
afterSalesResolutionForm.resetFields()
|
||||
}
|
||||
|
||||
async function submitDeduct(values: { amount?: number; note?: string }) {
|
||||
if (!deductOrder) return
|
||||
const succeeded = await runAction(
|
||||
@@ -983,6 +1070,15 @@ export default function WorkOrdersPanel() {
|
||||
扣押金
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'accepted' ? (
|
||||
<Button
|
||||
icon={<WarningOutlined />}
|
||||
danger
|
||||
onClick={() => openAfterSalesCreateModal(row)}
|
||||
>
|
||||
售后
|
||||
</Button>
|
||||
) : null}
|
||||
{row.capabilities?.canMarkProblem ? (
|
||||
<Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}>
|
||||
问题单
|
||||
@@ -1154,6 +1250,138 @@ export default function WorkOrdersPanel() {
|
||||
scroll={{ x: 1240 }}
|
||||
/>
|
||||
</Card>
|
||||
<Card
|
||||
title="验收后售后问题单"
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={afterSalesCasesQuery.isFetching}
|
||||
onClick={() => afterSalesCasesQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
bordered={false}
|
||||
>
|
||||
<Table<WorkerAfterSalesCase>
|
||||
rowKey="caseId"
|
||||
loading={afterSalesCasesQuery.isLoading}
|
||||
dataSource={afterSalesCasesQuery.data?.data.items || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 1160 }}
|
||||
columns={[
|
||||
{
|
||||
title: '售后单',
|
||||
width: 190,
|
||||
render: (_, item) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>{item.caseNo}</Typography.Text>
|
||||
<Typography.Text type="secondary">{item.workOrderNo || '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatAdminDateTime(item.createdAt)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '责任打手',
|
||||
width: 150,
|
||||
render: (_, item) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text>{item.workerName || `打手#${item.workerId}`}</Typography.Text>
|
||||
{item.shareQuantity ? (
|
||||
<Tag color="purple">拼单 {item.shareQuantity} 份</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '投诉与证据',
|
||||
width: 260,
|
||||
render: (_, item) => (
|
||||
<div className="cell-stack">
|
||||
<Tag color="red">{formatAfterSalesProblemType(item.problemType)}</Tag>
|
||||
<Typography.Text ellipsis={{ tooltip: item.complaintNote }}>
|
||||
{item.complaintNote || '-'}
|
||||
</Typography.Text>
|
||||
{item.complaintFiles.length > 0 ? (
|
||||
<ImagePreviewList files={item.complaintFiles} size={36} maxVisible={3} />
|
||||
) : null}
|
||||
{getAfterSalesSnapshotFiles(item).length > 0 ? (
|
||||
<Space size={6} align="center">
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
固化验收图
|
||||
</Typography.Text>
|
||||
<ImagePreviewList
|
||||
files={getAfterSalesSnapshotFiles(item)}
|
||||
size={36}
|
||||
maxVisible={3}
|
||||
/>
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '打手说明',
|
||||
width: 250,
|
||||
render: (_, item) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text ellipsis={{ tooltip: item.workerResponse }}>
|
||||
{item.workerResponse || '暂未提交'}
|
||||
</Typography.Text>
|
||||
{item.workerResponseFiles.length > 0 ? (
|
||||
<ImagePreviewList files={item.workerResponseFiles} size={36} maxVisible={3} />
|
||||
) : null}
|
||||
{item.respondedAt ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatAdminDateTime(item.respondedAt)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '处置与追缴',
|
||||
width: 240,
|
||||
render: (_, item) => (
|
||||
<div className="cell-stack">
|
||||
<Tag color={resolveAfterSalesStatusColor(item.status)}>
|
||||
{formatAfterSalesStatus(item.status)}
|
||||
</Tag>
|
||||
{item.resolutionAction ? (
|
||||
<Typography.Text type="secondary" ellipsis={{ tooltip: item.resolutionNote }}>
|
||||
{formatAfterSalesResolution(item.resolutionAction)} ·{' '}
|
||||
{item.resolutionNote || '-'}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{item.resolutionAction === 'upheld' ? (
|
||||
<Typography.Text type={item.debtAmount > 0 ? 'danger' : undefined}>
|
||||
追缴 {formatMoney(item.recoveryAmount)},待还 {formatMoney(item.debtAmount)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, item) =>
|
||||
['awaiting_worker_response', 'under_review'].includes(item.status) ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => openAfterSalesResolutionModal(item)}
|
||||
>
|
||||
处置
|
||||
</Button>
|
||||
) : (
|
||||
<Typography.Text type="secondary">已完成</Typography.Text>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Drawer
|
||||
className="worker-order-detail-drawer"
|
||||
title={
|
||||
@@ -1486,6 +1714,132 @@ export default function WorkOrdersPanel() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`发起验收后售后 · ${getDisplayOrderNo(afterSalesOrder)}`}
|
||||
open={Boolean(afterSalesOrder)}
|
||||
onCancel={() => {
|
||||
setAfterSalesOrder(null)
|
||||
setAfterSalesFiles([])
|
||||
afterSalesCreateForm.resetFields()
|
||||
}}
|
||||
onOk={() => afterSalesCreateForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
{afterSalesOrder ? (
|
||||
<Form form={afterSalesCreateForm} layout="vertical" onFinish={submitAfterSalesCreate}>
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="售后不会回退已验收工单;原验收资料会在创建时固化。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
{(afterSalesSharingQuery.data?.data.shares || []).some(
|
||||
(share) => share.status === 'accepted',
|
||||
) ? (
|
||||
<Form.Item
|
||||
label="责任拼单份额"
|
||||
name="shareId"
|
||||
rules={[{ required: true, message: '请选择责任打手的已验收份额' }]}
|
||||
>
|
||||
<Select
|
||||
loading={afterSalesSharingQuery.isLoading}
|
||||
placeholder="请选择责任拼单份额"
|
||||
options={(afterSalesSharingQuery.data?.data.shares || [])
|
||||
.filter((share) => share.status === 'accepted')
|
||||
.map((share) => ({
|
||||
value: share.shareId,
|
||||
label: `${share.worker?.displayName || share.worker?.username || `打手#${share.workerId}`} · ${share.quantity} 份`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item label="问题类型" name="problemType" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'fake_evidence', label: '疑似 P 图 / 伪造验收' },
|
||||
{ value: 'result_not_met', label: '实际结果未达标' },
|
||||
{ value: 'other', label: '其他售后问题' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="用户投诉说明"
|
||||
name="complaintNote"
|
||||
rules={[{ required: true, message: '请填写用户投诉说明' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="记录用户反馈、核查结论及需打手说明的事项" />
|
||||
</Form.Item>
|
||||
<Form.Item label="用户证据图片">
|
||||
<ImageUpload
|
||||
scene="collect-material"
|
||||
scope="admin"
|
||||
value={afterSalesFiles}
|
||||
onChange={setAfterSalesFiles}
|
||||
maxCount={10}
|
||||
showPasteHint
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`处置售后问题单 · ${afterSalesCase?.caseNo || ''}`}
|
||||
open={Boolean(afterSalesCase)}
|
||||
onCancel={() => {
|
||||
setAfterSalesCase(null)
|
||||
afterSalesResolutionForm.resetFields()
|
||||
}}
|
||||
onOk={() => afterSalesResolutionForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
{afterSalesCase ? (
|
||||
<Form
|
||||
form={afterSalesResolutionForm}
|
||||
layout="vertical"
|
||||
onFinish={submitAfterSalesResolution}
|
||||
>
|
||||
<Form.Item
|
||||
label="处置结论"
|
||||
name="action"
|
||||
rules={[{ required: true, message: '请选择处置结论' }]}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'upheld', label: '问题成立,执行追缴' },
|
||||
{ value: 'remedied', label: '已补救完成,不追缴' },
|
||||
{ value: 'not_upheld', label: '问题不成立,关闭售后' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(previous, current) => previous.action !== current.action}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('action') === 'upheld' ? (
|
||||
<Form.Item
|
||||
label="追缴金额"
|
||||
name="recoveryAmount"
|
||||
rules={[{ required: true, message: '请输入追缴金额' }]}
|
||||
extra="系统依次扣减该责任打手的本单待解冻押金、可用余额;不足部分转为欠款,并从后续验收报酬自动抵扣。"
|
||||
>
|
||||
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="处置备注"
|
||||
name="resolutionNote"
|
||||
rules={[{ required: true, message: '请填写处置备注' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="处置问题单"
|
||||
open={Boolean(resolutionOrder)}
|
||||
@@ -2294,6 +2648,58 @@ function formatRequirementFieldsText(fields: CollectField[]): string {
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function formatAfterSalesProblemType(value: string): string {
|
||||
if (value === 'fake_evidence') return '疑似 P 图 / 伪造验收'
|
||||
if (value === 'result_not_met') return '实际结果未达标'
|
||||
return '其他售后问题'
|
||||
}
|
||||
|
||||
function formatAfterSalesStatus(value: string): string {
|
||||
if (value === 'awaiting_worker_response') return '待打手说明'
|
||||
if (value === 'under_review') return '客服处理中'
|
||||
if (value === 'recovery_pending') return '追缴中'
|
||||
return '已关闭'
|
||||
}
|
||||
|
||||
function resolveAfterSalesStatusColor(value: string): string {
|
||||
if (value === 'awaiting_worker_response') return 'orange'
|
||||
if (value === 'under_review') return 'blue'
|
||||
if (value === 'recovery_pending') return 'red'
|
||||
return 'green'
|
||||
}
|
||||
|
||||
function formatAfterSalesResolution(value: string): string {
|
||||
if (value === 'upheld') return '问题成立'
|
||||
if (value === 'remedied') return '补救完成'
|
||||
if (value === 'not_upheld') return '问题不成立'
|
||||
return value || '-'
|
||||
}
|
||||
|
||||
function getAfterSalesSnapshotFiles(item: WorkerAfterSalesCase): UploadedFile[] {
|
||||
const snapshot = asRecord(item.acceptanceSnapshot)
|
||||
const acceptance = asRecord(snapshot.acceptance)
|
||||
const files = Array.isArray(acceptance.files) ? acceptance.files : []
|
||||
const imageUrls = Array.isArray(acceptance.imageUrls) ? acceptance.imageUrls : []
|
||||
return [
|
||||
...files.map((value) => asRecord(value)),
|
||||
...imageUrls.map((url) => asRecord({ url: String(url || '') })),
|
||||
]
|
||||
.map((file) => {
|
||||
const url = String(file.url || '').trim()
|
||||
if (!url) return null
|
||||
return {
|
||||
objectKey: String(file.objectKey || file.object_key || ''),
|
||||
url,
|
||||
thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || url),
|
||||
mediumUrl: String(file.mediumUrl || file.medium_url || url),
|
||||
filename: String(file.filename || '验收图片'),
|
||||
contentType: String(file.contentType || ''),
|
||||
size: Number(file.size || 0),
|
||||
} satisfies UploadedFile
|
||||
})
|
||||
.filter((file): file is UploadedFile => Boolean(file))
|
||||
}
|
||||
|
||||
function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
||||
const { message } = App.useApp()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
Reference in New Issue
Block a user