feat(intake): add edge banding library
This commit is contained in:
parent
94f7c0a302
commit
2b204c6a98
916
furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.tsx
Normal file
916
furniture-3d-system/src/app/intake/EdgeBandingLibraryPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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"),
|
||||
const candidateTabs = workspaceTabs.filter(
|
||||
(tab) => tab.kind !== "newNonIntegratedProduct",
|
||||
);
|
||||
const { activeTab } = resolveWorkspaceTabsForOpen(candidateTabs, nextTab);
|
||||
setWorkspaceTabs((current) =>
|
||||
resolveWorkspaceTabsForOpen(
|
||||
current.filter((tab) => tab.kind !== "newNonIntegratedProduct"),
|
||||
nextTab,
|
||||
]);
|
||||
syncWorkspaceState(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"),
|
||||
const candidateTabs = workspaceTabs.filter(
|
||||
(tab) => tab.kind !== "newNonIntegratedProduct",
|
||||
);
|
||||
const { activeTab } = resolveWorkspaceTabsForOpen(candidateTabs, nextTab);
|
||||
setWorkspaceTabs((current) =>
|
||||
resolveWorkspaceTabsForOpen(
|
||||
current.filter((tab) => tab.kind !== "newNonIntegratedProduct"),
|
||||
nextTab,
|
||||
]);
|
||||
syncWorkspaceState(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() {
|
||||
|
||||
@ -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 ===
|
||||
|
||||
30
furniture-3d-system/src/lib/edge-banding-library.test.ts
Normal file
30
furniture-3d-system/src/lib/edge-banding-library.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
308
furniture-3d-system/src/lib/edge-banding-library.ts
Normal file
308
furniture-3d-system/src/lib/edge-banding-library.ts
Normal 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
|
||||
)
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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,9 +42,7 @@ describe('non-integrated bom template helpers', () => {
|
||||
})
|
||||
|
||||
it('splits board edge-banding rows by board material group', () => {
|
||||
const displayRows = buildNonIntegratedBomDisplayRows(
|
||||
'板材',
|
||||
[
|
||||
const boardRows: Parameters<typeof buildNonIntegratedBomDisplayRows>[1] = [
|
||||
{
|
||||
id: 'board-12',
|
||||
category: '板材',
|
||||
@ -86,7 +85,11 @@ describe('non-integrated bom template helpers', () => {
|
||||
unitWeight: '7',
|
||||
quantity: '1',
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
const displayRows = buildNonIntegratedBomDisplayRows(
|
||||
'板材',
|
||||
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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user