Files
order_site/apps/frontend/src/pages/admin/cost-center/CostItemPanel.tsx
T

321 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.
/**
* 成本项 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. 再建成本版本:同一成本项可有多条,按范围优先级 接单规则 &gt; 标准商品 &gt; 店铺
&gt; 来源 &gt; 全局 命中;
</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
}