风险与内控管理优化: 异常账单服务端分页/筛选/收银员搜索/合计, 收银员TOP15按异常账单数排序, 双击跳转明细, MonthPicker靠右, 侧边栏底部间距

This commit is contained in:
freedakgmail
2026-08-01 21:28:15 +08:00
parent ac8e39e2f9
commit d724a565f5
60 changed files with 3154 additions and 2561 deletions
+288 -204
View File
@@ -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>
)
}