321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
/**
|
||
* 成本项 Tab:有哪些成本。
|
||
*
|
||
* 成本项 = 成本模型 + 计价方式;不再有「适用载体」列。
|
||
* 专属成本模型(上游采购 / 打手报酬 / 人工实际支出)只对对应履约方式生效,
|
||
* 通用成本模型(平台费 / 支付费 / 售后损失 / 其他)对所有履约方式生效。
|
||
*/
|
||
import { PlusOutlined, SearchOutlined } from '@ant-design/icons'
|
||
import { Alert, App, Button, Card, Form, Input, Modal, Select, Space, Tag, Typography } from 'antd'
|
||
import PercentTable from '@/components/common/PercentTable'
|
||
import type { TableColumnsType } from 'antd'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import { useState } from 'react'
|
||
|
||
import {
|
||
createAdminCostItem,
|
||
deleteAdminCostItem,
|
||
fetchAdminCostItems,
|
||
updateAdminCostItem,
|
||
} from '@/services/admin'
|
||
import type { CostItem } from '@/types/admin'
|
||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||
|
||
import { useCostCenter } from './CostCenterContext'
|
||
import {
|
||
COST_ITEM_PRESETS,
|
||
COST_MODEL_OPTIONS,
|
||
PRICING_OPTIONS,
|
||
costModelLabel,
|
||
labelOf,
|
||
type CostItemFormValues,
|
||
type CostItemPreset,
|
||
} from './shared'
|
||
|
||
export function CostItemPanel() {
|
||
const { message, modal } = App.useApp()
|
||
const { canConfig, fulfillmentModeFilter, refreshAll } = useCostCenter()
|
||
const [costItemForm] = Form.useForm<CostItemFormValues>()
|
||
|
||
const [keyword, setKeyword] = useState('')
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
||
const [modalOpen, setModalOpen] = useState(false)
|
||
const [editing, setEditing] = useState<CostItem | null>(null)
|
||
|
||
const costItemsQuery = useQuery({
|
||
queryKey: ['admin-cost-center-cost-items', fulfillmentModeFilter, keyword, page, pageSize],
|
||
queryFn: () =>
|
||
fetchAdminCostItems({
|
||
keyword: keyword || undefined,
|
||
fulfillmentMode: fulfillmentModeFilter,
|
||
page,
|
||
pageSize,
|
||
}),
|
||
})
|
||
|
||
const costItems = costItemsQuery.data?.data.items || []
|
||
const total = costItemsQuery.data?.data.pagination.total || 0
|
||
|
||
const visiblePresets =
|
||
fulfillmentModeFilter === undefined
|
||
? COST_ITEM_PRESETS
|
||
: COST_ITEM_PRESETS.filter((preset) => presetAppliesToScope(preset, fulfillmentModeFilter))
|
||
|
||
function openCreate(preset?: CostItemPreset) {
|
||
setEditing(null)
|
||
costItemForm.resetFields()
|
||
costItemForm.setFieldsValue({
|
||
name: preset?.name,
|
||
costModel: preset?.costModel,
|
||
pricingMode: preset?.pricingMode || 'per_item',
|
||
unitName: preset?.unitName || '件',
|
||
status: 'active',
|
||
})
|
||
setModalOpen(true)
|
||
}
|
||
|
||
function openEdit(item: CostItem) {
|
||
setEditing(item)
|
||
costItemForm.setFieldsValue({
|
||
name: item.name,
|
||
costModel: item.costModel,
|
||
pricingMode: item.pricingMode,
|
||
unitName: item.unitName,
|
||
status: item.status,
|
||
note: item.note,
|
||
})
|
||
setModalOpen(true)
|
||
}
|
||
|
||
async function submit(values: CostItemFormValues) {
|
||
try {
|
||
if (editing) {
|
||
await updateAdminCostItem(editing.id, {
|
||
name: values.name,
|
||
costModel: values.costModel,
|
||
pricingMode: values.pricingMode,
|
||
unitName: values.unitName || '件',
|
||
status: values.status || 'active',
|
||
note: values.note || '',
|
||
})
|
||
message.success('成本项目已更新')
|
||
} else {
|
||
const created = await createAdminCostItem(values)
|
||
message.success(`成本项目已创建(编码 ${created.data.costItem.code})`)
|
||
}
|
||
setModalOpen(false)
|
||
refreshAll()
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '保存成本项目失败')
|
||
}
|
||
}
|
||
|
||
function remove(item: CostItem) {
|
||
modal.confirm({
|
||
title: '删除成本项目',
|
||
content: `确认删除「${item.name}(${item.code})」?存在成本版本、成本快照或接单规则引用时会被拒绝,请改用归档。`,
|
||
okText: '删除',
|
||
okButtonProps: { danger: true },
|
||
cancelText: '取消',
|
||
onOk: async () => {
|
||
try {
|
||
await deleteAdminCostItem(item.id)
|
||
message.success('成本项目已删除')
|
||
refreshAll()
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '删除成本项目失败')
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
const columns: TableColumnsType<CostItem> = [
|
||
{ title: '编码', dataIndex: 'code', width: 180 },
|
||
{ title: '名称', dataIndex: 'name', width: 320, ellipsis: true },
|
||
{
|
||
title: '成本模型',
|
||
dataIndex: 'costModel',
|
||
width: 170,
|
||
render: (value: string) => costModelLabel(value),
|
||
},
|
||
{
|
||
title: '计价',
|
||
dataIndex: 'pricingMode',
|
||
width: 120,
|
||
render: (value: string) => labelOf(PRICING_OPTIONS, value),
|
||
},
|
||
{ title: '版本数', dataIndex: 'versionCount', width: 90 },
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 90,
|
||
render: (value: string) => (
|
||
<Tag color={value === 'active' ? 'green' : 'default'}>
|
||
{value === 'active' ? '启用' : '归档'}
|
||
</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 150,
|
||
fixed: 'right',
|
||
render: (_, row) =>
|
||
canConfig ? (
|
||
<Space size={4}>
|
||
<Button size="small" onClick={() => openEdit(row)}>
|
||
编辑
|
||
</Button>
|
||
<Button size="small" danger onClick={() => remove(row)}>
|
||
删除
|
||
</Button>
|
||
</Space>
|
||
) : null,
|
||
},
|
||
]
|
||
|
||
return (
|
||
<>
|
||
<Card
|
||
title={
|
||
<Space size={6} wrap>
|
||
<Typography.Text strong>成本项目</Typography.Text>
|
||
<Tag color="blue">{total} 个</Tag>
|
||
</Space>
|
||
}
|
||
extra={
|
||
canConfig ? (
|
||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openCreate()}>
|
||
新建成本项
|
||
</Button>
|
||
) : null
|
||
}
|
||
>
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 12 }}
|
||
message="成本项目 = 有哪些成本;成本版本 = 这笔成本多少钱、对谁生效"
|
||
description={
|
||
<Space direction="vertical" size={2}>
|
||
<span>
|
||
1. 先建成本项目:上游采购 / 接单平台打手报酬 / 人工客服实际支出 / 平台服务费等;
|
||
</span>
|
||
<span>
|
||
2. 再建成本版本:同一成本项可有多条,按范围优先级 接单规则 > 标准商品 > 店铺
|
||
> 来源 > 全局 命中;
|
||
</span>
|
||
<span>3. 改价只新增版本,历史订单走成本快照。</span>
|
||
</Space>
|
||
}
|
||
/>
|
||
{canConfig && visiblePresets.length > 0 ? (
|
||
<Space style={{ marginBottom: 12 }} wrap>
|
||
<Typography.Text type="secondary">常用成本项:</Typography.Text>
|
||
{visiblePresets.map((preset) => (
|
||
<Button key={preset.key} size="small" onClick={() => openCreate(preset)}>
|
||
+ {preset.label}
|
||
</Button>
|
||
))}
|
||
</Space>
|
||
) : null}
|
||
<Space style={{ marginBottom: 16 }}>
|
||
<Input
|
||
allowClear
|
||
prefix={<SearchOutlined />}
|
||
placeholder="编码 / 名称"
|
||
value={keyword}
|
||
onChange={(event) => {
|
||
setKeyword(event.target.value)
|
||
setPage(1)
|
||
}}
|
||
style={{ width: 260 }}
|
||
/>
|
||
</Space>
|
||
<PercentTable
|
||
tableLayout="fixed"
|
||
rowKey="id"
|
||
size="small"
|
||
columns={columns}
|
||
dataSource={costItems}
|
||
loading={costItemsQuery.isLoading}
|
||
scroll={{ x: 1100 }}
|
||
pagination={buildAdminTablePagination({
|
||
current: page,
|
||
pageSize,
|
||
total,
|
||
showTotal: (count) => `共 ${count} 个成本项`,
|
||
onChange: (nextPage, nextPageSize) => {
|
||
setPage(nextPage)
|
||
setPageSize(nextPageSize)
|
||
},
|
||
})}
|
||
/>
|
||
</Card>
|
||
|
||
<Modal
|
||
title={editing ? '编辑成本项目' : '新建成本项目'}
|
||
open={modalOpen}
|
||
onCancel={() => setModalOpen(false)}
|
||
onOk={() => costItemForm.submit()}
|
||
okText="保存"
|
||
cancelText="取消"
|
||
destroyOnClose
|
||
>
|
||
<Form form={costItemForm} layout="vertical" onFinish={submit}>
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 12 }}
|
||
message="编码由系统自动生成(按成本模型 + 序号),无需填写;创建后不可修改。"
|
||
/>
|
||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||
<Input placeholder="例如:接单平台打手报酬" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="costModel"
|
||
label="成本模型"
|
||
rules={[{ required: true }]}
|
||
extra="上游采购 / 打手报酬 / 人工实际支出只对对应履约方式生效;平台费、支付费、售后损失、其他对所有履约方式生效。"
|
||
>
|
||
<Select options={COST_MODEL_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="pricingMode"
|
||
label="计价方式"
|
||
rules={[{ required: true }]}
|
||
extra="按件 = 数量 × 单价;按单 = 每单固定金额;按收入比例 = 订单金额 × 百分比。人工成本逐单按实际金额,不按工时。"
|
||
>
|
||
<Select options={PRICING_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item name="unitName" label="单位">
|
||
<Input placeholder="件 / 单" />
|
||
</Form.Item>
|
||
<Form.Item name="status" label="状态">
|
||
<Select
|
||
options={[
|
||
{ value: 'active', label: '启用' },
|
||
{ value: 'archived', label: '归档' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="note" label="备注">
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 常用模板是否适用于当前履约方式分区。 */
|
||
function presetAppliesToScope(
|
||
preset: CostItemPreset,
|
||
fulfillmentMode: 'affiliate_dash' | 'work_order' | 'manual',
|
||
) {
|
||
if (preset.costModel === 'affiliate_purchase') return fulfillmentMode === 'affiliate_dash'
|
||
if (preset.costModel === 'work_order_reward') return fulfillmentMode === 'work_order'
|
||
if (preset.costModel === 'manual_actual') return fulfillmentMode === 'manual'
|
||
return true
|
||
}
|