feat: finish non-integrated intake flow
This commit is contained in:
parent
8c4ad11092
commit
7687032d9c
365
furniture-3d-system/src/app/intake/IntakeClient.test.tsx
Normal file
365
furniture-3d-system/src/app/intake/IntakeClient.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
782
furniture-3d-system/src/app/intake/IntakeClient.tsx
Normal file
782
furniture-3d-system/src/app/intake/IntakeClient.tsx
Normal 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
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user