feat: finish non-integrated intake flow

This commit is contained in:
Codex 2026-04-15 20:22:09 +08:00
parent 8c4ad11092
commit 7687032d9c
5 changed files with 5120 additions and 0 deletions

View File

@ -0,0 +1,365 @@
'use client'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import IntakeClient from './IntakeClient'
import { NON_INTEGRATED_STORAGE_KEY } from './non-integrated/nonIntegratedStorage'
jest.mock('@/components/platform/PlatformSplitLayout', () => ({
__esModule: true,
PlatformSplitLayout: ({
leftContent,
rightContent,
}: {
leftContent: ReactNode
rightContent: ReactNode
}) => (
<div>
<div data-testid="left-panel">{leftContent}</div>
<div data-testid="right-panel">{rightContent}</div>
</div>
),
}))
jest.mock('@/components/platform/AccordionSection', () => ({
__esModule: true,
AccordionSection: ({
title,
children,
onToggle,
}: {
title: ReactNode
children: ReactNode
onToggle?: () => void
}) => (
<section>
<button type="button" onClick={onToggle}>
{title}
</button>
<div>{children}</div>
</section>
),
}))
jest.mock('@/lib/open-product/platform-view', () => ({
__esModule: true,
readPreviewNodes: () => [],
}))
jest.mock('@/components/platform/ModelPreviewPanel', () => ({
__esModule: true,
ModelPreviewPanel: ({
title,
emptyTitle,
emptyDescription,
}: {
title?: string
emptyTitle?: string
emptyDescription?: string
}) => (
<div>
<div>{title ?? '3D Preview'}</div>
<div>{emptyTitle}</div>
<div>{emptyDescription}</div>
</div>
),
}))
function renderSelector(props?: { eligibleSkuList?: Array<Record<string, unknown>> }) {
return render(
<IntakeClient
user={{
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
role: 'SUPER_ADMIN',
avatar: null,
department: null,
}}
metrics={[]}
eligibleDraftGroups={[
{
id: 'draft-group-1',
name: 'Draft Group',
seriesTemplateName: 'Single board desk',
code: 'DG-001',
},
]}
eligibleSkuList={props?.eligibleSkuList as never}
draftGroupId={null}
hasIneligibleSelected={false}
/>,
)
}
function renderEditor() {
return render(
<IntakeClient
user={{
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
role: 'SUPER_ADMIN',
avatar: null,
department: null,
}}
metrics={[]}
detail={{
draftGroup: {
id: 'draft-group-1',
name: 'Draft Group',
categoryId: 'category-desk',
seriesTemplateName: 'Single board desk',
categoryName: 'Desk',
code: 'DG-001',
},
preSkus: [
{
id: 'pre-sku-1',
status: 'PACKAGING',
customName: 'Test SKU',
defaultName: 'Board A + Leg A',
componentCodes: ['BOARD-A', 'LEG-A'],
previewNodes: [],
displaySummary: {
spuCode: 'GBGJ-DNZ0001',
skuCode: 'GBGJ-DNZ000101',
},
familySelection: {
seriesName: 'Single board desk',
},
},
],
} as never}
recommendedProducts={[] as never}
locations={[] as never}
categories={[] as never}
defaultAttachProductId={null}
/>,
)
}
describe('IntakeClient', () => {
beforeEach(() => {
window.localStorage.clear()
jest.restoreAllMocks()
})
it('shows the renamed section buttons', () => {
renderSelector()
const rightPanel = screen.getByTestId('right-panel')
expect(within(rightPanel).getByRole('button', { name: '\u8d44\u6599\u5165\u5e93' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: 'BOM \u6e05\u5355' })).toBeInTheDocument()
expect(within(rightPanel).queryByRole('button', { name: '\u6267\u884c\u51c6\u5907' })).not.toBeInTheDocument()
expect(within(rightPanel).queryByRole('button', { name: '\u5305\u88c5\u6267\u884c' })).not.toBeInTheDocument()
expect(within(rightPanel).queryByRole('button', { name: '\u8d28\u68c0\u786e\u8ba4' })).not.toBeInTheDocument()
})
it('defaults to the non-integrated intake view with the pending section expanded', () => {
renderSelector({ eligibleSkuList: [] })
const rightPanel = screen.getByTestId('right-panel')
expect(within(rightPanel).getByRole('button', { name: '\u975e\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '\u65b0\u589e\u4ea7\u54c1' })).toBeInTheDocument()
})
it('shows intake entry tabs and the integrated pending list', async () => {
const actor = userEvent.setup()
renderSelector()
const rightPanel = screen.getByTestId('right-panel')
const leftPanel = screen.getByTestId('left-panel')
expect(within(rightPanel).getByRole('button', { name: '\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' })).toBeInTheDocument()
expect(within(rightPanel).getByRole('button', { name: '\u975e\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' })).toBeInTheDocument()
await actor.click(within(rightPanel).getByRole('button', { name: '\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' }))
expect(within(leftPanel).getByRole('button', { name: '\u672a\u64cd\u4f5c' })).toBeInTheDocument()
expect(within(leftPanel).getByRole('button', { name: '\u5df2\u64cd\u4f5c' })).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: 'SPU' })).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: 'SKU' })).toBeInTheDocument()
})
it('does not show preparation packaging or quality sections in editor mode', () => {
renderEditor()
const rightPanel = screen.getByTestId('right-panel')
expect(within(rightPanel).queryByRole('button', { name: '\u6267\u884c\u51c6\u5907' })).not.toBeInTheDocument()
expect(within(rightPanel).queryByRole('button', { name: '\u5305\u88c5\u6267\u884c' })).not.toBeInTheDocument()
expect(within(rightPanel).queryByRole('button', { name: '\u8d28\u68c0\u786e\u8ba4' })).not.toBeInTheDocument()
})
it('paginates integrated sku data by 25 rows', async () => {
const actor = userEvent.setup()
const eligibleSkuList = Array.from({ length: 26 }, (_, index) => ({
id: `pre-sku-${index + 1}`,
spuCode: 'GBGJ-DNZ0001',
code: `GBGJ-DNZ0001${String(index + 1).padStart(2, '0')}`,
name: `Test SKU ${index + 1}`,
seriesName: 'Single board desk',
draftGroupName: 'Single board desk',
status: '\u5f85\u5904\u7406',
componentCodes: [],
}))
renderSelector({ eligibleSkuList })
await actor.click(screen.getByRole('button', { name: '\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' }))
expect(screen.getByText(/\u6bcf\u9875 25 \u6761/)).toBeInTheDocument()
expect(screen.getByText('GBGJ-DNZ000125')).toBeInTheDocument()
expect(screen.queryByText('GBGJ-DNZ000126')).not.toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '\u4e0b\u4e00\u9875' }))
expect(screen.getByText('GBGJ-DNZ000126')).toBeInTheDocument()
})
it('shows locally saved non-integrated products and supports delete', async () => {
const actor = userEvent.setup()
jest.spyOn(window, 'confirm').mockReturnValue(true)
window.localStorage.setItem(
NON_INTEGRATED_STORAGE_KEY,
JSON.stringify([
{
id: 'DNZ0001-1',
spu: 'DNZ0001',
designer: 'Designer A',
previewImage: 'data:image/png;base64,test-image',
previewImageName: 'preview.png',
productCode: '',
size: '',
color: '',
installMethod: '',
installSteps: '',
tagMaterial: '',
packageDetail: '',
craftDetail: '',
createdAt: '2026-04-14T10:00:00.000Z',
updatedAt: '2026-04-14T10:00:00.000Z',
},
]),
)
renderSelector({ eligibleSkuList: [] })
const rightPanel = screen.getByTestId('right-panel')
expect(within(rightPanel).getByRole('button', { name: '\u975e\u96c6\u6210\u7cfb\u5217\u4ea7\u54c1' })).toBeInTheDocument()
expect(screen.getByText('DNZ0001')).toBeInTheDocument()
expect(screen.getByText('Designer A')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '\u4fee\u6539' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '\u5220\u9664' })).toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '\u5220\u9664' }))
expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument()
})
it('shows operation and purchaser fields and filters non-integrated products by query button', async () => {
const actor = userEvent.setup()
window.localStorage.setItem(
NON_INTEGRATED_STORAGE_KEY,
JSON.stringify([
{
id: 'DNZ0002-1',
spu: 'GBGJ-DNZ0002',
designer: 'Designer B',
operator: 'Operator B',
purchaser: 'Buyer B',
previewImage: 'data:image/png;base64,test-image-2',
previewImageName: 'preview-2.png',
productCode: '',
size: '',
color: '',
installMethod: '',
installSteps: '',
tagMaterial: '',
packageDetail: '',
craftDetail: '',
createdAt: '2026-04-14T10:00:00.000Z',
updatedAt: '2026-04-14T10:00:00.000Z',
},
{
id: 'DNZ0001-1',
spu: 'DNZ0001',
designer: 'Designer A',
operator: 'Operator A',
purchaser: 'Buyer A',
previewImage: 'data:image/png;base64:test-image',
previewImageName: 'preview.png',
productCode: '',
size: '',
color: '',
installMethod: '',
installSteps: '',
tagMaterial: '',
packageDetail: '',
craftDetail: '',
createdAt: '2026-04-15T10:00:00.000Z',
updatedAt: '2026-04-15T10:00:00.000Z',
},
]),
)
renderSelector({ eligibleSkuList: [] })
expect(screen.getByText('DNZ0001')).toBeInTheDocument()
expect(screen.getByText('Operator A')).toBeInTheDocument()
expect(screen.getByText('Buyer A')).toBeInTheDocument()
expect(screen.getByLabelText('SPU')).toBeInTheDocument()
expect(screen.getByLabelText('\u8bbe\u8ba1\u5e08')).toBeInTheDocument()
expect(screen.getByLabelText('\u8fd0\u8425')).toBeInTheDocument()
expect(screen.getByLabelText('\u91c7\u8d2d')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '\u67e5\u8be2' })).toBeInTheDocument()
await actor.type(screen.getByLabelText('\u8fd0\u8425'), 'Operator B')
await actor.click(screen.getByRole('button', { name: '\u67e5\u8be2' }))
expect(screen.getByText('GBGJ-DNZ0002')).toBeInTheDocument()
expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument()
await actor.clear(screen.getByLabelText('\u8fd0\u8425'))
await actor.type(screen.getByLabelText('SPU'), 'DNZ0002')
await actor.click(screen.getByRole('button', { name: '\u67e5\u8be2' }))
expect(screen.getByText('GBGJ-DNZ0002')).toBeInTheDocument()
expect(screen.queryByText('DNZ0001')).not.toBeInTheDocument()
})
it('opens a larger preview when clicking the non-integrated product thumbnail', async () => {
const actor = userEvent.setup()
window.localStorage.setItem(
NON_INTEGRATED_STORAGE_KEY,
JSON.stringify([
{
id: 'DNZ0001-1',
spu: 'DNZ0001',
designer: 'Designer A',
operator: 'Operator A',
purchaser: 'Buyer A',
previewImage: 'data:image/png;base64,test-image',
previewImageName: 'preview.png',
productCode: '',
size: '',
color: '',
installMethod: '',
installSteps: '',
tagMaterial: '',
packageDetail: '',
craftDetail: '',
createdAt: '2026-04-15T10:00:00.000Z',
updatedAt: '2026-04-15T10:00:00.000Z',
},
]),
)
renderSelector({ eligibleSkuList: [] })
await actor.click(screen.getByAltText('DNZ0001 成品预览图'))
expect(screen.getByRole('dialog', { name: '成品预览图' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '关闭预览' })).toBeInTheDocument()
})
})

View File

@ -0,0 +1,782 @@
'use client'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { PackagePlus, Printer } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import Button from '@/components/ui/Button'
import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout'
import { AccordionSection } from '@/components/platform/AccordionSection'
import {
deleteNonIntegratedProduct,
getSearchableNonIntegratedSpuKeywords,
listNonIntegratedProducts,
NON_INTEGRATED_PRODUCTS_UPDATED,
type NonIntegratedProductRecord,
} from './non-integrated/nonIntegratedStorage'
import type {
CategoryDTO,
DraftGroupDetailDTO,
IntakeProductRecommendationDTO,
WarehouseLocationDTO,
} from '@/server/services'
import type { PlatformMetricItem } from '@/components/platform/types'
interface IntakeClientSelectorProps {
user: {
id: string
name: string
email: string
role: string
avatar: string | null
department: { id: string; name: string } | null
}
metrics: PlatformMetricItem[]
eligibleDraftGroups: { id: string; name: string; seriesTemplateName: string; code: string }[]
eligibleSkuList?: WorkbenchSku[]
draftGroupId: string | null
hasIneligibleSelected: boolean
}
interface IntakeClientEditorProps {
user: {
id: string
name: string
email: string
role: string
avatar: string | null
department: { id: string; name: string } | null
}
metrics: PlatformMetricItem[]
detail: DraftGroupDetailDTO
recommendedProducts: IntakeProductRecommendationDTO[]
locations: WarehouseLocationDTO[]
categories: CategoryDTO[]
defaultAttachProductId: string | null
}
type IntakeClientProps = IntakeClientSelectorProps | IntakeClientEditorProps
type IntakeSection = 'pending' | 'bom' | null
type PendingTabKey = 'pending' | 'completed' | 'paused'
type IntakeEntryTab = 'integrated' | 'nonIntegrated'
type WorkbenchSku = {
id: string
spuCode: string
code: string
name: string
seriesName: string
draftGroupName: string
status: string
componentCodes: string[]
}
const PENDING_PAGE_SIZE = 25
function isEditorMode(props: IntakeClientProps): props is IntakeClientEditorProps {
return 'detail' in props && props.detail !== undefined
}
function buildSkuCode(seed: string, index: number) {
return `SKU-${String(index + 1).padStart(3, '0')}-${seed.slice(-4).toUpperCase()}`
}
function readPreSkuTextField(preSku: DraftGroupDetailDTO['preSkus'][number], key: string) {
const displayValue = preSku.displaySummary?.[key]
if (typeof displayValue === 'string' && displayValue.trim().length > 0) {
return displayValue.trim()
}
const familyValue = preSku.familySelection?.[key]
if (typeof familyValue === 'string' && familyValue.trim().length > 0) {
return familyValue.trim()
}
return ''
}
function mapStatus(status: string) {
if (status === 'FORMALIZED') return '已完成'
if (status === 'READY_FOR_INTAKE') return '待执行'
if (status === 'PACKAGING') return '待入库'
return '待处理'
}
function mapPendingTab(status: string): PendingTabKey {
if (status === '已完成') return 'completed'
if (status === '暂缓') return 'paused'
return 'pending'
}
function toggleSection(current: IntakeSection, section: Exclude<IntakeSection, null>) {
return current === section ? null : section
}
function DetailPanel({
title,
description,
children,
}: {
title: string
description: string
children?: React.ReactNode
}) {
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>
<h3 className="text-base font-apple-semibold text-apple-label-primary">{title}</h3>
<p className="mt-1 text-xs leading-5 text-apple-label-secondary">{description}</p>
</div>
<div className="mt-3 flex-1 overflow-y-auto">{children}</div>
</div>
)
}
function ImagePreviewDialog({
src,
alt,
onClose,
}: {
src: string
alt: string
onClose: () => void
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
role="dialog"
aria-label="成品预览图"
onClick={onClose}
>
<div
className="relative w-full max-w-3xl rounded-apple-xl bg-apple-bg-primary p-4 shadow-apple-lg"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="truncate text-sm font-apple-medium text-apple-label-primary">{alt}</p>
<Button type="button" variant="plain" size="small" onClick={onClose}>
关闭预览
</Button>
</div>
<div className="overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-secondary">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={alt} className="max-h-[70vh] w-full object-contain" />
</div>
</div>
</div>
)
}
function PendingSkuDetail({ skuList }: { skuList: WorkbenchSku[] }) {
const [activeTab, setActiveTab] = useState<PendingTabKey>('pending')
const [spuKeyword, setSpuKeyword] = useState('')
const [skuKeyword, setSkuKeyword] = useState('')
const [seriesKeyword, setSeriesKeyword] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const filteredList = useMemo(() => {
return skuList.filter((sku) => {
if (mapPendingTab(sku.status) !== activeTab) return false
const spuMatched =
spuKeyword.trim().length === 0 ||
sku.spuCode.toLowerCase().includes(spuKeyword.trim().toLowerCase())
const skuMatched =
skuKeyword.trim().length === 0 ||
sku.code.toLowerCase().includes(skuKeyword.trim().toLowerCase()) ||
sku.name.toLowerCase().includes(skuKeyword.trim().toLowerCase())
const seriesMatched =
seriesKeyword.trim().length === 0 ||
sku.seriesName.toLowerCase().includes(seriesKeyword.trim().toLowerCase())
return spuMatched && skuMatched && seriesMatched
})
}, [activeTab, seriesKeyword, skuKeyword, skuList, spuKeyword])
const totalPages = Math.max(1, Math.ceil(filteredList.length / PENDING_PAGE_SIZE))
const safeCurrentPage = Math.min(currentPage, totalPages)
const pagedList = filteredList.slice(
(safeCurrentPage - 1) * PENDING_PAGE_SIZE,
safeCurrentPage * PENDING_PAGE_SIZE,
)
const tabOptions: { key: PendingTabKey; label: string }[] = [
{ key: 'pending', label: '未操作' },
{ key: 'completed', label: '已操作' },
{ key: 'paused', label: '暂缓' },
]
return (
<div className="flex h-full flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
<div className="border-b border-apple-separator px-4 py-3">
<div className="flex flex-wrap gap-2">
{tabOptions.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => {
setActiveTab(tab.key)
setCurrentPage(1)
}}
className={`rounded-apple-lg border px-3 py-1.5 text-xs transition ${
activeTab === tab.key
? 'border-apple-blue bg-apple-blue/10 text-apple-blue'
: 'border-apple-separator bg-apple-bg-secondary text-apple-label-secondary'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="mt-3 grid gap-2.5 md:grid-cols-[1fr_1fr_1fr_112px_88px]">
<label className="space-y-1.5 text-xs text-apple-label-secondary">
<span>SPU</span>
<input
value={spuKeyword}
onChange={(event) => {
setSpuKeyword(event.target.value)
setCurrentPage(1)
}}
placeholder="搜索 SPU"
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
/>
</label>
<label className="space-y-1.5 text-xs text-apple-label-secondary">
<span>SKU</span>
<input
value={skuKeyword}
onChange={(event) => {
setSkuKeyword(event.target.value)
setCurrentPage(1)
}}
placeholder="搜索 SKU"
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
/>
</label>
<label className="space-y-1.5 text-xs text-apple-label-secondary">
<span>系列</span>
<input
value={seriesKeyword}
onChange={(event) => {
setSeriesKeyword(event.target.value)
setCurrentPage(1)
}}
placeholder="搜索系列"
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
/>
</label>
<div className="flex items-end">
<Button size="small" className="h-9 w-full text-xs">
搜索
</Button>
</div>
<div className="flex items-end">
<Button
variant="plain"
size="small"
className="h-9 w-full text-xs"
onClick={() => {
setSpuKeyword('')
setSkuKeyword('')
setSeriesKeyword('')
setCurrentPage(1)
}}
>
重置
</Button>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{skuList.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 py-5 text-xs leading-6 text-apple-label-secondary">
当前还没有草稿组。先去辅助装配完成手动拼装,或回到草稿组继续补齐资料。
</div>
) : (
<div className="h-full overflow-auto">
<table className="min-w-full text-left text-xs">
<thead className="bg-apple-bg-primary text-apple-label-secondary">
<tr className="border-b border-apple-separator">
<th className="px-3 py-2.5 font-apple-medium">编号</th>
<th className="px-3 py-2.5 font-apple-medium">图片</th>
<th className="px-3 py-2.5 font-apple-medium">上传</th>
<th className="px-3 py-2.5 font-apple-medium">SPU</th>
<th className="px-3 py-2.5 font-apple-medium">SKU</th>
<th className="px-3 py-2.5 font-apple-medium">系列</th>
<th className="px-3 py-2.5 font-apple-medium">状态</th>
<th className="px-3 py-2.5 font-apple-medium">操作</th>
</tr>
</thead>
<tbody>
{filteredList.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-xs text-apple-label-secondary">
当前筛选条件下没有找到 SKU。
</td>
</tr>
) : (
pagedList.map((sku, index) => (
<tr key={sku.id} className="border-b border-apple-separator">
<td className="px-3 py-3 text-apple-label-secondary">
{(safeCurrentPage - 1) * PENDING_PAGE_SIZE + index + 1}
</td>
<td className="px-3 py-3">
<div className="flex h-10 w-10 items-center justify-center rounded-apple-lg border border-apple-separator bg-apple-bg-secondary text-[10px] text-apple-label-secondary">
SKU 图
</div>
</td>
<td className="px-3 py-3">
<Button variant="plain" size="small">
上传
</Button>
</td>
<td className="px-3 py-3 text-apple-label-primary">{sku.spuCode}</td>
<td className="px-3 py-3">
<p className="font-apple-medium text-apple-label-primary">{sku.code}</p>
<p className="mt-0.5 text-[11px] text-apple-label-secondary">{sku.name}</p>
</td>
<td className="px-3 py-3 text-apple-label-primary">{sku.seriesName}</td>
<td className="px-3 py-3">
<Badge type="info" variant="subtle">
{sku.status}
</Badge>
</td>
<td className="px-3 py-3">
<div className="flex gap-2">
<Button size="small">查看</Button>
<Button variant="plain" size="small">
暂缓
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
{filteredList.length > 0 ? (
<div className="flex items-center justify-between border-t border-apple-separator px-4 py-3 text-xs text-apple-label-secondary">
<span>
第 {safeCurrentPage} / {totalPages} 页,每页 {PENDING_PAGE_SIZE} 条
</span>
<div className="flex gap-2">
<Button
variant="plain"
size="small"
className="h-8 px-3 text-xs"
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
disabled={safeCurrentPage === 1}
>
上一页
</Button>
<Button
variant="plain"
size="small"
className="h-8 px-3 text-xs"
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
disabled={safeCurrentPage === totalPages}
>
下一页
</Button>
</div>
</div>
) : null}
</div>
)
}
function NonIntegratedPendingDetail() {
const [products, setProducts] = useState<NonIntegratedProductRecord[]>([])
const [previewProduct, setPreviewProduct] = useState<{ src: string; alt: string } | null>(null)
const [queryDraft, setQueryDraft] = useState({
spu: '',
designer: '',
operator: '',
purchaser: '',
})
const [appliedQuery, setAppliedQuery] = useState({
spu: '',
designer: '',
operator: '',
purchaser: '',
})
useEffect(() => {
function syncProducts() {
setProducts(listNonIntegratedProducts())
}
syncProducts()
window.addEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts)
return () => window.removeEventListener(NON_INTEGRATED_PRODUCTS_UPDATED, syncProducts)
}, [])
function handleDelete(productId: string) {
if (!window.confirm('确认删除这条非集成产品测试数据吗?')) {
return
}
deleteNonIntegratedProduct(productId)
setProducts(listNonIntegratedProducts())
}
const filteredProducts = useMemo(() => {
return products.filter((product) => {
const normalizedSpuKeyword = appliedQuery.spu.trim().toUpperCase()
const spuMatched =
normalizedSpuKeyword.length === 0 ||
getSearchableNonIntegratedSpuKeywords(product.spu).some((keyword) =>
keyword.includes(normalizedSpuKeyword),
)
const designerMatched =
appliedQuery.designer.trim().length === 0 ||
product.designer.toLowerCase().includes(appliedQuery.designer.trim().toLowerCase())
const operatorMatched =
appliedQuery.operator.trim().length === 0 ||
product.operator.toLowerCase().includes(appliedQuery.operator.trim().toLowerCase())
const purchaserMatched =
appliedQuery.purchaser.trim().length === 0 ||
product.purchaser.toLowerCase().includes(appliedQuery.purchaser.trim().toLowerCase())
return spuMatched && designerMatched && operatorMatched && purchaserMatched
})
}, [appliedQuery, products])
return (
<div className="flex h-full flex-col overflow-hidden rounded-apple-xl border border-apple-separator bg-apple-bg-primary shadow-apple-xs">
<div className="border-b border-apple-separator px-4 py-3">
<div className="flex flex-col gap-3">
<div className="grid gap-2.5 md:grid-cols-[1fr_1fr_1fr_1fr_88px_112px]">
<label className="space-y-1.5 text-xs text-apple-label-secondary">
<span>SPU</span>
<input
aria-label="SPU"
value={queryDraft.spu}
onChange={(event) =>
setQueryDraft((current) => ({ ...current, spu: event.target.value }))
}
placeholder="搜索 SPU"
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
/>
</label>
<label className="space-y-1.5 text-xs text-apple-label-secondary">
<span>设计师</span>
<input
aria-label="设计师"
value={queryDraft.designer}
onChange={(event) =>
setQueryDraft((current) => ({ ...current, designer: event.target.value }))
}
placeholder="搜索设计师"
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
/>
</label>
<label className="space-y-1.5 text-xs text-apple-label-secondary">
<span>运营</span>
<input
aria-label="运营"
value={queryDraft.operator}
onChange={(event) =>
setQueryDraft((current) => ({ ...current, operator: event.target.value }))
}
placeholder="搜索运营"
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
/>
</label>
<label className="space-y-1.5 text-xs text-apple-label-secondary">
<span>采购</span>
<input
aria-label="采购"
value={queryDraft.purchaser}
onChange={(event) =>
setQueryDraft((current) => ({ ...current, purchaser: event.target.value }))
}
placeholder="搜索采购"
className="h-9 w-full rounded-apple-lg border border-apple-separator bg-apple-bg-secondary px-3 text-xs text-apple-label-primary outline-none"
/>
</label>
<div className="flex items-end">
<Button
size="small"
className="h-9 w-full text-xs"
onClick={() => setAppliedQuery(queryDraft)}
>
查询
</Button>
</div>
<div className="flex items-end justify-end">
<Link href="/intake/non-integrated/new" className="w-full">
<Button size="small" className="h-9 w-full px-4 text-xs">
新增产品
</Button>
</Link>
</div>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
<div className="overflow-hidden rounded-apple-lg border border-apple-separator">
<table className="min-w-full text-left text-xs">
<thead className="bg-apple-bg-primary text-apple-label-secondary">
<tr className="border-b border-apple-separator">
<th className="px-3 py-2.5 font-apple-medium">序号</th>
<th className="px-3 py-2.5 font-apple-medium">成品预览图</th>
<th className="px-3 py-2.5 font-apple-medium">SPU</th>
<th className="px-3 py-2.5 font-apple-medium">设计师</th>
<th className="px-3 py-2.5 font-apple-medium">运营</th>
<th className="px-3 py-2.5 font-apple-medium">采购</th>
<th className="px-3 py-2.5 font-apple-medium">操作</th>
</tr>
</thead>
<tbody>
{filteredProducts.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-xs text-apple-label-secondary">
{products.length === 0
? '当前还没有非集成产品,点击右上角“新增产品”开始维护资料。'
: '当前查询条件下没有找到资料。'}
</td>
</tr>
) : (
filteredProducts.map((product, index) => (
<tr key={product.id} className="border-b border-apple-separator">
<td className="px-3 py-3 text-apple-label-secondary">{index + 1}</td>
<td className="px-3 py-3">
<button
type="button"
className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-apple-lg border border-apple-separator bg-apple-bg-secondary"
onClick={() =>
product.previewImage
? setPreviewProduct({
src: product.previewImage,
alt: `${product.spu} 成品预览图`,
})
: undefined
}
>
{product.previewImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={product.previewImage}
alt={`${product.spu} 成品预览图`}
className="h-full w-full object-cover"
/>
) : (
<span className="text-[10px] text-apple-label-secondary">暂无图片</span>
)}
</button>
</td>
<td className="px-3 py-3 text-apple-label-primary">{product.spu}</td>
<td className="px-3 py-3 text-apple-label-primary">{product.designer}</td>
<td className="px-3 py-3 text-apple-label-primary">{product.operator || '-'}</td>
<td className="px-3 py-3 text-apple-label-primary">{product.purchaser || '-'}</td>
<td className="px-3 py-3">
<div className="flex gap-2">
<Link href={`/intake/non-integrated/${encodeURIComponent(product.id)}`}>
<Button size="small">修改</Button>
</Link>
<Button
variant="plain"
size="small"
onClick={() => handleDelete(product.id)}
>
删除
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{previewProduct ? (
<ImagePreviewDialog
src={previewProduct.src}
alt={previewProduct.alt}
onClose={() => setPreviewProduct(null)}
/>
) : null}
</div>
)
}
function EmptyDetail() {
return (
<DetailPanel title="" description="">
<div className="h-full rounded-apple-lg bg-apple-bg-primary" />
</DetailPanel>
)
}
function BomDetail() {
return (
<DetailPanel title="" description="">
<div className="h-full rounded-apple-lg bg-apple-bg-primary" />
</DetailPanel>
)
}
function FormalIntakeWorkbench({
activeSection,
onToggle,
pendingEntryTab,
onPendingEntryTabChange,
}: {
activeSection: IntakeSection
onToggle: (section: Exclude<IntakeSection, null>) => void
pendingEntryTab: IntakeEntryTab
onPendingEntryTabChange: (tab: IntakeEntryTab) => void
}) {
const pendingEntryOptions: { key: IntakeEntryTab; label: string }[] = [
{ key: 'nonIntegrated', label: '非集成系列产品' },
{ key: 'integrated', label: '集成系列产品' },
]
return (
<div className="space-y-3">
<AccordionSection
title="资料入库"
icon={<PackagePlus className="h-4 w-4" />}
expanded={activeSection === 'pending'}
onToggle={() => onToggle('pending')}
>
<div className="space-y-2">
{pendingEntryOptions.map((option) => (
<button
key={option.key}
type="button"
onClick={() => onPendingEntryTabChange(option.key)}
className={`flex w-full items-center rounded-apple-lg border px-3 py-2 text-left text-xs transition ${
pendingEntryTab === option.key
? 'border-apple-blue bg-apple-blue/10 text-apple-blue'
: 'border-apple-separator bg-apple-bg-secondary text-apple-label-secondary'
}`}
>
{option.label}
</button>
))}
</div>
</AccordionSection>
<AccordionSection
title="BOM 清单"
icon={<Printer className="h-4 w-4" />}
expanded={activeSection === 'bom'}
onToggle={() => onToggle('bom')}
/>
</div>
)
}
export default function IntakeClient(props: IntakeClientProps) {
const [activeSection, setActiveSection] = useState<IntakeSection>('pending')
const [pendingEntryTab, setPendingEntryTab] = useState<IntakeEntryTab>('nonIntegrated')
if (!isEditorMode(props)) {
const skuList =
props.eligibleSkuList && props.eligibleSkuList.length > 0
? props.eligibleSkuList
: props.eligibleDraftGroups.slice(0, 3).map((draftGroup, index) => ({
id: draftGroup.id,
spuCode: draftGroup.code || `SPU-${String(index + 1).padStart(3, '0')}`,
code: `SKU-${String(index + 1).padStart(3, '0')}`,
name: draftGroup.name,
seriesName: draftGroup.seriesTemplateName,
draftGroupName: draftGroup.name,
status: '待确认',
componentCodes: [],
}))
let leftContent: React.ReactNode = <EmptyDetail />
if (activeSection === 'pending') {
leftContent =
pendingEntryTab === 'integrated' ? (
<PendingSkuDetail skuList={skuList} />
) : (
<NonIntegratedPendingDetail />
)
} else if (activeSection === 'bom') {
leftContent = <BomDetail />
}
return (
<PlatformSplitLayout
title={undefined}
metrics={props.metrics}
rightPanelClassName="w-[280px] max-w-[22vw] p-3"
leftContent={leftContent}
rightContent={
<FormalIntakeWorkbench
activeSection={activeSection}
onToggle={(section) => setActiveSection((current) => toggleSection(current, section))}
pendingEntryTab={pendingEntryTab}
onPendingEntryTabChange={(tab) => {
setPendingEntryTab(tab)
setActiveSection('pending')
}}
/>
}
/>
)
}
const skuList = props.detail.preSkus.map((preSku, index) => ({
id: preSku.id,
spuCode:
readPreSkuTextField(preSku, 'spuCode') ||
readPreSkuTextField(preSku, 'spu') ||
props.detail.draftGroup.code ||
`SPU-${String(index + 1).padStart(3, '0')}`,
code:
readPreSkuTextField(preSku, 'skuCode') ||
preSku.customName ||
preSku.defaultName ||
buildSkuCode(preSku.id, index),
name: preSku.customName || preSku.defaultName,
seriesName:
readPreSkuTextField(preSku, 'seriesName') ||
props.detail.draftGroup.seriesTemplateName ||
props.detail.draftGroup.name,
draftGroupName: props.detail.draftGroup.name,
status: mapStatus(preSku.status),
componentCodes: preSku.componentCodes ?? [],
}))
let leftContent: React.ReactNode = <EmptyDetail />
if (activeSection === 'pending') {
leftContent =
pendingEntryTab === 'integrated' ? (
<PendingSkuDetail skuList={skuList} />
) : (
<NonIntegratedPendingDetail />
)
} else if (activeSection === 'bom') {
leftContent = <BomDetail />
}
return (
<PlatformSplitLayout
title={undefined}
metrics={props.metrics}
rightPanelClassName="w-[280px] max-w-[22vw] p-3"
leftContent={leftContent}
rightContent={
<FormalIntakeWorkbench
activeSection={activeSection}
onToggle={(section) => setActiveSection((current) => toggleSection(current, section))}
pendingEntryTab={pendingEntryTab}
onPendingEntryTabChange={(tab) => {
setPendingEntryTab(tab)
setActiveSection('pending')
}}
/>
}
/>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,399 @@
export const NON_INTEGRATED_STORAGE_KEY = 'intake.nonIntegratedProducts'
export const NON_INTEGRATED_PRODUCTS_UPDATED = 'intake:nonIntegratedProductsUpdated'
export const NON_INTEGRATED_DUPLICATE_SPU_MESSAGE =
'SPU 已存在,不能重复新建。删除原记录后才可以重新新建。'
export const NON_INTEGRATED_STORAGE_LIMIT_MESSAGE =
'本地测试存储空间已满,当前上传文件过大或过多,保存失败。'
export const NON_INTEGRATED_BRAND_PREFIX_MAP: Record<string, string> = {
基本盘: '',
顾家: 'GBGJ-',
索菲亚: 'GBSFY-',
朴美: 'GBPM-',
}
export type NonIntegratedSkuItem = {
id: string
skuSequence: number
packageGroupId: string
packageSuffix: string
image: string
imageName: string
model: string
skuCode: string
setCount: string
specName: string
barcode: string
size: string
color: string
material: string
attributeCode: string
drawingId: string
installMethod: string
executeStandard: string
manufacturer: string
manufacturerCode: string
tagMaterial: string
model3dFileName: string
model3dDataUrl: string
}
export type NonIntegratedUploadFile = {
id: string
name: string
category: string
dataUrl: string
mimeType: string
}
export type NonIntegratedStyleDetail = {
styleKey: string
installMethod: string
installSteps: string
packageDetail: string
craftDetail: string
model3dFileName: string
model3dDataUrl: string
}
export type NonIntegratedBomRow = {
id: string
partCode: string
partImage: string
partName: string
structure: string
material: string
process: string
remark: string
specSize: string
quantity: string
}
export type NonIntegratedBomDetail = {
styleKey: string
rows: NonIntegratedBomRow[]
}
export type NonIntegratedProductRecord = {
id: string
spu: string
brand: string
adaptableBrands: string[]
designer: string
operator: string
purchaser: string
previewImage: string
previewImageName: string
productCode: string
size: string
color: string
installMethod: string
installSteps: string
tagMaterial: string
packageDetail: string
craftDetail: string
model3dFileName?: string
model3dDataUrl?: string
styleDetails: NonIntegratedStyleDetail[]
bomDetails: NonIntegratedBomDetail[]
skuItems: NonIntegratedSkuItem[]
uploadFiles: NonIntegratedUploadFile[]
createdAt: string
updatedAt: string
}
function normalizeNonIntegratedCode(value: string) {
return value.trim().toUpperCase()
}
export function stripNonIntegratedBrandPrefix(value: string) {
const normalizedValue = normalizeNonIntegratedCode(value)
for (const prefix of Object.values(NON_INTEGRATED_BRAND_PREFIX_MAP)) {
if (prefix && normalizedValue.startsWith(prefix)) {
return normalizedValue.slice(prefix.length)
}
}
return normalizedValue
}
export function applyBrandPrefixToNonIntegratedCode(value: string, brand: string) {
const normalizedBaseValue = stripNonIntegratedBrandPrefix(value)
const prefix = NON_INTEGRATED_BRAND_PREFIX_MAP[brand] ?? ''
if (!normalizedBaseValue) return ''
return `${prefix}${normalizedBaseValue}`
}
export function getSearchableNonIntegratedSpuKeywords(spu: string) {
const normalizedSpu = normalizeNonIntegratedCode(spu)
const baseSpu = stripNonIntegratedBrandPrefix(spu)
return Array.from(new Set([normalizedSpu, baseSpu].filter(Boolean)))
}
function canUseStorage() {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
}
function emitProductsUpdated() {
if (typeof window === 'undefined') return
window.dispatchEvent(new Event(NON_INTEGRATED_PRODUCTS_UPDATED))
}
function normalizeString(value: unknown) {
return typeof value === 'string' ? value : ''
}
function normalizeSkuItem(item: Partial<NonIntegratedSkuItem>): NonIntegratedSkuItem | null {
if (typeof item.id !== 'string') return null
return {
id: item.id,
skuSequence:
typeof item.skuSequence === 'number' && Number.isFinite(item.skuSequence) ? item.skuSequence : 0,
packageGroupId: normalizeString(item.packageGroupId),
packageSuffix: normalizeString(item.packageSuffix),
image: normalizeString(item.image),
imageName: normalizeString(item.imageName),
model: normalizeString(item.model),
skuCode: normalizeString(item.skuCode),
setCount:
typeof item.setCount === 'string'
? item.setCount
: typeof item.setCount === 'number' && Number.isFinite(item.setCount)
? String(item.setCount)
: '1',
specName: normalizeString(item.specName),
barcode: normalizeString(item.barcode),
size: normalizeString(item.size),
color: normalizeString(item.color),
material: normalizeString(item.material),
attributeCode: normalizeString(item.attributeCode),
drawingId: normalizeString(item.drawingId),
installMethod: normalizeString(item.installMethod),
executeStandard: normalizeString(item.executeStandard),
manufacturer: normalizeString(item.manufacturer),
manufacturerCode: normalizeString(item.manufacturerCode),
tagMaterial: normalizeString(item.tagMaterial),
model3dFileName: normalizeString(item.model3dFileName),
model3dDataUrl: normalizeString(item.model3dDataUrl),
}
}
function normalizeUploadFile(item: Partial<NonIntegratedUploadFile>): NonIntegratedUploadFile | null {
if (typeof item.id !== 'string' || typeof item.name !== 'string') return null
return {
id: item.id,
name: item.name,
category: typeof item.category === 'string' ? item.category : '未分类',
dataUrl: normalizeString(item.dataUrl),
mimeType: normalizeString(item.mimeType),
}
}
function normalizeBomRow(item: Partial<NonIntegratedBomRow>): NonIntegratedBomRow | null {
if (typeof item.id !== 'string') return null
return {
id: item.id,
partCode: normalizeString(item.partCode),
partImage: normalizeString(item.partImage),
partName: normalizeString(item.partName),
structure: normalizeString(item.structure),
material: normalizeString(item.material),
process: normalizeString(item.process),
remark: normalizeString(item.remark),
specSize: normalizeString(item.specSize),
quantity:
typeof item.quantity === 'string'
? item.quantity
: typeof item.quantity === 'number' && Number.isFinite(item.quantity)
? String(item.quantity)
: '',
}
}
function normalizeStyleDetail(entry: unknown): NonIntegratedStyleDetail | null {
if (!entry || typeof entry !== 'object') return null
const item = entry as Partial<NonIntegratedStyleDetail>
if (typeof item.styleKey !== 'string') return null
return {
styleKey: item.styleKey,
installMethod: normalizeString(item.installMethod),
installSteps: normalizeString(item.installSteps),
packageDetail: normalizeString(item.packageDetail),
craftDetail: normalizeString(item.craftDetail),
model3dFileName: normalizeString(item.model3dFileName),
model3dDataUrl: normalizeString(item.model3dDataUrl),
}
}
function normalizeBomDetail(entry: unknown): NonIntegratedBomDetail | null {
if (!entry || typeof entry !== 'object') return null
const item = entry as Partial<NonIntegratedBomDetail>
if (typeof item.styleKey !== 'string') return null
return {
styleKey: item.styleKey,
rows: Array.isArray(item.rows)
? item.rows.map((row) => normalizeBomRow(row)).filter((row): row is NonIntegratedBomRow => row !== null)
: [],
}
}
function toProductRecord(item: Partial<NonIntegratedProductRecord>): NonIntegratedProductRecord | null {
if (
typeof item.id !== 'string' ||
typeof item.spu !== 'string' ||
typeof item.designer !== 'string' ||
typeof item.previewImage !== 'string'
) {
return null
}
return {
id: item.id,
spu: item.spu,
brand: normalizeString(item.brand) || '基本盘',
adaptableBrands: Array.isArray(item.adaptableBrands)
? item.adaptableBrands.filter((brand): brand is string => typeof brand === 'string')
: [normalizeString(item.brand) || '基本盘'],
designer: item.designer,
operator: normalizeString(item.operator),
purchaser: normalizeString(item.purchaser),
previewImage: item.previewImage,
previewImageName: normalizeString(item.previewImageName),
productCode: normalizeString(item.productCode),
size: normalizeString(item.size),
color: normalizeString(item.color),
installMethod: normalizeString(item.installMethod),
installSteps: normalizeString(item.installSteps),
tagMaterial: normalizeString(item.tagMaterial),
packageDetail: normalizeString(item.packageDetail),
craftDetail: normalizeString(item.craftDetail),
model3dFileName: normalizeString(item.model3dFileName),
model3dDataUrl: normalizeString(item.model3dDataUrl),
styleDetails: Array.isArray(item.styleDetails)
? item.styleDetails
.map((entry) => normalizeStyleDetail(entry))
.filter((entry): entry is NonIntegratedStyleDetail => entry !== null)
: [],
bomDetails: Array.isArray(item.bomDetails)
? item.bomDetails
.map((entry) => normalizeBomDetail(entry))
.filter((entry): entry is NonIntegratedBomDetail => entry !== null)
: [],
skuItems: Array.isArray(item.skuItems)
? item.skuItems
.map((entry) => normalizeSkuItem(entry))
.filter((entry): entry is NonIntegratedSkuItem => entry !== null)
: [],
uploadFiles: Array.isArray(item.uploadFiles)
? item.uploadFiles
.map((entry) => normalizeUploadFile(entry))
.filter((entry): entry is NonIntegratedUploadFile => entry !== null)
: [],
createdAt: typeof item.createdAt === 'string' ? item.createdAt : new Date().toISOString(),
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : new Date().toISOString(),
}
}
export function listNonIntegratedProducts(): NonIntegratedProductRecord[] {
if (!canUseStorage()) return []
try {
const rawValue = window.localStorage.getItem(NON_INTEGRATED_STORAGE_KEY)
if (!rawValue) return []
const parsedValue = JSON.parse(rawValue)
if (!Array.isArray(parsedValue)) return []
return parsedValue
.map((item) => toProductRecord(item))
.filter((item): item is NonIntegratedProductRecord => item !== null)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
} catch {
return []
}
}
export function getNonIntegratedProductById(id: string) {
return listNonIntegratedProducts().find((item) => item.id === id) ?? null
}
export function getNonIntegratedProductBySpu(spu: string) {
const normalizedSpu = spu.trim().toUpperCase()
if (!normalizedSpu) return null
return listNonIntegratedProducts().find((item) => item.spu.trim().toUpperCase() === normalizedSpu) ?? null
}
export function buildNonIntegratedProductId(spu: string) {
const normalizedSpu = spu.trim().replace(/[^a-zA-Z0-9-]/g, '').toUpperCase() || 'NON-INTEGRATED'
return `${normalizedSpu}-${Date.now()}`
}
export function buildNonIntegratedSkuId() {
return `sku-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
export function buildNonIntegratedUploadFileId() {
return `file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
export function buildNonIntegratedBomRowId() {
return `bom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
export function saveNonIntegratedProduct(
product: Omit<NonIntegratedProductRecord, 'updatedAt'> & { updatedAt?: string },
) {
if (!canUseStorage()) return product
const currentList = listNonIntegratedProducts()
const normalizedSpu = product.spu.trim().toUpperCase()
const duplicateProduct = currentList.find(
(item) => item.id !== product.id && item.spu.trim().toUpperCase() === normalizedSpu,
)
if (normalizedSpu && duplicateProduct) {
throw new Error(NON_INTEGRATED_DUPLICATE_SPU_MESSAGE)
}
const nextProduct: NonIntegratedProductRecord = {
...product,
updatedAt: product.updatedAt ?? new Date().toISOString(),
}
const existingIndex = currentList.findIndex((item) => item.id === nextProduct.id)
if (existingIndex >= 0) {
currentList.splice(existingIndex, 1, nextProduct)
} else {
currentList.unshift(nextProduct)
}
try {
window.localStorage.setItem(NON_INTEGRATED_STORAGE_KEY, JSON.stringify(currentList))
} catch (error) {
if (
error instanceof DOMException &&
(error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED')
) {
throw new Error(NON_INTEGRATED_STORAGE_LIMIT_MESSAGE)
}
throw error
}
emitProductsUpdated()
return nextProduct
}
export function deleteNonIntegratedProduct(id: string) {
if (!canUseStorage()) return
const nextList = listNonIntegratedProducts().filter((item) => item.id !== id)
window.localStorage.setItem(NON_INTEGRATED_STORAGE_KEY, JSON.stringify(nextList))
emitProductsUpdated()
}