风险与内控管理优化: 异常账单服务端分页/筛选/收银员搜索/合计, 收银员TOP15按异常账单数排序, 双击跳转明细, MonthPicker靠右, 侧边栏底部间距
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo, useCallback, useEffect, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, Treemap } from 'recharts'
|
||||
import api from '@/lib/api'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { formatNumber, formatCurrency } from '@/lib/utils'
|
||||
import { Network, ChevronRight, Layers, GitBranch } from 'lucide-react'
|
||||
import { FilterableTable } from '@/components/FilterableTable'
|
||||
import { formatNumber, formatCurrency, cn } from '@/lib/utils'
|
||||
import { Network, ChevronRight, ChevronDown, Layers, GitBranch, Box, Package, Loader2, X } from 'lucide-react'
|
||||
import { MonthPicker } from '@/components/MonthPicker'
|
||||
|
||||
function varianceColorClass(pct: number): string {
|
||||
@@ -20,10 +21,53 @@ function varianceText(pct: number): string {
|
||||
return `${pct.toFixed(2)}%`
|
||||
}
|
||||
|
||||
interface BomTreeNode {
|
||||
level: number
|
||||
material_code: string
|
||||
material_name: string
|
||||
unit: string
|
||||
theoretical_qty: number
|
||||
issue_qty: number
|
||||
theoretical_amt: number
|
||||
issue_amt: number
|
||||
avg_unit_price: number
|
||||
is_finished_product: boolean
|
||||
parent_material_code: string
|
||||
root_product_code: string
|
||||
root_product_name: string
|
||||
root_recipe: string
|
||||
path: string
|
||||
children: BomTreeNode[]
|
||||
}
|
||||
|
||||
function buildTree(flatTree: any[]): BomTreeNode[] {
|
||||
const nodeMap = new Map<string, BomTreeNode>()
|
||||
const roots: BomTreeNode[] = []
|
||||
flatTree.forEach((n: any) => {
|
||||
const node: BomTreeNode = { ...n, children: [] }
|
||||
nodeMap.set(node.material_code + '_' + node.level, node)
|
||||
})
|
||||
flatTree.forEach((n: any) => {
|
||||
const key = n.material_code + '_' + n.level
|
||||
const node = nodeMap.get(key)!
|
||||
if (n.level === 1) {
|
||||
roots.push(node)
|
||||
} else {
|
||||
const parentKey = n.parent_material_code + '_' + (n.level - 1)
|
||||
const parent = nodeMap.get(parentKey)
|
||||
if (parent) parent.children.push(node)
|
||||
else roots.push(node)
|
||||
}
|
||||
})
|
||||
return roots
|
||||
}
|
||||
|
||||
export function BomPenetrationPage() {
|
||||
const [month, setMonth] = useState('2026-04')
|
||||
const [selectedProduct, setSelectedProduct] = useState('0202028')
|
||||
const [productFilter, setProductFilter] = useState('')
|
||||
const [selectedProduct, setSelectedProduct] = useState<string | null>(null)
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set())
|
||||
const [lazyChildren, setLazyChildren] = useState<Map<string, BomTreeNode[]>>(new Map())
|
||||
const [loadingNodes, setLoadingNodes] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: overviewData, isLoading: overviewLoading } = useQuery({
|
||||
queryKey: ['bom-penetration-overview', month],
|
||||
@@ -42,11 +86,69 @@ export function BomPenetrationPage() {
|
||||
enabled: !!selectedProduct,
|
||||
})
|
||||
|
||||
const bomTree = productData?.bomTree || []
|
||||
|
||||
// BOM树构建
|
||||
const bomTreeData = useMemo(() => buildTree(bomTree), [bomTree])
|
||||
|
||||
const maxLevel = useMemo(() => Math.max(0, ...bomTree.map((n: any) => n.level)), [bomTree])
|
||||
|
||||
const toggleNode = useCallback(async (code: string, isFinishedProduct: boolean) => {
|
||||
if (expandedNodes.has(code)) {
|
||||
setExpandedNodes(prev => { const n = new Set(prev); n.delete(code); return n })
|
||||
return
|
||||
}
|
||||
setExpandedNodes(prev => new Set(prev).add(code))
|
||||
if (isFinishedProduct && !lazyChildren.has(code)) {
|
||||
setLoadingNodes(prev => new Set(prev).add(code))
|
||||
try {
|
||||
const res = await api.get(`/central-kitchen/bom-penetration?month=${month}&productCode=${code}`)
|
||||
const children = buildTree(res.data.bomTree || [])
|
||||
setLazyChildren(prev => new Map(prev).set(code, children))
|
||||
} catch (e) {
|
||||
setLazyChildren(prev => new Map(prev).set(code, []))
|
||||
} finally {
|
||||
setLoadingNodes(prev => { const n = new Set(prev); n.delete(code); return n })
|
||||
}
|
||||
}
|
||||
}, [expandedNodes, lazyChildren, month])
|
||||
|
||||
const openProductModal = (code: string) => {
|
||||
setSelectedProduct(code)
|
||||
setExpandedNodes(new Set())
|
||||
setLazyChildren(new Map())
|
||||
setLoadingNodes(new Set())
|
||||
}
|
||||
|
||||
// 数据加载后自动展开所有有子级的节点
|
||||
useEffect(() => {
|
||||
if (!bomTreeData.length) return
|
||||
const toExpand = new Set<string>()
|
||||
const collect = (nodes: BomTreeNode[]) => {
|
||||
nodes.forEach(n => {
|
||||
if (n.children.length > 0) {
|
||||
toExpand.add(n.material_code)
|
||||
collect(n.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
collect(bomTreeData)
|
||||
if (toExpand.size > 0) {
|
||||
setExpandedNodes(prev => new Set([...prev, ...toExpand]))
|
||||
}
|
||||
}, [bomTreeData])
|
||||
|
||||
const closeModal = () => {
|
||||
setSelectedProduct(null)
|
||||
setExpandedNodes(new Set())
|
||||
setLazyChildren(new Map())
|
||||
setLoadingNodes(new Set())
|
||||
}
|
||||
|
||||
if (overviewLoading) return <LoadingSpinner />
|
||||
if (!overviewData) return <div className="p-4 text-muted-foreground">暂无数据</div>
|
||||
|
||||
const { productList, bomSummary, multiLevelChains } = overviewData
|
||||
const bomTree = productData?.bomTree || []
|
||||
|
||||
// 汇总指标
|
||||
const totalProducts = productList.length
|
||||
@@ -63,13 +165,63 @@ export function BomPenetrationPage() {
|
||||
领用成本: b.bom_issue_amt,
|
||||
}))
|
||||
|
||||
// BOM树按层级分组
|
||||
const treeByLevel: Record<number, any[]> = {}
|
||||
bomTree.forEach((node: any) => {
|
||||
if (!treeByLevel[node.level]) treeByLevel[node.level] = []
|
||||
treeByLevel[node.level].push(node)
|
||||
})
|
||||
const maxLevel = Math.max(...Object.keys(treeByLevel).map(Number), 1)
|
||||
const renderTreeRows = (nodes: BomTreeNode[], depth: number): ReactNode[] => {
|
||||
const rows: ReactNode[] = []
|
||||
nodes.forEach((node) => {
|
||||
const isExpanded = expandedNodes.has(node.material_code)
|
||||
const hasChildren = node.children.length > 0
|
||||
const lazyLoaded = lazyChildren.get(node.material_code)
|
||||
const isLoading = loadingNodes.has(node.material_code)
|
||||
const showChevron = hasChildren || node.is_finished_product
|
||||
const childrenToRender = hasChildren ? node.children : (lazyLoaded || [])
|
||||
const isZero = node.theoretical_qty === 0 && node.issue_qty === 0 && node.theoretical_amt === 0 && node.issue_amt === 0
|
||||
rows.push(
|
||||
<tr key={node.material_code + '_' + node.level} className={cn('border-b hover:bg-muted/50', node.is_finished_product && 'bg-purple-50/30', isZero && 'bg-yellow-50/40')}>
|
||||
<td className="py-1.5 pr-3" style={{ paddingLeft: `${depth * 20 + 8}px` }}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{showChevron ? (
|
||||
<button onClick={() => toggleNode(node.material_code, node.is_finished_product)} className="flex-shrink-0">
|
||||
{isLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> :
|
||||
isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-block w-3.5" />
|
||||
)}
|
||||
{node.is_finished_product && <Box className="h-3.5 w-3.5 text-purple-600 flex-shrink-0" />}
|
||||
<span className={cn('text-xs', node.is_finished_product && 'font-medium text-purple-700')}>{node.material_name}</span>
|
||||
{isZero && <span className="rounded bg-yellow-100 px-1 py-0.5 text-[10px] text-yellow-700">用量为0</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{node.material_code}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{node.unit}</td>
|
||||
<td className="py-1.5 pr-3 text-right text-xs">{formatNumber(node.theoretical_qty)}</td>
|
||||
<td className="py-1.5 pr-3 text-right text-xs">{formatNumber(node.issue_qty)}</td>
|
||||
<td className="py-1.5 pr-3 text-right text-xs">{formatCurrency(node.theoretical_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right text-xs">{formatCurrency(node.issue_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right text-xs">
|
||||
<span className={cn('font-medium', node.issue_amt - node.theoretical_amt >= 0 ? 'text-yellow-600' : 'text-red-600')}>
|
||||
{formatCurrency(node.issue_amt - node.theoretical_amt)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-3" />
|
||||
</tr>
|
||||
)
|
||||
if (isExpanded && childrenToRender.length > 0) {
|
||||
rows.push(...renderTreeRows(childrenToRender, depth + 1))
|
||||
}
|
||||
if (isExpanded && !hasChildren && lazyLoaded && lazyLoaded.length === 0 && !isLoading) {
|
||||
rows.push(
|
||||
<tr key={node.material_code + '_empty'} className="border-b">
|
||||
<td className="py-1 pr-3 text-xs text-muted-foreground" style={{ paddingLeft: `${(depth + 1) * 20 + 8}px` }}>
|
||||
无子配方数据
|
||||
</td>
|
||||
<td colSpan={8} />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
// Treemap数据
|
||||
const treemapData = bomSummary.slice(0, 30).map((b: any) => ({
|
||||
@@ -78,18 +230,14 @@ export function BomPenetrationPage() {
|
||||
variance: b.variance_pct,
|
||||
}))
|
||||
|
||||
const filteredProducts = productList.filter((p: any) =>
|
||||
!productFilter ||
|
||||
p.product_code?.includes(productFilter) ||
|
||||
p.product_name?.includes(productFilter)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-6 w-6 text-purple-600" />
|
||||
<h1 className="text-xl font-bold">多级BOM成本穿透</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-6 w-6 text-purple-600" />
|
||||
<h1 className="text-xl font-bold">多级BOM成本穿透</h1>
|
||||
</div>
|
||||
<MonthPicker month={month} onChange={setMonth} />
|
||||
</div>
|
||||
|
||||
@@ -115,45 +263,32 @@ export function BomPenetrationPage() {
|
||||
<Bar dataKey="领用成本" fill="#722ed1" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">产品编码</th>
|
||||
<th className="pb-2 pr-3">产品名称</th>
|
||||
<th className="pb-2 pr-3 text-right">材料数</th>
|
||||
<th className="pb-2 pr-3 text-right">半成品数</th>
|
||||
<th className="pb-2 pr-3 text-right">理论成本</th>
|
||||
<th className="pb-2 pr-3 text-right">领用成本</th>
|
||||
<th className="pb-2 pr-3 text-right">单位理论成本</th>
|
||||
<th className="pb-2 pr-3 text-right">单位领用成本</th>
|
||||
<th className="pb-2 pr-3 text-right">差异金额</th>
|
||||
<th className="pb-2 pr-3 text-right">差异率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bomSummaryTop10.map((b: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50 cursor-pointer" onClick={() => setSelectedProduct(b.product_code)}>
|
||||
<td className="py-1.5 pr-3 text-xs">{b.product_code}</td>
|
||||
<td className="py-1.5 pr-3 font-medium text-blue-600">{b.product_name}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{b.material_count}</td>
|
||||
<td className="py-1.5 pr-3 text-right">
|
||||
{b.sub_product_count > 0 ? <span className="text-purple-600 font-medium">{b.sub_product_count}</span> : '-'}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(b.bom_theoretical_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(b.bom_issue_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(b.bom_unit_theoretical_cost)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(b.bom_unit_issue_cost)}</td>
|
||||
<td className={`py-1.5 pr-3 text-right ${b.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(b.variance_amt)}
|
||||
</td>
|
||||
<td className={`py-1.5 pr-3 text-right font-medium ${varianceColorClass(b.variance_pct)}`}>
|
||||
{varianceText(b.variance_pct)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-3">
|
||||
<FilterableTable
|
||||
data={bomSummary}
|
||||
searchKeys={['product_code', 'product_name']}
|
||||
searchPlaceholder="搜索产品..."
|
||||
sortOptions={[
|
||||
{ key: 'bom_theoretical_amt', label: '理论成本' },
|
||||
{ key: 'bom_issue_amt', label: '领用成本' },
|
||||
{ key: 'variance_amt', label: '差异金额' },
|
||||
{ key: 'variance_pct', label: '差异率' },
|
||||
{ key: 'material_count', label: '材料数' },
|
||||
]}
|
||||
columns={[
|
||||
{ key: 'product_code', label: '产品编码' },
|
||||
{ key: 'product_name', label: '产品名称', render: (b) => <span className="font-medium text-blue-600">{b.product_name}</span> },
|
||||
{ key: 'material_count', label: '材料数', align: 'right' },
|
||||
{ key: 'sub_product_count', label: '半成品数', align: 'right', render: (b) => b.sub_product_count > 0 ? <span className="text-purple-600 font-medium">{b.sub_product_count}</span> : '-' },
|
||||
{ key: 'bom_theoretical_amt', label: '理论成本', align: 'right', render: (b) => formatCurrency(b.bom_theoretical_amt) },
|
||||
{ key: 'bom_issue_amt', label: '领用成本', align: 'right', render: (b) => formatCurrency(b.bom_issue_amt) },
|
||||
{ key: 'bom_unit_theoretical_cost', label: '单位理论成本', align: 'right', render: (b) => formatNumber(b.bom_unit_theoretical_cost) },
|
||||
{ key: 'bom_unit_issue_cost', label: '单位领用成本', align: 'right', render: (b) => formatNumber(b.bom_unit_issue_cost) },
|
||||
{ key: 'variance_amt', label: '差异金额', align: 'right', render: (b) => <span className={b.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(b.variance_amt)}</span> },
|
||||
{ key: 'variance_pct', label: '差异率', align: 'right', render: (b) => <span className={`font-medium ${varianceColorClass(b.variance_pct)}`}>{varianceText(b.variance_pct)}</span> },
|
||||
]}
|
||||
onRowClick={(b) => setSelectedProduct(b.product_code)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -170,156 +305,105 @@ export function BomPenetrationPage() {
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* BOM树穿透 */}
|
||||
<CollapsibleSection
|
||||
title={`BOM树穿透 - ${productData?.productList?.find((p: any) => p.product_code === selectedProduct)?.product_name || selectedProduct}`}
|
||||
subtitle={`共${bomTree.length}个节点,最大${maxLevel}层`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm text-muted-foreground">点击下方产品可切换BOM树</span>
|
||||
</div>
|
||||
{productLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(treeByLevel).map(([level, nodes]) => (
|
||||
<div key={level} className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm font-medium">Level {level}</span>
|
||||
<span className="text-xs text-muted-foreground">({nodes.length}个物料)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
|
||||
{nodes.map((node: any, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded border p-2 ${node.is_finished_product ? 'border-purple-300 bg-purple-50/50' : 'border-gray-200'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">{node.material_name}</span>
|
||||
{node.is_finished_product && (
|
||||
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-600">半成品</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>编码: {node.material_code}</span>
|
||||
<span>单位: {node.unit}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs">
|
||||
<span>理论: {formatCurrency(node.theoretical_amt)}</span>
|
||||
<span>领用: {formatCurrency(node.issue_amt)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>理论量: {formatNumber(node.theoretical_qty)}</span>
|
||||
<span>领用量: {formatNumber(node.issue_qty)}</span>
|
||||
</div>
|
||||
{node.is_finished_product && (
|
||||
<button
|
||||
className="mt-1 text-xs text-purple-600 hover:underline"
|
||||
onClick={() => setSelectedProduct(node.material_code)}
|
||||
>
|
||||
展开此半成品BOM →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{bomTree.length === 0 && (
|
||||
<div className="py-4 text-center text-muted-foreground">该产品无配方数据</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 产品选择器 */}
|
||||
<CollapsibleSection title="产品列表" subtitle={`${filteredProducts.length}个产品`} defaultOpen={false}
|
||||
headerRight={
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索产品..."
|
||||
value={productFilter}
|
||||
onChange={(e) => setProductFilter(e.target.value)}
|
||||
className="rounded border px-3 py-1 text-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">产品编码</th>
|
||||
<th className="pb-2 pr-3">产品名称</th>
|
||||
<th className="pb-2 pr-3">品类</th>
|
||||
<th className="pb-2 pr-3 text-right">入库量</th>
|
||||
<th className="pb-2 pr-3 text-right">理论成本</th>
|
||||
<th className="pb-2 pr-3 text-right">实际成本</th>
|
||||
<th className="pb-2 pr-3 text-right">材料数</th>
|
||||
<th className="pb-2 pr-3 text-center">多级BOM</th>
|
||||
<th className="pb-2 pr-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredProducts.map((p: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="py-1.5 pr-3 text-xs">{p.product_code}</td>
|
||||
<td className="py-1.5 pr-3 font-medium">{p.product_name}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{p.category_minor}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(p.inbound_quantity)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.theoretical_cost)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.actual_cost)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{p.material_count}</td>
|
||||
<td className="py-1.5 pr-3 text-center">
|
||||
{p.has_multi_level_bom ? <span className="text-purple-600">✓</span> : '-'}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<button
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
onClick={() => setSelectedProduct(p.product_code)}
|
||||
>
|
||||
查看BOM
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<CollapsibleSection title="产品列表" subtitle={`${productList.length}个产品`}>
|
||||
<FilterableTable
|
||||
data={productList}
|
||||
searchKeys={['product_code', 'product_name']}
|
||||
searchPlaceholder="搜索产品..."
|
||||
sortOptions={[
|
||||
{ key: 'theoretical_cost', label: '理论成本' },
|
||||
{ key: 'actual_cost', label: '实际成本' },
|
||||
{ key: 'inbound_quantity', label: '入库量' },
|
||||
{ key: 'material_count', label: '材料数' },
|
||||
]}
|
||||
columns={[
|
||||
{ key: 'product_code', label: '产品编码' },
|
||||
{ key: 'product_name', label: '产品名称', render: (p) => <span className="font-medium">{p.product_name}</span> },
|
||||
{ key: 'category_minor', label: '品类' },
|
||||
{ key: 'inbound_quantity', label: '入库量', align: 'right', render: (p) => formatNumber(p.inbound_quantity) },
|
||||
{ key: 'theoretical_cost', label: '理论成本', align: 'right', render: (p) => formatCurrency(p.theoretical_cost) },
|
||||
{ key: 'actual_cost', label: '实际成本', align: 'right', render: (p) => formatCurrency(p.actual_cost) },
|
||||
{ key: 'material_count', label: '材料数', align: 'right' },
|
||||
{ key: 'has_multi_level_bom', label: '多级BOM', align: 'center', render: (p) => p.has_multi_level_bom ? <span className="text-purple-600">✓</span> : '-' },
|
||||
{ key: 'action', label: '', render: (p) => <button className="text-xs text-blue-600 hover:underline" onClick={(e) => { e.stopPropagation(); openProductModal(p.product_code) }}>查看BOM</button> },
|
||||
]}
|
||||
onRowClick={(p) => openProductModal(p.product_code)}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 半成品依赖链 */}
|
||||
<CollapsibleSection title="半成品依赖链" subtitle={`${multiLevelChains.length}条成品→半成品依赖关系`} defaultOpen={false}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">成品编码</th>
|
||||
<th className="pb-2 pr-3">成品名称</th>
|
||||
<th className="pb-2 pr-3">成品配方</th>
|
||||
<th className="pb-2 pr-3"></th>
|
||||
<th className="pb-2 pr-3">半成品编码</th>
|
||||
<th className="pb-2 pr-3">半成品名称</th>
|
||||
<th className="pb-2 pr-3">半成品配方</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{multiLevelChains.map((c: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="py-1.5 pr-3 text-xs">{c.finished_code}</td>
|
||||
<td className="py-1.5 pr-3 font-medium">{c.finished_name}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{c.finished_recipe}</td>
|
||||
<td className="py-1.5 pr-3"><ChevronRight className="h-4 w-4 text-purple-600" /></td>
|
||||
<td className="py-1.5 pr-3 text-xs">{c.sub_product_code}</td>
|
||||
<td className="py-1.5 pr-3 font-medium text-purple-600">{c.sub_product_finished_name || c.sub_product_name}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{c.sub_product_recipe || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Network className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm text-muted-foreground">展示成品与半成品之间的多级BOM依赖关系,可快速定位半成品成本波动影响的成品范围</span>
|
||||
</div>
|
||||
<FilterableTable
|
||||
data={multiLevelChains}
|
||||
searchKeys={['finished_code', 'finished_name', 'sub_product_code', 'sub_product_name']}
|
||||
searchPlaceholder="搜索成品/半成品..."
|
||||
sortOptions={[
|
||||
{ key: 'finished_name', label: '成品名称' },
|
||||
{ key: 'sub_product_name', label: '半成品名称' },
|
||||
]}
|
||||
columns={[
|
||||
{ key: 'finished_code', label: '成品编码' },
|
||||
{ key: 'finished_name', label: '成品名称', render: (c) => <span className="font-medium">{c.finished_name}</span> },
|
||||
{ key: 'finished_recipe', label: '成品配方' },
|
||||
{ key: 'arrow', label: '', render: () => <ChevronRight className="h-4 w-4 text-purple-600" /> },
|
||||
{ key: 'sub_product_code', label: '半成品编码' },
|
||||
{ key: 'sub_product_finished_name', label: '半成品名称', render: (c) => <span className="font-medium text-purple-600">{c.sub_product_finished_name || c.sub_product_name}</span> },
|
||||
{ key: 'sub_product_recipe', label: '半成品配方', render: (c) => c.sub_product_recipe || '-' },
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* BOM树弹窗 */}
|
||||
{selectedProduct && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={closeModal}>
|
||||
<div className="max-h-[85vh] w-[90vw] max-w-5xl overflow-hidden rounded-lg bg-white shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5 text-purple-600" />
|
||||
<h2 className="text-base font-semibold">
|
||||
BOM树穿透 - {productData?.productList?.find((p: any) => p.product_code === selectedProduct)?.product_name || selectedProduct}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">共{bomTree.length}个节点,最大{maxLevel}层</span>
|
||||
</div>
|
||||
<button onClick={closeModal} className="rounded p-1 hover:bg-muted">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[calc(85vh-60px)] overflow-auto p-4">
|
||||
{productLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : bomTree.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">该产品无配方数据</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">物料名称</th>
|
||||
<th className="pb-2 pr-3">编码</th>
|
||||
<th className="pb-2 pr-3">单位</th>
|
||||
<th className="pb-2 pr-3 text-right">理论量</th>
|
||||
<th className="pb-2 pr-3 text-right">领用量</th>
|
||||
<th className="pb-2 pr-3 text-right">理论金额</th>
|
||||
<th className="pb-2 pr-3 text-right">领用金额</th>
|
||||
<th className="pb-2 pr-3 text-right">差异金额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderTreeRows(bomTreeData, 0)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user