feat(intake): add edge banding library

This commit is contained in:
WuYongJie 2026-05-09 15:46:10 +08:00
parent 94f7c0a302
commit 2b204c6a98
8 changed files with 1864 additions and 85 deletions

View File

@ -0,0 +1,916 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Copy, Layers3, Plus, Ruler, Save, Search, Trash2 } from 'lucide-react'
import Button from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import {
buildEdgeBandingDisplayName,
buildEdgeBandingLibraryItems,
createEdgeBandingLibraryItem,
createEmptyEdgeBandingDraft,
parseBoardThicknessMmFromText,
persistEdgeBandingLibraryItems,
readEdgeBandingLibraryItems,
type EdgeBandingLibraryDraft,
type EdgeBandingLibraryItem,
} from '@/lib/edge-banding-library'
type EditorMode = 'view' | 'create' | 'edit' | 'createCategory'
type EdgeBandingLibraryCategory = {
id: string
key: string
label: string
name: string
edgeThicknessText: string
sortThickness: number
createdAt: string
}
const EDGE_BANDING_CATEGORY_STORAGE_KEY =
'formal-intake-edge-banding-library-categories'
function normalizeText(value: string) {
return value.trim().toLowerCase()
}
function normalizeDisplayText(value: string) {
return value.replace(/\s+/g, ' ').trim()
}
function formatThicknessLabel(value: string) {
const parsed = parseBoardThicknessMmFromText(value)
return parsed === null ? value.trim() || '-' : `${parsed}MM`
}
function parseEdgeBandingThicknessFromSpec(value: string) {
const matchedText = value.trim().match(/^(\d+(?:\.\d+)?)/)?.[1]
if (!matchedText) return null
const parsed = Number(matchedText)
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function getEdgeBandingThicknessTextFromSpec(value: string) {
return value.trim().match(/^(\d+(?:\.\d+)?)/)?.[1] ?? ''
}
function getEdgeBandingBaseName(item: Pick<EdgeBandingLibraryItem, 'name' | 'spec'>) {
const spec = normalizeDisplayText(item.spec)
const edgeThickness = getEdgeBandingThicknessTextFromSpec(spec)
let name = normalizeDisplayText(item.name)
if (spec) {
name = normalizeDisplayText(name.split(spec).join(' '))
}
if (edgeThickness && name.startsWith(edgeThickness)) {
name = normalizeDisplayText(name.slice(edgeThickness.length))
}
return name || '封边条'
}
function formatEdgeBandingGroupLabel(item: EdgeBandingLibraryItem) {
const edgeThickness = getEdgeBandingThicknessTextFromSpec(item.spec)
const name = getEdgeBandingBaseName(item)
return edgeThickness ? `${edgeThickness}${name}` : name
}
function getEdgeBandingGroupKeyFromParts(edgeThicknessText: string, name: string) {
const edgeThickness = parseEdgeBandingThicknessFromSpec(edgeThicknessText) ?? 999999
return `${edgeThickness}:${normalizeText(name)}`
}
function getEdgeBandingGroupKey(item: EdgeBandingLibraryItem) {
return getEdgeBandingGroupKeyFromParts(
getEdgeBandingThicknessTextFromSpec(item.spec),
getEdgeBandingBaseName(item),
)
}
function createNormalizedDraftItem(draft: EdgeBandingLibraryDraft) {
return createEdgeBandingLibraryItem(draft, { id: 'draft' })
}
function createEdgeBandingCategoryId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `edge-banding-category-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`
}
function createEdgeBandingCategory(
label: string,
options?: { id?: string; createdAt?: string },
): EdgeBandingLibraryCategory | null {
const normalizedLabel = normalizeDisplayText(label)
if (!normalizedLabel) return null
const edgeThicknessText = getEdgeBandingThicknessTextFromSpec(normalizedLabel)
const name = edgeThicknessText
? normalizeDisplayText(normalizedLabel.slice(edgeThicknessText.length)) ||
'PVC封边条'
: normalizedLabel
const sortThickness =
parseEdgeBandingThicknessFromSpec(edgeThicknessText) ?? 999999
return {
id: options?.id?.trim() || createEdgeBandingCategoryId(),
key: getEdgeBandingGroupKeyFromParts(edgeThicknessText, name),
label: edgeThicknessText ? `${edgeThicknessText}${name}` : name,
name,
edgeThicknessText,
sortThickness,
createdAt: options?.createdAt || new Date().toISOString(),
}
}
function readEdgeBandingCategories() {
if (typeof window === 'undefined') return []
try {
const rawValue = window.localStorage.getItem(EDGE_BANDING_CATEGORY_STORAGE_KEY)
if (!rawValue) return []
const parsedValue = JSON.parse(rawValue)
if (!Array.isArray(parsedValue)) return []
return parsedValue
.map((item) =>
createEdgeBandingCategory(String(item?.label ?? ''), {
id: typeof item?.id === 'string' ? item.id : undefined,
createdAt:
typeof item?.createdAt === 'string' ? item.createdAt : undefined,
}),
)
.filter((item): item is EdgeBandingLibraryCategory => item !== null)
} catch {
return []
}
}
function persistEdgeBandingCategories(categories: EdgeBandingLibraryCategory[]) {
if (typeof window === 'undefined') return
window.localStorage.setItem(
EDGE_BANDING_CATEGORY_STORAGE_KEY,
JSON.stringify(categories),
)
}
export default function EdgeBandingLibraryPanel() {
const initialItems = useMemo(() => readEdgeBandingLibraryItems(), [])
const initialCategories = useMemo(() => readEdgeBandingCategories(), [])
const [items, setItems] = useState(initialItems)
const [categories, setCategories] = useState(initialCategories)
const [queryName, setQueryName] = useState('')
const [queryThickness, setQueryThickness] = useState('')
const [appliedQueryName, setAppliedQueryName] = useState('')
const [appliedQueryThickness, setAppliedQueryThickness] = useState('')
const [categoryDraftName, setCategoryDraftName] = useState('')
const [selectedItemId, setSelectedItemId] = useState<string | null>(
initialItems[0]?.id ?? null,
)
const [editorMode, setEditorMode] = useState<EditorMode>('view')
const [editingItemId, setEditingItemId] = useState<string | null>(null)
const [editorDraft, setEditorDraft] = useState<EdgeBandingLibraryDraft>(
createEmptyEdgeBandingDraft(),
)
const [panelMessage, setPanelMessage] = useState('')
const normalizedItems = useMemo(() => buildEdgeBandingLibraryItems(items), [items])
useEffect(() => {
try {
persistEdgeBandingLibraryItems(items)
} catch {
console.warn('封边资料库本地缓存空间不足,当前修改暂未保存。')
}
}, [items])
useEffect(() => {
try {
persistEdgeBandingCategories(categories)
} catch {
console.warn('封边分类本地缓存空间不足,当前修改暂未保存。')
}
}, [categories])
const filteredItems = useMemo(() => {
const nameKeyword = normalizeText(appliedQueryName)
const queryThicknessValue = parseBoardThicknessMmFromText(appliedQueryThickness)
return normalizedItems.filter((item) => {
const matchesName =
!nameKeyword ||
[item.name, item.spec, buildEdgeBandingDisplayName(item)]
.map(normalizeText)
.some((value) => value.includes(nameKeyword))
const itemThickness = parseBoardThicknessMmFromText(item.compatibleThicknessMm)
const matchesThickness =
queryThicknessValue === null || itemThickness === queryThicknessValue
return matchesName && matchesThickness
})
}, [appliedQueryName, appliedQueryThickness, normalizedItems])
const sortedFilteredItems = useMemo(
() =>
[...filteredItems].sort((left, right) => {
const leftThickness =
parseBoardThicknessMmFromText(left.compatibleThicknessMm) ?? 0
const rightThickness =
parseBoardThicknessMmFromText(right.compatibleThicknessMm) ?? 0
if (leftThickness !== rightThickness) return leftThickness - rightThickness
return buildEdgeBandingDisplayName(left).localeCompare(
buildEdgeBandingDisplayName(right),
'zh-CN',
{ numeric: true },
)
}),
[filteredItems],
)
const edgeBandingGroups = useMemo(() => {
const groupMap = new Map<
string,
{
key: string
label: string
name: string
edgeThicknessText: string
sortThickness: number
items: EdgeBandingLibraryItem[]
}
>()
const nameKeyword = normalizeText(appliedQueryName)
const queryThicknessValue = parseBoardThicknessMmFromText(appliedQueryThickness)
categories.forEach((category) => {
if (
nameKeyword &&
![category.label, category.name].map(normalizeText).some((value) =>
value.includes(nameKeyword),
)
) {
return
}
if (queryThicknessValue !== null) return
groupMap.set(category.key, {
key: category.key,
label: category.label,
name: category.name,
edgeThicknessText: category.edgeThicknessText,
sortThickness: category.sortThickness,
items: [],
})
})
sortedFilteredItems.forEach((item) => {
const label = formatEdgeBandingGroupLabel(item)
const sortThickness = parseEdgeBandingThicknessFromSpec(item.spec) ?? 999999
const key = getEdgeBandingGroupKey(item)
const edgeThicknessText = getEdgeBandingThicknessTextFromSpec(item.spec)
const name = getEdgeBandingBaseName(item)
const currentGroup = groupMap.get(key)
if (currentGroup) {
currentGroup.items.push(item)
return
}
groupMap.set(key, {
key,
label,
name,
edgeThicknessText,
sortThickness,
items: [item],
})
})
return [...groupMap.values()].sort((left, right) => {
if (left.sortThickness !== right.sortThickness) {
return left.sortThickness - right.sortThickness
}
return left.label.localeCompare(right.label, 'zh-CN', { numeric: true })
})
}, [appliedQueryName, appliedQueryThickness, categories, sortedFilteredItems])
const [selectedGroupKey, setSelectedGroupKey] = useState<string | null>(null)
const activeGroup =
selectedGroupKey === null &&
(editorMode === 'create' || editorMode === 'createCategory')
? null
: edgeBandingGroups.find((group) => group.key === selectedGroupKey) ??
edgeBandingGroups[0] ??
null
const activeGroupItems = activeGroup?.items ?? []
const isCreatingCategory = editorMode === 'createCategory'
const categoryDraft = useMemo(
() => createEdgeBandingCategory(categoryDraftName),
[categoryDraftName],
)
const categoryCanSave = Boolean(categoryDraft)
const resolvedSelectedItemId = useMemo(() => {
if (editorMode === 'create') return null
if (activeGroupItems.length === 0) return null
const hasCurrentItem = activeGroupItems.some(
(item) => item.id === selectedItemId,
)
return hasCurrentItem ? selectedItemId : activeGroupItems[0].id
}, [activeGroupItems, editorMode, selectedItemId])
const selectedItem =
activeGroupItems.find((item) => item.id === resolvedSelectedItemId) ??
normalizedItems.find((item) => item.id === resolvedSelectedItemId) ??
null
const duplicateItem = useMemo(() => {
const normalizedDraft = createNormalizedDraftItem(editorDraft)
if (
!normalizedDraft.name ||
!normalizedDraft.spec ||
!normalizedDraft.unit ||
!normalizedDraft.unitPrice ||
!normalizedDraft.compatibleThicknessMm
) {
return null
}
const draftGroupKey = getEdgeBandingGroupKey(normalizedDraft)
return normalizedItems.find((item) => {
if (item.id === editingItemId) return false
if (getEdgeBandingGroupKey(item) !== draftGroupKey) return false
return (
item.name === normalizedDraft.name &&
item.spec === normalizedDraft.spec &&
item.unit === normalizedDraft.unit &&
item.unitPrice === normalizedDraft.unitPrice &&
item.compatibleThicknessMm === normalizedDraft.compatibleThicknessMm
)
})
}, [editingItemId, editorDraft, normalizedItems])
const draftCanSave =
Boolean(editorDraft.name.trim()) &&
Boolean(editorDraft.spec.trim()) &&
Boolean(editorDraft.unit.trim()) &&
Boolean(editorDraft.unitPrice.trim()) &&
parseBoardThicknessMmFromText(editorDraft.compatibleThicknessMm) !== null
function applySearch() {
setAppliedQueryName(queryName)
setAppliedQueryThickness(queryThickness)
}
function startCreate() {
setEditorMode('create')
setEditingItemId(null)
setSelectedItemId(null)
setEditorDraft({
...createEmptyEdgeBandingDraft(),
name: activeGroup?.name ?? '',
spec: activeGroup?.edgeThicknessText
? `${activeGroup.edgeThicknessText}*`
: '',
})
setCategoryDraftName('')
setPanelMessage('')
}
function startCreateCategory() {
setSelectedGroupKey(null)
setEditorMode('createCategory')
setEditingItemId(null)
setSelectedItemId(null)
setEditorDraft(createEmptyEdgeBandingDraft())
setCategoryDraftName('')
setPanelMessage('')
}
function selectItem(item: EdgeBandingLibraryItem) {
setSelectedGroupKey(getEdgeBandingGroupKey(item))
setSelectedItemId(item.id)
setEditorMode('edit')
setEditingItemId(item.id)
setEditorDraft({
name: item.name,
spec: item.spec,
unit: item.unit,
unitPrice: item.unitPrice,
compatibleThicknessMm: item.compatibleThicknessMm,
})
setPanelMessage('')
}
function copySelectedItem() {
if (!selectedItem) return
setSelectedGroupKey(getEdgeBandingGroupKey(selectedItem))
setSelectedItemId(null)
setEditorMode('create')
setEditingItemId(null)
setEditorDraft({
name: selectedItem.name,
spec: selectedItem.spec,
unit: selectedItem.unit,
unitPrice: selectedItem.unitPrice,
compatibleThicknessMm: selectedItem.compatibleThicknessMm,
})
setPanelMessage('已复制,请微调规格或单价后保存。')
}
function updateDraftField(key: keyof EdgeBandingLibraryDraft, value: string) {
setEditorDraft((current) => ({
...current,
[key]: value,
}))
setPanelMessage('')
}
function saveDraft() {
if (!draftCanSave) {
setPanelMessage('请先填完整规格名称、规格、单位、单价和适配板材厚度。')
return
}
if (duplicateItem) {
setPanelMessage('同一个分类里已有完全相同的封边条。')
return
}
const nextItem = createEdgeBandingLibraryItem(editorDraft, {
id: editingItemId ?? undefined,
})
setItems((current) => {
if (editingItemId) {
return current.map((item) => (item.id === editingItemId ? nextItem : item))
}
return [...current, nextItem]
})
setEditorMode('edit')
setEditingItemId(nextItem.id)
setSelectedGroupKey(getEdgeBandingGroupKey(nextItem))
setSelectedItemId(nextItem.id)
setEditorDraft({
name: nextItem.name,
spec: nextItem.spec,
unit: nextItem.unit,
unitPrice: nextItem.unitPrice,
compatibleThicknessMm: nextItem.compatibleThicknessMm,
})
setPanelMessage('封边资料已保存。')
}
function saveCategoryDraft() {
const nextCategory = categoryDraft
if (!nextCategory) {
setPanelMessage('请先填写分类名称。')
return
}
const existingGroup = edgeBandingGroups.find(
(group) => group.key === nextCategory.key,
)
if (existingGroup) {
setSelectedGroupKey(existingGroup.key)
setEditorMode('view')
setCategoryDraftName('')
setPanelMessage('分类已存在,已切换到该分类。')
return
}
setCategories((current) => [...current, nextCategory])
setSelectedGroupKey(nextCategory.key)
setEditorMode('view')
setEditingItemId(null)
setSelectedItemId(null)
setCategoryDraftName('')
setPanelMessage('封边分类已保存。')
}
function deleteSelectedItem() {
if (!editingItemId) return
const target = normalizedItems.find((item) => item.id === editingItemId)
if (!target) return
const confirmed = window.confirm(
`确认删除封边条【${buildEdgeBandingDisplayName(target)}】吗?`,
)
if (!confirmed) return
const fallbackItem = normalizedItems.find((item) => item.id !== editingItemId)
setItems((current) => current.filter((item) => item.id !== editingItemId))
setEditorMode('view')
setEditingItemId(null)
setEditorDraft(createEmptyEdgeBandingDraft())
setSelectedGroupKey(fallbackItem ? getEdgeBandingGroupKey(fallbackItem) : null)
setSelectedItemId(fallbackItem?.id ?? null)
setPanelMessage('封边资料已删除。')
}
function deleteActiveGroup() {
if (!activeGroup) return
const targetIds = new Set(activeGroup.items.map((item) => item.id))
const confirmed = window.confirm(
`确认删除分类【${activeGroup.label}】及其中 ${activeGroup.items.length} 条封边条吗?`,
)
if (!confirmed) return
const fallbackItem = normalizedItems.find((item) => !targetIds.has(item.id))
setCategories((current) =>
current.filter((category) => category.key !== activeGroup.key),
)
setItems((current) => current.filter((item) => !targetIds.has(item.id)))
setEditorMode('view')
setEditingItemId(null)
setEditorDraft(createEmptyEdgeBandingDraft())
setSelectedGroupKey(fallbackItem ? getEdgeBandingGroupKey(fallbackItem) : null)
setSelectedItemId(fallbackItem?.id ?? null)
setPanelMessage('封边分类已删除。')
}
const shouldShowEditor =
isCreatingCategory ||
editorMode === 'create' ||
(editorMode === 'edit' && Boolean(editingItemId))
return (
<div className="flex h-full min-h-0 flex-col gap-4">
<div className="rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-lg font-apple-semibold text-apple-label-primary">
封边资料库
</h3>
</div>
</div>
<div className="mt-4 grid gap-3 xl:grid-cols-[minmax(0,1fr)_240px_auto]">
<Input
label="规格名称 / 规格"
value={queryName}
onChange={(event) => setQueryName(event.target.value)}
placeholder="例如:PVC封边条 / 22×1"
/>
<Input
label="适配厚度"
value={queryThickness}
onChange={(event) => setQueryThickness(event.target.value)}
placeholder="例如:12MM"
/>
<div className="flex flex-wrap items-end justify-end gap-2">
<Button type="button" size="small" onClick={applySearch}>
<Search className="h-4 w-4" />
查询
</Button>
<Button type="button" variant="tinted" size="small" onClick={startCreate}>
<Plus className="h-4 w-4" />
新建封边
</Button>
</div>
</div>
</div>
<div className="grid min-h-0 flex-1 items-stretch gap-4 xl:grid-cols-[220px_minmax(0,1fr)_360px]">
<div className="flex min-h-0 flex-col overflow-hidden rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Layers3 className="h-4 w-4 text-apple-blue" />
<p className="text-sm font-apple-semibold text-apple-label-primary">
封边分类
</p>
</div>
<span className="text-xs text-apple-label-secondary">
{edgeBandingGroups.length} 类
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
type="button"
variant="tinted"
size="small"
className="!min-h-8 !px-2 !py-1 text-xs"
onClick={startCreateCategory}
>
<Plus className="h-3.5 w-3.5" />
新建分类
</Button>
<Button
type="button"
variant="plain"
size="small"
className="!min-h-8 !px-2 !py-1 text-xs"
onClick={deleteActiveGroup}
disabled={!activeGroup}
>
<Trash2 className="h-3.5 w-3.5" />
删除分类
</Button>
</div>
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
{edgeBandingGroups.length === 0 ? (
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/50 px-4 py-8 text-center text-xs leading-6 text-apple-label-secondary">
当前还没有匹配的封边分类。
</div>
) : (
edgeBandingGroups.map((group) => {
const isActive = activeGroup?.key === group.key
return (
<button
key={group.key}
type="button"
onClick={() => {
setSelectedGroupKey(group.key)
const firstItem = group.items[0]
if (firstItem) selectItem(firstItem)
}}
className={`w-full rounded-apple-lg border px-3 py-3 text-left transition ${
isActive
? 'border-apple-blue bg-apple-blue/10'
: 'border-apple-separator bg-apple-bg-secondary/40 hover:bg-apple-bg-secondary'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-apple-medium text-apple-label-primary">
{group.label}
</p>
<p className="mt-1 text-xs text-apple-label-secondary">
{group.items.length} 个适配规格
</p>
</div>
<span className="shrink-0 text-sm font-apple-semibold text-apple-label-primary">
{group.items.length}
</span>
</div>
</button>
)
})
)}
</div>
</div>
<div className="flex min-h-0 flex-col overflow-hidden rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-4">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs tracking-[0.18em] text-apple-label-tertiary">
EDGE BAND
</p>
<h4 className="mt-1 text-sm font-apple-semibold text-apple-label-primary">
{activeGroup?.label ?? '封边条列表'}
</h4>
</div>
<span className="rounded-full bg-apple-bg-secondary px-2.5 py-1 text-xs text-apple-label-secondary">
{activeGroupItems.length} 条
</span>
</div>
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
{activeGroupItems.length === 0 ? (
<div className="rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/50 px-4 py-8 text-center text-xs leading-6 text-apple-label-secondary">
请先在左侧选择封边分类,或新建封边条。
</div>
) : (
activeGroupItems.map((item) => {
const isActive = resolvedSelectedItemId === item.id
return (
<button
key={item.id}
type="button"
onClick={() => selectItem(item)}
className={`w-full rounded-apple-lg border px-4 py-3 text-left transition ${
isActive
? 'border-apple-blue bg-apple-blue/10'
: 'border-apple-separator bg-apple-bg-secondary/40 hover:bg-apple-bg-secondary'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-apple-medium text-apple-label-primary">
{buildEdgeBandingDisplayName(item)}
</p>
<p className="mt-1 text-xs text-apple-label-secondary">
适配 {formatThicknessLabel(item.compatibleThicknessMm)} 板材 / 单位{' '}
{item.unit}
</p>
</div>
<span className="shrink-0 rounded-full bg-apple-bg-secondary px-2.5 py-1 text-xs font-apple-semibold text-apple-label-primary">
¥{item.unitPrice}
</span>
</div>
</button>
)
})
)}
</div>
</div>
<div className="min-h-0 overflow-hidden rounded-apple-lg border border-apple-separator bg-apple-bg-primary p-3">
{shouldShowEditor ? (
<div className="flex h-full flex-col">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h4 className="text-base font-apple-semibold text-apple-label-primary">
{isCreatingCategory
? '新建分类'
: editorMode === 'create'
? '新建封边条'
: '编辑封边条'}
</h4>
</div>
<div className="flex flex-wrap gap-2">
{editorMode === 'edit' ? (
<>
<Button
type="button"
size="small"
variant="plain"
className="!min-h-8 !px-2.5 !py-1 text-xs"
onClick={deleteSelectedItem}
>
<Trash2 className="h-4 w-4" />
删除
</Button>
<Button
type="button"
size="small"
variant="plain"
className="!min-h-8 !px-2.5 !py-1 text-xs"
onClick={copySelectedItem}
>
<Copy className="h-4 w-4" />
复制
</Button>
</>
) : null}
<Button
type="button"
size="small"
className="!min-h-8 !px-3 !py-1 text-xs"
onClick={isCreatingCategory ? saveCategoryDraft : saveDraft}
disabled={isCreatingCategory ? !categoryCanSave : !draftCanSave}
>
<Save className="h-4 w-4" />
保存
</Button>
</div>
</div>
{isCreatingCategory ? (
<div className="mt-3 grid gap-2">
<Input
label="分类名称"
size="small"
containerClassName="space-y-1"
value={categoryDraftName}
onChange={(event) => setCategoryDraftName(event.target.value)}
placeholder="例如:0.6PVC封边条"
required
/>
</div>
) : (
<>
<div className="mt-2 grid gap-1.5">
<Input
label="规格名称"
size="small"
containerClassName="space-y-1"
value={editorDraft.name}
onChange={(event) =>
updateDraftField('name', event.target.value)
}
placeholder="例如:PVC封边条"
required
/>
<Input
label="规格"
size="small"
containerClassName="space-y-1"
value={editorDraft.spec}
onChange={(event) =>
updateDraftField('spec', event.target.value)
}
placeholder="例如:22×1"
required
/>
<Input
label="单位"
size="small"
containerClassName="space-y-1"
value={editorDraft.unit}
onChange={(event) =>
updateDraftField('unit', event.target.value)
}
placeholder="例如:米"
required
/>
<Input
label="单价"
size="small"
containerClassName="space-y-1"
value={editorDraft.unitPrice}
onChange={(event) =>
updateDraftField('unitPrice', event.target.value)
}
placeholder="例如:0.35"
required
/>
<Input
label="适配板材厚度"
size="small"
containerClassName="space-y-1"
value={editorDraft.compatibleThicknessMm}
onChange={(event) =>
updateDraftField('compatibleThicknessMm', event.target.value)
}
placeholder="例如:12MM"
required
/>
</div>
<div className="mt-2 grid grid-cols-3 gap-1.5">
{[
{
label: '显示名',
value: buildEdgeBandingDisplayName(editorDraft),
},
{
label: '板厚',
value: formatThicknessLabel(editorDraft.compatibleThicknessMm),
},
{
label: '单价',
value: editorDraft.unitPrice
? `¥${editorDraft.unitPrice}`
: '-',
},
].map((field) => (
<div
key={field.label}
className="rounded-apple-lg border border-apple-separator bg-apple-bg-secondary/60 p-1.5"
>
<p className="text-[10px] text-apple-label-secondary">
{field.label}
</p>
<p className="mt-0.5 break-all text-[11px] font-apple-medium leading-4 text-apple-label-primary">
{field.value}
</p>
</div>
))}
</div>
</>
)}
{panelMessage ? (
<p className="mt-3 text-xs text-apple-blue">{panelMessage}</p>
) : duplicateItem ? (
<p className="mt-3 text-xs text-[#d94b45]">
同一个分类里已有完全相同的“{buildEdgeBandingDisplayName(duplicateItem)}”。
</p>
) : null}
</div>
) : selectedItem ? (
<div className="flex h-full flex-col">
<div className="flex items-start gap-3">
<Ruler className="mt-1 h-5 w-5 text-apple-blue" />
<div>
<h4 className="text-lg font-apple-semibold text-apple-label-primary">
{buildEdgeBandingDisplayName(selectedItem)}
</h4>
<p className="mt-1 text-sm text-apple-label-secondary">
厚度 {formatThicknessLabel(selectedItem.compatibleThicknessMm)} / 单位{' '}
{selectedItem.unit} / 单价 ¥{selectedItem.unitPrice}
</p>
</div>
</div>
</div>
) : (
<div className="flex h-full min-h-[420px] items-center justify-center rounded-apple-lg border border-dashed border-apple-separator bg-apple-bg-secondary/40 p-6 text-center text-sm leading-7 text-apple-label-secondary">
请先新建一条封边资料。
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -28,6 +28,7 @@ import {
} from "lucide-react";
import MaterialLibraryClient from "@/app/asset-center/material-library/MaterialLibraryClient";
import AccessoryLibraryPanel from "@/app/intake/AccessoryLibraryPanel";
import EdgeBandingLibraryPanel from "@/app/intake/EdgeBandingLibraryPanel";
import PackagingMaterialLibraryPanel from "@/app/intake/PackagingMaterialLibraryPanel";
import RawMaterialLibraryPanel, {
ReferenceImageCostEstimator,
@ -204,6 +205,7 @@ type IntakeSection =
type IntakeEntryTab = "integrated" | "nonIntegrated";
type BomLibraryKey =
| "materialLibrary"
| "edgeBandingLibrary"
| "steelFrameLibrary"
| "packagingLibrary"
| "packagingMaterialLibrary";
@ -275,6 +277,8 @@ type IntakeWorkspaceTab =
label: string;
};
const MAX_WORKSPACE_TABS_PER_PAGE = 3;
type WorkbenchSku = {
id: string;
spuCode: string;
@ -373,6 +377,14 @@ const BOM_LIBRARY_OPTIONS: {
helper:
"这里展示资产中心板材资料库的复制视图,基材、饰面和字典规则保持同一套操作方式。",
},
{
key: "edgeBandingLibrary",
label: "封边资料库",
description:
"按板材厚度维护封边条,细节资料里的封边自动项会按对应厚度筛选并带出单价。",
helper:
"维护规格名称、规格、单位、单价和适配板材厚度;封边行仍可在细节资料里手动改单价。",
},
{
key: "steelFrameLibrary",
label: "钢架资料库",
@ -447,6 +459,52 @@ function createNewNonIntegratedProductWorkspaceTab(
};
}
function getWorkspaceTabPageKey(tab: IntakeWorkspaceTab) {
if (tab.kind === "pendingEntry") return `pending-entry:${tab.entryTab}`;
if (tab.kind === "progressDashboard") return "product-progress:dashboard";
if (tab.kind === "productPatentDashboard") return "product-patent:dashboard";
if (tab.kind === "productPatentLedger") {
const target = tab.target;
return [
"product-patent:ledger",
target?.patentNo?.trim() ?? "",
target?.spu?.trim() ?? "",
target?.patentName?.trim() ?? "",
].join(":");
}
if (tab.kind === "nonIntegratedProduct") {
return `non-integrated-product:${tab.productId}:${tab.mode}`;
}
if (tab.kind === "newNonIntegratedProduct") return "new-non-integrated";
if (tab.kind === "bomLibrary") return `bom-library:${tab.bomLibrary}`;
if (tab.kind === "rawMaterialLibrary") {
return `raw-material-library:${tab.rawMaterialLibrary}`;
}
return "reference-estimator";
}
function resolveWorkspaceTabsForOpen(
tabs: IntakeWorkspaceTab[],
incomingTab: IntakeWorkspaceTab,
): { tabs: IntakeWorkspaceTab[]; activeTab: IntakeWorkspaceTab } {
const incomingKey = getWorkspaceTabPageKey(incomingTab);
const samePageTabs = tabs.filter(
(tab) => getWorkspaceTabPageKey(tab) === incomingKey,
);
if (samePageTabs.length >= MAX_WORKSPACE_TABS_PER_PAGE) {
return {
tabs,
activeTab: samePageTabs[samePageTabs.length - 1] ?? incomingTab,
};
}
return {
tabs: [...tabs, incomingTab],
activeTab: incomingTab,
};
}
function getBomLibraryLabel(library: BomLibraryKey) {
return (
BOM_LIBRARY_OPTIONS.find((option) => option.key === library)?.label ??
@ -3873,6 +3931,16 @@ function BomDetail({
);
}
if (activeBomLibrary === "edgeBandingLibrary") {
return (
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
<div className="flex-1 overflow-y-auto">
<EdgeBandingLibraryPanel />
</div>
</div>
);
}
if (activeBomLibrary === "packagingMaterialLibrary") {
return (
<div className="flex h-full flex-col rounded-apple-xl border border-apple-separator bg-apple-bg-primary p-4 shadow-apple-xs">
@ -7128,11 +7196,17 @@ export default function IntakeClient(props: IntakeClientProps) {
mode: effectiveMode,
};
setWorkspaceTabs((current) => [
...current.filter((tab) => tab.kind !== "newNonIntegratedProduct"),
nextTab,
]);
syncWorkspaceState(nextTab);
const candidateTabs = workspaceTabs.filter(
(tab) => tab.kind !== "newNonIntegratedProduct",
);
const { activeTab } = resolveWorkspaceTabsForOpen(candidateTabs, nextTab);
setWorkspaceTabs((current) =>
resolveWorkspaceTabsForOpen(
current.filter((tab) => tab.kind !== "newNonIntegratedProduct"),
nextTab,
).tabs,
);
syncWorkspaceState(activeTab);
}
function createWorkspaceTabId(prefix: string, key: string) {
@ -7174,6 +7248,14 @@ export default function IntakeClient(props: IntakeClientProps) {
}
}
function openWorkspaceTab(nextTab: IntakeWorkspaceTab) {
const { activeTab } = resolveWorkspaceTabsForOpen(workspaceTabs, nextTab);
setWorkspaceTabs(
(current) => resolveWorkspaceTabsForOpen(current, nextTab).tabs,
);
syncWorkspaceState(activeTab);
}
function closeWorkspaceTab(tabId: string) {
const closingIndex = workspaceTabs.findIndex((tab) => tab.id === tabId);
if (closingIndex < 0) {
@ -7207,8 +7289,7 @@ export default function IntakeClient(props: IntakeClientProps) {
entryTab,
createWorkspaceTabId("pending-entry", entryTab),
);
setWorkspaceTabs((current) => [...current, nextTab]);
syncWorkspaceState(nextTab);
openWorkspaceTab(nextTab);
}
function openProgressDashboardWorkspace() {
@ -7218,8 +7299,7 @@ export default function IntakeClient(props: IntakeClientProps) {
kind: "progressDashboard",
label: "产品开发进度",
};
setWorkspaceTabs((current) => [...current, nextTab]);
syncWorkspaceState(nextTab);
openWorkspaceTab(nextTab);
}
function openProductWorkspace(
@ -7242,8 +7322,7 @@ export default function IntakeClient(props: IntakeClientProps) {
product,
mode: effectiveMode,
};
setWorkspaceTabs((current) => [...current, nextTab]);
syncWorkspaceState(nextTab);
openWorkspaceTab(nextTab);
}
function openNewNonIntegratedProductWorkspace() {
@ -7258,8 +7337,7 @@ export default function IntakeClient(props: IntakeClientProps) {
const nextTab = createNewNonIntegratedProductWorkspaceTab(
createWorkspaceTabId("new-non-integrated", "create"),
);
setWorkspaceTabs((current) => [...current, nextTab]);
syncWorkspaceState(nextTab);
openWorkspaceTab(nextTab);
}
function activateNonIntegratedListWorkspace() {
@ -7278,11 +7356,17 @@ export default function IntakeClient(props: IntakeClientProps) {
"nonIntegrated",
createWorkspaceTabId("pending-entry", "nonIntegrated"),
);
setWorkspaceTabs((current) => [
...current.filter((tab) => tab.kind !== "newNonIntegratedProduct"),
nextTab,
]);
syncWorkspaceState(nextTab);
const candidateTabs = workspaceTabs.filter(
(tab) => tab.kind !== "newNonIntegratedProduct",
);
const { activeTab } = resolveWorkspaceTabsForOpen(candidateTabs, nextTab);
setWorkspaceTabs((current) =>
resolveWorkspaceTabsForOpen(
current.filter((tab) => tab.kind !== "newNonIntegratedProduct"),
nextTab,
).tabs,
);
syncWorkspaceState(activeTab);
}
function closeNewNonIntegratedProductWorkspace(tabId: string) {
@ -7297,8 +7381,9 @@ export default function IntakeClient(props: IntakeClientProps) {
setWorkspaceTabs((current) => {
const withoutCancelledTab = current.filter((tab) => tab.id !== tabId);
const hasListTab = withoutCancelledTab.some((tab) => tab.id === listTab.id);
return hasListTab ? withoutCancelledTab : [listTab, ...withoutCancelledTab];
return withoutCancelledTab.some((tab) => tab.id === listTab.id)
? withoutCancelledTab
: resolveWorkspaceTabsForOpen(withoutCancelledTab, listTab).tabs;
});
syncWorkspaceState(listTab);
}
@ -7311,8 +7396,7 @@ export default function IntakeClient(props: IntakeClientProps) {
label: getBomLibraryLabel(library),
bomLibrary: library,
};
setWorkspaceTabs((current) => [...current, nextTab]);
syncWorkspaceState(nextTab);
openWorkspaceTab(nextTab);
}
function openRawMaterialLibraryWorkspace(library: RawMaterialLibraryKey) {
@ -7323,8 +7407,7 @@ export default function IntakeClient(props: IntakeClientProps) {
label: getRawMaterialLibraryLabel(library),
rawMaterialLibrary: library,
};
setWorkspaceTabs((current) => [...current, nextTab]);
syncWorkspaceState(nextTab);
openWorkspaceTab(nextTab);
}
function openReferenceEstimatorWorkspace() {
@ -7337,8 +7420,7 @@ export default function IntakeClient(props: IntakeClientProps) {
kind: "referenceEstimator",
label: REFERENCE_ESTIMATOR_WORKSPACE_LABEL,
};
setWorkspaceTabs((current) => [...current, nextTab]);
syncWorkspaceState(nextTab);
openWorkspaceTab(nextTab);
}
function openProductPatentDashboardWorkspace() {
@ -7348,8 +7430,7 @@ export default function IntakeClient(props: IntakeClientProps) {
kind: "productPatentDashboard",
label: PRODUCT_PATENT_DASHBOARD_LABEL,
};
setWorkspaceTabs((current) => [...current, nextTab]);
syncWorkspaceState(nextTab);
openWorkspaceTab(nextTab);
}
function openProductPatentLedgerWorkspace() {
@ -7359,8 +7440,7 @@ export default function IntakeClient(props: IntakeClientProps) {
kind: "productPatentLedger",
label: PRODUCT_PATENT_LEDGER_LABEL,
};
setWorkspaceTabs((current) => [...current, nextTab]);
syncWorkspaceState(nextTab);
openWorkspaceTab(nextTab);
}
function openProductPatentLedgerWorkspaceForTarget(
@ -7373,8 +7453,7 @@ export default function IntakeClient(props: IntakeClientProps) {
label: PRODUCT_PATENT_LEDGER_LABEL,
target,
};
setWorkspaceTabs((current) => [...current, nextTab]);
syncWorkspaceState(nextTab);
openWorkspaceTab(nextTab);
}
function openProductPatents() {

View File

@ -66,6 +66,13 @@ import {
findNonIntegratedBomSteelFrameOption,
type NonIntegratedBomSteelFrameOption,
} from "@/lib/non-integrated-bom-steel-materials";
import {
EDGE_BANDING_LIBRARY_CHANGED_EVENT,
buildNonIntegratedBomEdgeBandingOptions,
filterNonIntegratedBomEdgeBandingOptions,
parseBoardThicknessMmFromText,
type NonIntegratedBomEdgeBandingOption,
} from "@/lib/edge-banding-library";
import {
QUALITY_INSPECTION_COLUMN_GROUPS,
QUALITY_INSPECTION_DISPLAY_COLUMNS,
@ -6438,6 +6445,156 @@ function BomBoardMaterialField({
);
}
function BomEdgeBandingField({
rowId: _rowId,
value,
sourceLabel,
selectedItemId,
options,
readOnly = false,
onSelect,
}: {
rowId: string;
value: string;
sourceLabel: string;
selectedItemId?: string | null;
options: NonIntegratedBomEdgeBandingOption[];
readOnly?: boolean;
onSelect: (option: NonIntegratedBomEdgeBandingOption) => void;
}) {
const closeDropdownTimerRef = useRef<number | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [keyword, setKeyword] = useState(value);
const thicknessMm = useMemo(
() => parseBoardThicknessMmFromText(sourceLabel),
[sourceLabel],
);
const filteredOptions = useMemo(
() =>
filterNonIntegratedBomEdgeBandingOptions(
options,
keyword,
thicknessMm,
20,
),
[keyword, options, thicknessMm],
);
const panelVisible = !readOnly && isOpen;
const clearCloseDropdownTimer = useCallback(() => {
if (
closeDropdownTimerRef.current !== null &&
typeof window !== "undefined"
) {
window.clearTimeout(closeDropdownTimerRef.current);
closeDropdownTimerRef.current = null;
}
}, []);
useEffect(() => clearCloseDropdownTimer, [clearCloseDropdownTimer]);
useEffect(() => {
if (!isOpen) {
setKeyword(value);
}
}, [isOpen, value]);
const buildOptionMetaText = useCallback(
(option: NonIntegratedBomEdgeBandingOption) => {
const metaParts = [
`适配 ${option.compatibleThicknessLabel}`,
option.item.spec ? `规格 ${option.item.spec}` : "",
option.unit ? `单位 ${option.unit}` : "",
option.unitPrice ? `单价 ¥${option.unitPrice}` : "",
].filter(Boolean);
return metaParts.join(" | ");
},
[],
);
const handleSelectOption = useCallback(
(option: NonIntegratedBomEdgeBandingOption) => {
clearCloseDropdownTimer();
onSelect(option);
setKeyword(option.selectionLabel);
setIsOpen(false);
},
[clearCloseDropdownTimer, onSelect],
);
return (
<div className="relative min-w-[260px]">
<input
type="text"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onFocus={() => {
clearCloseDropdownTimer();
setIsOpen(true);
}}
onBlur={() => {
clearCloseDropdownTimer();
if (typeof window !== "undefined") {
closeDropdownTimerRef.current = window.setTimeout(() => {
setIsOpen(false);
setKeyword(value);
}, 120);
}
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
clearCloseDropdownTimer();
setIsOpen(false);
setKeyword(value);
}
}}
placeholder={
thicknessMm === null ? "未识别板材厚度" : "搜索对应厚度封边条"
}
readOnly={readOnly}
title={value}
className="h-8 w-full rounded border border-apple-separator bg-[#eef1f4] px-2 py-1 text-xs text-apple-label-primary outline-none placeholder:text-apple-label-tertiary"
/>
{panelVisible ? (
<div className="absolute left-0 top-full z-30 mt-1 max-h-72 w-[560px] max-w-[82vw] overflow-auto rounded-lg border border-apple-separator bg-apple-bg-primary shadow-apple-lg">
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => {
const isSelected = selectedItemId === option.id;
return (
<button
key={option.id}
type="button"
className={`block w-full border-b border-apple-separator px-3 py-2 text-left last:border-b-0 hover:bg-apple-fill-quaternary ${
isSelected ? "bg-apple-blue/10" : ""
}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelectOption(option)}
title={option.selectionLabel}
>
<div className="break-all text-xs font-medium leading-5 text-apple-label-primary">
{option.selectionLabel}
</div>
<div className="mt-1 break-all text-[11px] leading-4 text-apple-label-secondary">
{buildOptionMetaText(option)}
</div>
</button>
);
})
) : (
<div className="px-3 py-3 text-xs text-apple-label-secondary">
{thicknessMm === null
? "当前板材名称里还没有识别出厚度。"
: `没有找到适配 ${thicknessMm}MM 的封边条。`}
</div>
)}
</div>
) : null}
</div>
);
}
function BomSteelMaterialField({
rowId: _rowId,
value,
@ -7168,6 +7325,9 @@ export default function NonIntegratedDetailClient({
const [steelFrameOptions, setSteelFrameOptions] = useState<
NonIntegratedBomSteelFrameOption[]
>([]);
const [edgeBandingOptions, setEdgeBandingOptions] = useState<
NonIntegratedBomEdgeBandingOption[]
>([]);
const [, setSaveMessage] = useState("");
const [, setSaveMessageType] = useState<"success" | "error">(
"success",
@ -7318,6 +7478,32 @@ export default function NonIntegratedDetailClient({
};
}, []);
useEffect(() => {
const loadEdgeBandingOptions = () => {
setEdgeBandingOptions(buildNonIntegratedBomEdgeBandingOptions());
};
loadEdgeBandingOptions();
if (typeof window === "undefined") return;
window.addEventListener("focus", loadEdgeBandingOptions);
window.addEventListener("storage", loadEdgeBandingOptions);
window.addEventListener(
EDGE_BANDING_LIBRARY_CHANGED_EVENT,
loadEdgeBandingOptions,
);
return () => {
window.removeEventListener("focus", loadEdgeBandingOptions);
window.removeEventListener("storage", loadEdgeBandingOptions);
window.removeEventListener(
EDGE_BANDING_LIBRARY_CHANGED_EVENT,
loadEdgeBandingOptions,
);
};
}, []);
const shouldAllowLooseBoardMaterialMatch = useCallback((value: string) => {
const normalizedValue = value.trim();
if (!normalizedValue) {
@ -7760,6 +7946,7 @@ export default function NonIntegratedDetailClient({
category,
rows,
selectedBomSyntheticUnitPriceOverrides,
edgeBandingOptions,
),
packageGroups:
category === "包材"
@ -7771,6 +7958,7 @@ export default function NonIntegratedDetailClient({
category,
groupRows,
selectedBomSyntheticUnitPriceOverrides,
edgeBandingOptions,
);
return {
@ -7807,7 +7995,7 @@ export default function NonIntegratedDetailClient({
),
}),
),
[selectedBomRows, selectedBomSyntheticUnitPriceOverrides],
[edgeBandingOptions, selectedBomRows, selectedBomSyntheticUnitPriceOverrides],
);
const selectedBomTotalPrice = useMemo(
() =>
@ -10757,6 +10945,83 @@ export default function NonIntegratedDetailClient({
setSaveMessageType("success");
}
function updateBomEdgeBandingSelection(
syntheticUnitPriceOverrideKey: string,
syntheticSelectionOverrideKey: string,
edgeBandingItemId: string,
) {
if (isReadOnly || !selectedBomStyleKey) return;
const priceOverrideKey = syntheticUnitPriceOverrideKey.trim();
const selectionOverrideKey = syntheticSelectionOverrideKey.trim();
const normalizedItemId = edgeBandingItemId.trim();
if (!priceOverrideKey || !selectionOverrideKey) return;
setBomDetails((current) => {
const sharedDetail =
current[selectedBomStyleKey] ?? createEmptyBomDetailState();
const sharedOverrides = sharedDetail.syntheticUnitPriceOverrides ?? {};
if (!selectedBomSkuKey) {
const nextSharedOverrides = {
...sharedOverrides,
[selectionOverrideKey]: normalizedItemId,
};
delete nextSharedOverrides[priceOverrideKey];
if (!normalizedItemId) {
delete nextSharedOverrides[selectionOverrideKey];
}
return {
...current,
[selectedBomStyleKey]: {
...sharedDetail,
syntheticUnitPriceOverrides: nextSharedOverrides,
},
};
}
const currentOverrideDetail =
current[selectedBomSkuKey] ?? createEmptyBomDetailState();
const currentOverrideOverrides =
currentOverrideDetail.syntheticUnitPriceOverrides ?? {};
const sharedSelectionValue =
sharedOverrides[selectionOverrideKey]?.trim() || "";
const nextOverrideOverrides = {
...currentOverrideOverrides,
[selectionOverrideKey]: normalizedItemId,
};
delete nextOverrideOverrides[priceOverrideKey];
if (!normalizedItemId || normalizedItemId === sharedSelectionValue) {
delete nextOverrideOverrides[selectionOverrideKey];
}
if (
currentOverrideDetail.rows.length === 0 &&
currentOverrideDetail.hiddenRowIds.length === 0 &&
Object.keys(nextOverrideOverrides).length === 0
) {
const { [selectedBomSkuKey]: _removedOverride, ...rest } = current;
return rest;
}
return {
...current,
[selectedBomSkuKey]: {
...currentOverrideDetail,
syntheticUnitPriceOverrides: nextOverrideOverrides,
},
};
});
setSaveMessage("");
setSaveMessageType("success");
}
function handleDeleteBomRow(rowId: string) {
if (isReadOnly || !selectedBomStyleKey) return;
@ -12734,6 +12999,7 @@ export default function NonIntegratedDetailClient({
skuCode: selectedSupplementSku?.skuCode?.trim() ?? "",
styleKey: selectedBomStyleKey?.trim() ?? "",
totalPrice: selectedBomTotalPrice,
edgeBandingOptions,
});
downloadNonIntegratedBomWorkbook(
@ -19286,6 +19552,16 @@ function renderFactoryQualityWorkspace() {
isSyntheticRow
? row.syntheticUnitPriceOverrideKey
: null;
const syntheticSelectionOverrideKey =
isSyntheticRow
? row.syntheticSelectionOverrideKey
: null;
const syntheticSelectedItemId =
syntheticSelectionOverrideKey
? selectedBomSyntheticUnitPriceOverrides[
syntheticSelectionOverrideKey
]?.trim() || null
: null;
return (
<tr
@ -19420,6 +19696,41 @@ function renderFactoryQualityWorkspace() {
)
}
/>
) : category ===
"板材" &&
syntheticKind ===
"edge-banding" &&
column.key ===
"material" &&
isSyntheticRow ? (
<BomEdgeBandingField
rowId={row.id}
value={cellValue}
sourceLabel={
row.syntheticSourceLabel ??
cellValue
}
selectedItemId={
syntheticSelectedItemId
}
readOnly={
isReadOnly
}
options={
edgeBandingOptions
}
onSelect={(
option,
) =>
updateBomEdgeBandingSelection(
syntheticUnitPriceOverrideKey ??
"edge-banding",
syntheticSelectionOverrideKey ??
"",
option.id,
)
}
/>
) : category ===
"板材" &&
column.key ===

View File

@ -0,0 +1,30 @@
import {
buildNonIntegratedBomEdgeBandingOptions,
createEdgeBandingLibraryItem,
parseBoardThicknessMmFromText,
} from '@/lib/edge-banding-library'
describe('edge-banding-library', () => {
it('parses board thickness from board and thickness labels', () => {
expect(parseBoardThicknessMmFromText('12密度板')).toBe(12)
expect(parseBoardThicknessMmFromText('15MM')).toBe(15)
expect(parseBoardThicknessMmFromText('18ENF级生态板')).toBe(18)
})
it('builds selectable edge banding options from saved items', () => {
const item = createEdgeBandingLibraryItem({
name: 'PVC封边条',
spec: '22×1',
unit: '米',
unitPrice: '0.35',
compatibleThicknessMm: '12MM',
})
const options = buildNonIntegratedBomEdgeBandingOptions([item])
expect(options).toHaveLength(1)
expect(options[0]?.selectionLabel).toBe('22×1 PVC封边条')
expect(options[0]?.compatibleThicknessMm).toBe(12)
expect(options[0]?.unitPrice).toBe('0.35')
})
})

View File

@ -0,0 +1,308 @@
export const EDGE_BANDING_LIBRARY_STORAGE_KEY =
'formal-intake-edge-banding-library-items'
export const EDGE_BANDING_LIBRARY_CHANGED_EVENT =
'formal-intake-edge-banding-library:changed'
export type EdgeBandingLibraryItem = {
id: string
name: string
spec: string
unit: string
unitPrice: string
compatibleThicknessMm: string
updatedAt: string
}
export type EdgeBandingLibraryDraft = Omit<EdgeBandingLibraryItem, 'id' | 'updatedAt'>
export interface NonIntegratedBomEdgeBandingOption {
id: string
item: EdgeBandingLibraryItem
displayName: string
selectionLabel: string
compatibleThicknessMm: number
compatibleThicknessLabel: string
unit: string
unitPrice: string
searchText: string
}
type EdgeBandingLibraryPersistedValue =
| EdgeBandingLibraryItem[]
| {
items?: EdgeBandingLibraryItem[]
}
function normalizeText(value?: string | null) {
return (value ?? '').trim().toLowerCase()
}
function normalizeDisplayText(value?: string | null) {
return (value ?? '').replace(/\s+/g, ' ').trim()
}
export function parseEdgeBandingNumber(value?: string | number | null) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value !== 'string') return null
const normalized = value.trim().replace(/,/g, '')
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
export function formatEdgeBandingNumber(value: number | null, digits = 3) {
if (value === null || !Number.isFinite(value)) return ''
return value.toFixed(digits).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
}
export function parseBoardThicknessMmFromText(value?: string | number | null) {
if (typeof value === 'number') {
return Number.isFinite(value) && value > 0 ? value : null
}
if (typeof value !== 'string') return null
const normalized = value
.trim()
.replace(/[Mm]/g, 'm')
.replace(/毫米/g, 'mm')
.replace(/厚度/g, '厚')
if (!normalized) return null
const explicitMatch =
normalized.match(/(?:^|[^\d.])(\d+(?:\.\d+)?)\s*(?:mm|MM|Mm|mM|厚)/) ??
normalized.match(/(?:^|[^\d.])(\d+(?:\.\d+)?)(?=\s*(?:密度板|刨花板|生态板|三合板|板))/)
const matchedText = explicitMatch?.[1] ?? normalized.match(/^(\d+(?:\.\d+)?)/)?.[1]
const parsed = parseEdgeBandingNumber(matchedText)
return parsed !== null && parsed > 0 ? parsed : null
}
export function createEmptyEdgeBandingDraft(): EdgeBandingLibraryDraft {
return {
name: '',
spec: '',
unit: '米',
unitPrice: '',
compatibleThicknessMm: '',
}
}
function normalizeEdgeBandingDraft(
draft: Partial<EdgeBandingLibraryDraft>,
): EdgeBandingLibraryDraft {
const parsedThickness = parseBoardThicknessMmFromText(draft.compatibleThicknessMm)
return {
name: normalizeDisplayText(draft.name),
spec: normalizeDisplayText(draft.spec),
unit: normalizeDisplayText(draft.unit) || '米',
unitPrice: normalizeDisplayText(draft.unitPrice),
compatibleThicknessMm: formatEdgeBandingNumber(parsedThickness),
}
}
export function createEdgeBandingLibraryItemId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `edge-banding-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
export function createEdgeBandingLibraryItem(
draft: Partial<EdgeBandingLibraryDraft>,
options?: {
id?: string
updatedAt?: string
},
): EdgeBandingLibraryItem {
return {
id: options?.id?.trim() || createEdgeBandingLibraryItemId(),
...normalizeEdgeBandingDraft(draft),
updatedAt: options?.updatedAt || new Date().toISOString(),
}
}
export function buildEdgeBandingLibraryItems(items: EdgeBandingLibraryItem[]) {
return items
.map((item) => {
if (!item || typeof item !== 'object') return null
const nextItem = createEdgeBandingLibraryItem(item, {
id: typeof item.id === 'string' ? item.id : undefined,
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
})
const unitPrice = parseEdgeBandingNumber(nextItem.unitPrice)
const thickness = parseBoardThicknessMmFromText(nextItem.compatibleThicknessMm)
if (
!nextItem.name ||
!nextItem.spec ||
!nextItem.unit ||
unitPrice === null ||
thickness === null
) {
return null
}
return nextItem
})
.filter((item): item is EdgeBandingLibraryItem => item !== null)
}
function readPersistedEdgeBandingLibraryValue(): EdgeBandingLibraryPersistedValue | null {
if (typeof window === 'undefined') return null
try {
const rawValue = window.localStorage.getItem(EDGE_BANDING_LIBRARY_STORAGE_KEY)
if (!rawValue) return null
const parsedValue = JSON.parse(rawValue)
if (Array.isArray(parsedValue)) return parsedValue
if (parsedValue && typeof parsedValue === 'object') return parsedValue
} catch {
return null
}
return null
}
function resolvePersistedEdgeBandingItems(
persistedValue: EdgeBandingLibraryPersistedValue | null,
) {
if (Array.isArray(persistedValue)) return persistedValue
if (Array.isArray(persistedValue?.items)) return persistedValue.items
return []
}
export function readEdgeBandingLibraryItems() {
return buildEdgeBandingLibraryItems(
resolvePersistedEdgeBandingItems(readPersistedEdgeBandingLibraryValue()),
)
}
export function persistEdgeBandingLibraryItems(items: EdgeBandingLibraryItem[]) {
if (typeof window === 'undefined') return
const normalizedItems = buildEdgeBandingLibraryItems(items)
window.localStorage.setItem(
EDGE_BANDING_LIBRARY_STORAGE_KEY,
JSON.stringify(normalizedItems),
)
window.dispatchEvent(new Event(EDGE_BANDING_LIBRARY_CHANGED_EVENT))
}
export function buildEdgeBandingDisplayName(
item: Pick<EdgeBandingLibraryItem, 'name' | 'spec'>,
) {
const name = normalizeDisplayText(item.name) || '未命名封边'
const spec = normalizeDisplayText(item.spec)
if (!spec) return name
const nameWithoutSpec = normalizeDisplayText(name.split(spec).join(' '))
return `${spec} ${nameWithoutSpec || name}`
}
export function buildEdgeBandingSelectionLabel(item: EdgeBandingLibraryItem) {
return buildEdgeBandingDisplayName(item)
}
export function buildNonIntegratedBomEdgeBandingOptions(
items = readEdgeBandingLibraryItems(),
) {
return items
.map<NonIntegratedBomEdgeBandingOption | null>((item) => {
const compatibleThicknessMm = parseBoardThicknessMmFromText(
item.compatibleThicknessMm,
)
if (compatibleThicknessMm === null) return null
const displayName = buildEdgeBandingDisplayName(item)
const selectionLabel = buildEdgeBandingSelectionLabel(item)
const compatibleThicknessLabel = `${formatEdgeBandingNumber(compatibleThicknessMm)}MM`
return {
id: item.id,
item,
displayName,
selectionLabel,
compatibleThicknessMm,
compatibleThicknessLabel,
unit: item.unit.trim() || '米',
unitPrice: item.unitPrice.trim(),
searchText: [
selectionLabel,
item.name,
item.spec,
item.unit,
item.unitPrice,
compatibleThicknessLabel,
]
.join(' ')
.toLowerCase(),
}
})
.filter((option): option is NonIntegratedBomEdgeBandingOption => option !== null)
.sort((left, right) => {
if (left.compatibleThicknessMm !== right.compatibleThicknessMm) {
return left.compatibleThicknessMm - right.compatibleThicknessMm
}
return left.selectionLabel.localeCompare(right.selectionLabel, 'zh-CN', {
numeric: true,
})
})
}
export function filterNonIntegratedBomEdgeBandingOptions(
options: NonIntegratedBomEdgeBandingOption[],
keyword: string,
thicknessMm?: number | null,
limit = 20,
) {
const normalizedKeyword = normalizeText(keyword)
return options
.filter((option) => {
const matchesThickness =
thicknessMm === undefined ||
thicknessMm === null ||
option.compatibleThicknessMm === thicknessMm
const matchesKeyword =
!normalizedKeyword || option.searchText.includes(normalizedKeyword)
return matchesThickness && matchesKeyword
})
.slice(0, limit)
}
export function findNonIntegratedBomEdgeBandingOption(
options: NonIntegratedBomEdgeBandingOption[],
sourceText: string,
selectedItemId?: string | null,
) {
const thicknessMm = parseBoardThicknessMmFromText(sourceText)
const selectedOption = selectedItemId
? options.find((option) => option.id === selectedItemId)
: null
if (
selectedOption &&
(thicknessMm === null || selectedOption.compatibleThicknessMm === thicknessMm)
) {
return selectedOption
}
if (thicknessMm === null) return null
return (
options.find((option) => option.compatibleThicknessMm === thicknessMm) ?? null
)
}

View File

@ -23,6 +23,7 @@ import {
buildNonIntegratedBomSteelFrameOptions,
findNonIntegratedBomSteelFrameOption,
} from '@/lib/non-integrated-bom-steel-materials'
import type { NonIntegratedBomEdgeBandingOption } from '@/lib/edge-banding-library'
const EXCEL_MIME_TYPE =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
@ -54,6 +55,7 @@ type NonIntegratedBomWorkbookInput = {
skuCode?: string
styleKey?: string
totalPrice?: number | null
edgeBandingOptions?: NonIntegratedBomEdgeBandingOption[]
}
type NonIntegratedBomExportColumnKey =
@ -416,6 +418,7 @@ function appendSummaryRow(
function getResolvedSections(
sections: NonIntegratedBomExportSection[],
edgeBandingOptions: NonIntegratedBomEdgeBandingOption[] = [],
): {
category: NonIntegratedBomCategory
rows: NonIntegratedBomRow[]
@ -447,6 +450,7 @@ function getResolvedSections(
category,
rows,
syntheticUnitPriceOverridesByCategory.get(category) ?? {},
edgeBandingOptions,
)
const packageGroups =
category === '包材'
@ -458,6 +462,7 @@ function getResolvedSections(
category,
groupRows,
syntheticUnitPriceOverridesByCategory.get(category) ?? {},
edgeBandingOptions,
)
return {
@ -485,6 +490,7 @@ function getResolvedSections(
export async function createNonIntegratedBomWorkbook({
sections,
totalPrice = null,
edgeBandingOptions = [],
}: NonIntegratedBomWorkbookInput) {
const ExcelJSImport = await import('exceljs')
const ExcelJS = ExcelJSImport as typeof import('exceljs')
@ -499,7 +505,7 @@ export async function createNonIntegratedBomWorkbook({
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
worksheet.columns = BOM_EXPORT_COLUMNS.map((column) => ({ width: column.width }))
const resolvedSections = getResolvedSections(sections)
const resolvedSections = getResolvedSections(sections, edgeBandingOptions)
const exportBlocks = buildExportBlocks(resolvedSections)
const materialSubtotal = exportBlocks.reduce(
(sum, block) => sum + (block.treatAsLabor ? 0 : block.subtotal),

View File

@ -5,6 +5,7 @@ import {
isNonIntegratedBomManualField,
isNonIntegratedBomSyntheticRow,
} from '@/lib/non-integrated-bom-template'
import type { NonIntegratedBomEdgeBandingOption } from '@/lib/edge-banding-library'
describe('non-integrated bom template helpers', () => {
it('adds edge-banding synthetic row when board rows have edge counts', () => {
@ -41,52 +42,54 @@ describe('non-integrated bom template helpers', () => {
})
it('splits board edge-banding rows by board material group', () => {
const boardRows: Parameters<typeof buildNonIntegratedBomDisplayRows>[1] = [
{
id: 'board-12',
category: '板材',
partCode: 'PJ001',
partImage: '',
partName: '侧板',
structure: '主体',
material: '12密度板 | 冰川白石纹色 | 单贴',
process: '',
remark: '',
specSize: '',
length: '1000',
width: '500',
longEdgeCount: '2',
shortEdgeCount: '0',
unit: '块',
factor: '1.05',
unitPrice: '16.16',
unitWeight: '6.73',
quantity: '1',
},
{
id: 'board-15',
category: '板材',
partCode: 'PJ002',
partImage: '',
partName: '面板',
structure: '主体',
material: '15密度板 | 北美原木纹色 | 单贴',
process: '',
remark: '',
specSize: '',
length: '1000',
width: '600',
longEdgeCount: '0',
shortEdgeCount: '2',
unit: '块',
factor: '1.05',
unitPrice: '18',
unitWeight: '7',
quantity: '1',
},
]
const displayRows = buildNonIntegratedBomDisplayRows(
'板材',
[
{
id: 'board-12',
category: '板材',
partCode: 'PJ001',
partImage: '',
partName: '侧板',
structure: '主体',
material: '12密度板 | 冰川白石纹色 | 单贴',
process: '',
remark: '',
specSize: '',
length: '1000',
width: '500',
longEdgeCount: '2',
shortEdgeCount: '0',
unit: '块',
factor: '1.05',
unitPrice: '16.16',
unitWeight: '6.73',
quantity: '1',
},
{
id: 'board-15',
category: '板材',
partCode: 'PJ002',
partImage: '',
partName: '面板',
structure: '主体',
material: '15密度板 | 北美原木纹色 | 单贴',
process: '',
remark: '',
specSize: '',
length: '1000',
width: '600',
longEdgeCount: '0',
shortEdgeCount: '2',
unit: '块',
factor: '1.05',
unitPrice: '18',
unitWeight: '7',
quantity: '1',
},
],
boardRows,
{
'edge-banding:12密度板': '0.3',
'edge-banding:15密度板': '0.6',
@ -108,6 +111,102 @@ describe('non-integrated bom template helpers', () => {
expect(getNonIntegratedBomDisplayRowTotalPrice(edgeRows[1]!)).toBeCloseTo(0.756, 6)
})
it('uses edge-banding library matches by board thickness', () => {
const edgeBandingOptions: NonIntegratedBomEdgeBandingOption[] = [
{
id: 'edge-12-a',
item: {
id: 'edge-12-a',
name: '12默认封边条',
spec: '22×1',
unit: '米',
unitPrice: '0.35',
compatibleThicknessMm: '12',
updatedAt: '2026-05-09T00:00:00.000Z',
},
displayName: '12默认封边条 22×1',
selectionLabel: '12默认封边条 22×1',
compatibleThicknessMm: 12,
compatibleThicknessLabel: '12MM',
unit: '米',
unitPrice: '0.35',
searchText: '12默认封边条 22×1 12mm',
},
{
id: 'edge-12-b',
item: {
id: 'edge-12-b',
name: '12加厚封边条',
spec: '23×1.2',
unit: '米',
unitPrice: '0.5',
compatibleThicknessMm: '12',
updatedAt: '2026-05-09T00:00:00.000Z',
},
displayName: '12加厚封边条 23×1.2',
selectionLabel: '12加厚封边条 23×1.2',
compatibleThicknessMm: 12,
compatibleThicknessLabel: '12MM',
unit: '米',
unitPrice: '0.5',
searchText: '12加厚封边条 23×1.2 12mm',
},
]
const boardRows = [
{
id: 'board-12',
category: '板材',
partCode: 'PJ001',
partImage: '',
partName: '侧板',
structure: '主体',
material: '12密度板',
process: '',
remark: '',
specSize: '',
length: '1000',
width: '500',
longEdgeCount: '2',
shortEdgeCount: '0',
unit: '块',
factor: '1',
unitPrice: '10',
unitWeight: '',
quantity: '1',
},
]
const displayRows = buildNonIntegratedBomDisplayRows(
'板材',
boardRows,
{
'edge-banding:12密度板:itemId': 'edge-12-b',
},
edgeBandingOptions,
)
const edgeRow = displayRows.find(isNonIntegratedBomSyntheticRow)
expect(edgeRow?.material).toBe('12加厚封边条 23×1.2')
expect(edgeRow?.unitPrice).toBe('0.5')
expect(getNonIntegratedBomDisplayRowTotalPrice(edgeRow!)).toBeCloseTo(1.05, 6)
const manualRows = buildNonIntegratedBomDisplayRows(
'板材',
boardRows,
{
'edge-banding:12密度板:itemId': 'edge-12-b',
'edge-banding:12密度板': '0.8',
},
edgeBandingOptions,
)
const manualEdgeRow = manualRows.find(isNonIntegratedBomSyntheticRow)
expect(manualEdgeRow?.material).toBe('12加厚封边条 23×1.2')
expect(manualEdgeRow?.unitPrice).toBe('0.8')
})
it('applies board synthetic rows by material category', () => {
const displayRows = buildNonIntegratedBomDisplayRows(
'板材',
@ -366,8 +465,8 @@ describe('non-integrated bom template helpers', () => {
it('marks only template-entered fields as manual input cells', () => {
expect(isNonIntegratedBomManualField('板材', 'partCode')).toBe(true)
expect(isNonIntegratedBomManualField('板材', 'material')).toBe(true)
expect(isNonIntegratedBomManualField('板材', 'partImage')).toBe(false)
expect(isNonIntegratedBomManualField('板材', 'structure')).toBe(false)
expect(isNonIntegratedBomManualField('板材', 'partImage')).toBe(true)
expect(isNonIntegratedBomManualField('板材', 'structure')).toBe(true)
expect(isNonIntegratedBomManualField('钢架', 'width')).toBe(false)
expect(isNonIntegratedBomManualField('钢架', 'material')).toBe(true)
expect(isNonIntegratedBomManualField('配件', 'partImage')).toBe(true)

View File

@ -5,6 +5,10 @@ import {
type NonIntegratedBomCategory,
type NonIntegratedBomPackageSubcategory,
} from '@/lib/non-integrated-bom-schema'
import {
findNonIntegratedBomEdgeBandingOption,
type NonIntegratedBomEdgeBandingOption,
} from '@/lib/edge-banding-library'
export type NonIntegratedBomTemplateFieldKey =
| 'partCode'
@ -29,6 +33,8 @@ export type NonIntegratedBomSyntheticUnitPriceOverrides = Record<string, string>
export type NonIntegratedBomSyntheticRow = NonIntegratedBomRow & {
syntheticKind: NonIntegratedBomSyntheticRowKind
syntheticUnitPriceOverrideKey: string
syntheticSelectionOverrideKey?: string
syntheticSourceLabel?: string
resolvedPricingQuantity: number | null
resolvedEdgeMeters: number | null
resolvedTotalPrice: number | null
@ -40,6 +46,7 @@ const BOARD_EDGE_FACTOR = 1.05
const BOARD_EDGE_UNIT_PRICE = 0.2
const BOARD_EDGE_SYNTHETIC_ID_PREFIX = '__synthetic-edge-banding__'
const BOARD_EDGE_SYNTHETIC_OVERRIDE_KEY_PREFIX = 'edge-banding:'
const BOARD_EDGE_SYNTHETIC_SELECTION_OVERRIDE_KEY_SUFFIX = ':itemId'
const BOARD_WOOD_PAINT_SYNTHETIC_ID = '__synthetic-board-wood-paint__'
const BOARD_WOOD_PAINT_OVERRIDE_KEY = 'wood-paint'
const STEEL_SPRAY_RATE = 0.06
@ -303,6 +310,10 @@ function getBoardEdgeSyntheticOverrideKey(groupLabel: string) {
return `${BOARD_EDGE_SYNTHETIC_OVERRIDE_KEY_PREFIX}${groupLabel}`
}
function getBoardEdgeSyntheticSelectionOverrideKey(overrideKey: string) {
return `${overrideKey}${BOARD_EDGE_SYNTHETIC_SELECTION_OVERRIDE_KEY_SUFFIX}`
}
function getBoardEdgeSyntheticRowId(groupLabel: string) {
return `${BOARD_EDGE_SYNTHETIC_ID_PREFIX}:${encodeURIComponent(groupLabel)}`
}
@ -334,6 +345,7 @@ function shouldCreateBoardWoodPaintSyntheticRow(row: NonIntegratedBomRow) {
function createBoardEdgeSyntheticRows(
rows: NonIntegratedBomRow[],
syntheticUnitPriceOverrides: NonIntegratedBomSyntheticUnitPriceOverrides = {},
edgeBandingOptions: NonIntegratedBomEdgeBandingOption[] = [],
): NonIntegratedBomSyntheticRow[] {
const groups = new Map<string, { label: string; edgeMeters: number }>()
@ -354,15 +366,24 @@ function createBoardEdgeSyntheticRows(
return Array.from(groups.values()).map((group) => {
const overrideKey = getBoardEdgeSyntheticOverrideKey(group.label)
const selectionOverrideKey = getBoardEdgeSyntheticSelectionOverrideKey(overrideKey)
const selectedEdgeBandingOption = findNonIntegratedBomEdgeBandingOption(
edgeBandingOptions,
group.label,
syntheticUnitPriceOverrides[selectionOverrideKey]?.trim(),
)
const fallbackUnitPrice =
parseNonIntegratedBomNumber(selectedEdgeBandingOption?.unitPrice) ??
BOARD_EDGE_UNIT_PRICE
const overrideValue = getSyntheticUnitPriceOverrideValue(
syntheticUnitPriceOverrides,
overrideKey,
'edge-banding',
)
const unitPrice = resolveSyntheticUnitPrice(overrideValue, BOARD_EDGE_UNIT_PRICE)
const unitPrice = resolveSyntheticUnitPrice(overrideValue, fallbackUnitPrice)
const displayUnitPrice = resolveSyntheticUnitPriceDisplay(
overrideValue,
BOARD_EDGE_UNIT_PRICE,
fallbackUnitPrice,
)
const totalPrice = group.edgeMeters * BOARD_EDGE_FACTOR * unitPrice
@ -371,11 +392,15 @@ function createBoardEdgeSyntheticRows(
category: '板材',
syntheticKind: 'edge-banding',
syntheticUnitPriceOverrideKey: overrideKey,
syntheticSelectionOverrideKey: selectionOverrideKey,
syntheticSourceLabel: group.label,
partCode: '',
partImage: '',
partName: '',
structure: '',
material: `${group.label}的封边+胶水`,
material:
selectedEdgeBandingOption?.selectionLabel ||
`${group.label}的封边+胶水`,
process: '',
remark: '',
specSize: '',
@ -383,7 +408,7 @@ function createBoardEdgeSyntheticRows(
width: '',
longEdgeCount: '',
shortEdgeCount: '',
unit: '米',
unit: selectedEdgeBandingOption?.unit || '米',
factor: String(BOARD_EDGE_FACTOR),
unitPrice: displayUnitPrice,
unitWeight: '',
@ -483,11 +508,16 @@ export function buildNonIntegratedBomDisplayRows(
category: NonIntegratedBomCategory,
rows: NonIntegratedBomRow[],
syntheticUnitPriceOverrides: NonIntegratedBomSyntheticUnitPriceOverrides = {},
edgeBandingOptions: NonIntegratedBomEdgeBandingOption[] = [],
): NonIntegratedBomDisplayRow[] {
if (rows.length === 0) return rows
if (category === '板材') {
const edgeRows = createBoardEdgeSyntheticRows(rows, syntheticUnitPriceOverrides)
const edgeRows = createBoardEdgeSyntheticRows(
rows,
syntheticUnitPriceOverrides,
edgeBandingOptions,
)
const woodPaintRow = createBoardWoodPaintSyntheticRow(rows, syntheticUnitPriceOverrides)
const syntheticRows = woodPaintRow ? [...edgeRows, woodPaintRow] : edgeRows
return syntheticRows.length > 0 ? [...rows, ...syntheticRows] : rows