From c643c09512bb90b653c54582301bfab3f7c2deca Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 18 May 2026 15:32:37 +0800 Subject: [PATCH 01/21] feat(intake): refine product list and sku workflow --- .../src/app/intake/IntakeClient.test.tsx | 38 ++ .../src/app/intake/IntakeClient.tsx | 481 +++++++++++++++++- .../NonIntegratedDetailClient.tsx | 80 ++- .../non-integrated/nonIntegratedStorage.ts | 3 + .../src/lib/non-integrated-sku-export.ts | 6 +- 5 files changed, 580 insertions(+), 28 deletions(-) diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx index 4cd934a3..86eea57b 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx @@ -473,6 +473,7 @@ describe('IntakeClient', () => { beforeEach(() => { jest.useRealTimers() window.localStorage.clear() + window.sessionStorage.clear() jest.restoreAllMocks() mockAoaToSheet.mockClear() mockBookNew.mockClear() @@ -2310,6 +2311,43 @@ describe('IntakeClient', () => { ) }) + it('restores opened workspace tabs after leaving and returning to intake', async () => { + const actor = userEvent.setup() + const product = createNonIntegratedProduct() + + window.localStorage.setItem( + NON_INTEGRATED_STORAGE_KEY, + JSON.stringify([product]), + ) + + const firstRender = renderSelector({ + eligibleSkuList: [], + initialNonIntegratedProducts: [product], + }) + + await actor.click(screen.getByRole('button', { name: '修改' })) + + expect(screen.getByRole('tab', { name: /DNZ0001/ })).toHaveAttribute( + 'aria-selected', + 'true', + ) + + firstRender.unmount() + + renderSelector({ + eligibleSkuList: [], + initialNonIntegratedProducts: [product], + }) + + expect(screen.getByRole('tab', { name: /DNZ0001/ })).toHaveAttribute( + 'aria-selected', + 'true', + ) + expect(screen.getByTestId('embedded-non-integrated-detail')).toHaveTextContent( + 'DNZ0001-1-edit', + ) + }) + it('opens a separate workspace tab each time product edit is clicked', async () => { const actor = userEvent.setup() diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx index 3f61153a..1e18a5bc 100644 --- a/furniture-3d-system/src/app/intake/IntakeClient.tsx +++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx @@ -334,6 +334,70 @@ type IntakeWorkspaceTab = }; const MAX_WORKSPACE_TABS_PER_PAGE = 3; +const INTAKE_WORKSPACE_STATE_STORAGE_KEY = "intake:workspace-state:v1"; + +type PersistedIntakeWorkspaceTab = + | { + id: string; + kind: "pendingEntry"; + entryTab: IntakeEntryTab; + } + | { + id: string; + kind: "progressDashboard"; + } + | { + id: string; + kind: "spuRuleLibrary"; + } + | { + id: string; + kind: "productPatentLedger"; + target?: ProductPatentLedgerTarget; + } + | { + id: string; + kind: "productPatentDashboard"; + } + | { + id: string; + kind: "nonIntegratedProduct"; + productId: string; + mode: WorkspaceProductMode; + productDevelopmentKind: ProductDevelopmentKind; + detailState?: WorkspaceProductDetailState; + } + | { + id: string; + kind: "newNonIntegratedProduct"; + productDevelopmentKind: ProductDevelopmentKind; + } + | { + id: string; + kind: "bomLibrary"; + bomLibrary: BomLibraryKey; + } + | { + id: string; + kind: "rawMaterialLibrary"; + rawMaterialLibrary: RawMaterialLibraryKey; + selectedMaterialName?: string; + } + | { + id: string; + kind: "referenceEstimator"; + }; + +type PersistedIntakeWorkspaceState = { + activeTabId: string | null; + tabs: PersistedIntakeWorkspaceTab[]; +}; + +type InitialIntakeWorkspaceState = { + activeTabId: string | null; + sequence: number; + tabs: IntakeWorkspaceTab[]; +}; type WorkbenchSku = { id: string; @@ -527,6 +591,17 @@ const TABLE_ACTION_BUTTON_PRIMARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-95 const INTAKE_PRIMARY_BUTTON_CLASS = "!border-zinc-950 !bg-zinc-950 !bg-none !text-white !shadow-none hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400"; type NonIntegratedQuickScope = "all" | "created" | "pending"; +type NonIntegratedProductKindFilter = "all" | "new" | "stock"; +type NonIntegratedListQuery = { + productKind: NonIntegratedProductKindFilter; + spu: string; + appearanceDesigner: string; + structureDesigner: string; + spuOwner: string; + planningOwner: string; + spaceDesigner: string; + purchaseOwner: string; +}; const BOM_LIBRARY_OPTIONS: { key: BomLibraryKey; @@ -614,6 +689,30 @@ function getProductDevelopmentKind( return product.productDevelopmentKind === "series" ? "series" : "product"; } +function isNonIntegratedStockProduct( + product: Pick< + NonIntegratedProductRecord, + "formalSpuAssigned" | "openingReference" | "processTemplate" | "spu" + >, +) { + const normalizedSpu = product.spu?.trim().toUpperCase() ?? ""; + const normalizedTemporaryCode = + product.openingReference?.temporaryCode?.trim().toUpperCase() ?? ""; + const normalizedProductName = + product.openingReference?.productName?.trim().toUpperCase() ?? ""; + + return ( + product.processTemplate?.trim() === "存量商品" || + product.openingReference?.currentStage?.trim() === "存量商品" || + Boolean( + product.formalSpuAssigned && + normalizedSpu && + (normalizedSpu === normalizedTemporaryCode || + normalizedSpu === normalizedProductName), + ) + ); +} + function getProductDevelopmentDetailLabel( product: NonIntegratedProductRecord, ) { @@ -649,6 +748,285 @@ function createNewNonIntegratedProductWorkspaceTab( }; } +function getIntakeWorkspaceStateStorageKey(userId: string) { + return `${INTAKE_WORKSPACE_STATE_STORAGE_KEY}:${userId || "anonymous"}`; +} + +function createDefaultIntakeWorkspaceState(): InitialIntakeWorkspaceState { + return { + tabs: [createPendingEntryWorkspaceTab("nonIntegrated")], + activeTabId: DEFAULT_WORKSPACE_TAB_ID, + sequence: 0, + }; +} + +function readWorkspaceTabSequence(tabId: string) { + const maybeSequence = Number(tabId.split(":").at(-1)); + return Number.isFinite(maybeSequence) ? maybeSequence : 0; +} + +function getMaxWorkspaceTabSequence(tabs: IntakeWorkspaceTab[]) { + return tabs.reduce( + (maxSequence, tab) => + Math.max(maxSequence, readWorkspaceTabSequence(tab.id)), + 0, + ); +} + +function normalizeWorkspaceProductDetailState( + value: unknown, +): WorkspaceProductDetailState | undefined { + if (!value || typeof value !== "object") return undefined; + + const record = value as Partial; + if ( + (record.view === "detail" || record.view === "document") && + record.category && + NON_INTEGRATED_DOCUMENT_WORKSPACE_CATEGORIES.includes(record.category) + ) { + return { + view: record.view, + category: record.category, + }; + } + + return undefined; +} + +function serializeIntakeWorkspaceTab( + tab: IntakeWorkspaceTab, +): PersistedIntakeWorkspaceTab { + if (tab.kind === "pendingEntry") { + return { id: tab.id, kind: tab.kind, entryTab: tab.entryTab }; + } + if (tab.kind === "nonIntegratedProduct") { + return { + id: tab.id, + kind: tab.kind, + productId: tab.productId, + mode: tab.mode, + productDevelopmentKind: tab.productDevelopmentKind, + detailState: tab.detailState, + }; + } + if (tab.kind === "newNonIntegratedProduct") { + return { + id: tab.id, + kind: tab.kind, + productDevelopmentKind: tab.productDevelopmentKind, + }; + } + if (tab.kind === "bomLibrary") { + return { id: tab.id, kind: tab.kind, bomLibrary: tab.bomLibrary }; + } + if (tab.kind === "rawMaterialLibrary") { + return { + id: tab.id, + kind: tab.kind, + rawMaterialLibrary: tab.rawMaterialLibrary, + selectedMaterialName: tab.selectedMaterialName, + }; + } + if (tab.kind === "productPatentLedger") { + return { id: tab.id, kind: tab.kind, target: tab.target }; + } + + return { id: tab.id, kind: tab.kind }; +} + +function restoreIntakeWorkspaceTab( + tab: PersistedIntakeWorkspaceTab, + productMap: Map, +): IntakeWorkspaceTab | null { + if (!tab.id || typeof tab.id !== "string") return null; + + if (tab.kind === "pendingEntry") { + if (tab.entryTab !== "integrated" && tab.entryTab !== "nonIntegrated") { + return null; + } + return createPendingEntryWorkspaceTab(tab.entryTab, tab.id); + } + if (tab.kind === "progressDashboard") { + return { id: tab.id, kind: tab.kind, label: "产品开发进度" }; + } + if (tab.kind === "spuRuleLibrary") { + return { id: tab.id, kind: tab.kind, label: "SPU规则库" }; + } + if (tab.kind === "productPatentLedger") { + return { + id: tab.id, + kind: tab.kind, + label: PRODUCT_PATENT_LEDGER_LABEL, + target: tab.target, + }; + } + if (tab.kind === "productPatentDashboard") { + return { + id: tab.id, + kind: tab.kind, + label: PRODUCT_PATENT_DASHBOARD_LABEL, + }; + } + if (tab.kind === "nonIntegratedProduct") { + const product = productMap.get(tab.productId); + if (!product) return null; + const productDevelopmentKind = getProductDevelopmentKind(product); + return { + id: tab.id, + kind: tab.kind, + label: getProductDevelopmentDetailLabel(product), + productId: product.id, + product, + mode: tab.mode === "view" ? "view" : "edit", + productDevelopmentKind, + detailState: normalizeWorkspaceProductDetailState(tab.detailState), + }; + } + if (tab.kind === "newNonIntegratedProduct") { + const productDevelopmentKind = + tab.productDevelopmentKind === "series" ? "series" : "product"; + return createNewNonIntegratedProductWorkspaceTab( + tab.id, + productDevelopmentKind, + ); + } + if (tab.kind === "bomLibrary") { + const option = BOM_LIBRARY_OPTIONS.find( + (item) => item.key === tab.bomLibrary, + ); + if (!option) return null; + return { + id: tab.id, + kind: tab.kind, + label: option.label, + bomLibrary: option.key, + }; + } + if (tab.kind === "rawMaterialLibrary") { + const option = RAW_MATERIAL_LIBRARY_OPTIONS.find( + (item) => item.key === tab.rawMaterialLibrary, + ); + if (!option) return null; + return { + id: tab.id, + kind: tab.kind, + label: option.label, + rawMaterialLibrary: option.key, + selectedMaterialName: tab.selectedMaterialName, + }; + } + if (tab.kind === "referenceEstimator") { + return { + id: tab.id, + kind: tab.kind, + label: REFERENCE_ESTIMATOR_WORKSPACE_LABEL, + }; + } + + return null; +} + +function readInitialIntakeWorkspaceState(params: { + products: NonIntegratedProductRecord[]; + userId: string; + shouldRestore: boolean; +}): InitialIntakeWorkspaceState { + if (!params.shouldRestore || typeof window === "undefined") { + return createDefaultIntakeWorkspaceState(); + } + + try { + const rawState = window.sessionStorage.getItem( + getIntakeWorkspaceStateStorageKey(params.userId), + ); + if (!rawState) return createDefaultIntakeWorkspaceState(); + + const parsed = JSON.parse(rawState) as Partial; + if (!Array.isArray(parsed.tabs)) { + return createDefaultIntakeWorkspaceState(); + } + + const productMap = new Map( + params.products.map((product) => [product.id, product]), + ); + const tabs = parsed.tabs + .map((tab) => + restoreIntakeWorkspaceTab( + tab as PersistedIntakeWorkspaceTab, + productMap, + ), + ) + .filter((tab): tab is IntakeWorkspaceTab => Boolean(tab)); + + if (tabs.length === 0) return createDefaultIntakeWorkspaceState(); + + const activeTabId = tabs.some((tab) => tab.id === parsed.activeTabId) + ? parsed.activeTabId ?? tabs[0]?.id ?? null + : tabs[0]?.id ?? null; + + return { + tabs, + activeTabId, + sequence: getMaxWorkspaceTabSequence(tabs), + }; + } catch { + return createDefaultIntakeWorkspaceState(); + } +} + +function persistIntakeWorkspaceState(params: { + activeTabId: string | null; + shouldPersist: boolean; + tabs: IntakeWorkspaceTab[]; + userId: string; +}) { + if (!params.shouldPersist || typeof window === "undefined") return; + + try { + const state: PersistedIntakeWorkspaceState = { + activeTabId: params.activeTabId, + tabs: params.tabs.map(serializeIntakeWorkspaceTab), + }; + window.sessionStorage.setItem( + getIntakeWorkspaceStateStorageKey(params.userId), + JSON.stringify(state), + ); + } catch { + // Ignore quota/private-mode failures; the UI can still rebuild the default tab. + } +} + +function getWorkspaceTabInitialSection(tab: IntakeWorkspaceTab | null) { + if (!tab) return "pending"; + if ( + tab.kind === "pendingEntry" || + tab.kind === "spuRuleLibrary" || + tab.kind === "nonIntegratedProduct" || + tab.kind === "newNonIntegratedProduct" + ) { + return "pending"; + } + if (tab.kind === "progressDashboard") return "progress"; + if (tab.kind === "productPatentLedger") return "patents"; + if (tab.kind === "productPatentDashboard") return "patents"; + if (tab.kind === "bomLibrary") return "bom"; + if (tab.kind === "rawMaterialLibrary") return "rawMaterialLibrary"; + return "referenceEstimator"; +} + +function getWorkspaceTabInitialPendingEntryTab(tab: IntakeWorkspaceTab | null) { + if (tab?.kind === "pendingEntry") return tab.entryTab; + if ( + tab?.kind === "nonIntegratedProduct" || + tab?.kind === "newNonIntegratedProduct" + ) { + return getPendingEntryTabFromProductDevelopmentKind( + tab.productDevelopmentKind, + ); + } + return "nonIntegrated"; +} + function getWorkspaceTabPageKey(tab: IntakeWorkspaceTab) { if (tab.kind === "pendingEntry") return `pending-entry:${tab.entryTab}`; if (tab.kind === "progressDashboard") return "product-progress:dashboard"; @@ -2047,7 +2425,8 @@ function NonIntegratedPendingDetail({ const [isCopySubmitting, setIsCopySubmitting] = useState(false); const [activeQuickScope, setActiveQuickScope] = useState("all"); - const [queryDraft, setQueryDraft] = useState({ + const [queryDraft, setQueryDraft] = useState({ + productKind: "all", spu: "", appearanceDesigner: "", structureDesigner: "", @@ -2056,7 +2435,8 @@ function NonIntegratedPendingDetail({ spaceDesigner: "", purchaseOwner: "", }); - const [appliedQuery, setAppliedQuery] = useState({ + const [appliedQuery, setAppliedQuery] = useState({ + productKind: "all", spu: "", appearanceDesigner: "", structureDesigner: "", @@ -2241,6 +2621,12 @@ function NonIntegratedPendingDetail({ const documentWorkspaceCountByCategory = getDocumentWorkspaceCountByCategory(product); const people = getNonIntegratedListPeople(product); + const stockProduct = isNonIntegratedStockProduct(product); + const productKindMatched = + appliedQuery.productKind === "all" || + (appliedQuery.productKind === "stock" + ? stockProduct + : !stockProduct); const spuMatched = normalizedSpuKeyword.length === 0 || productDisplayName.includes(normalizedSpuKeyword) || @@ -2270,11 +2656,16 @@ function NonIntegratedPendingDetail({ documentWorkspaceCountByCategory, )); - return spuMatched && peopleMatched && quickScopeMatched; + return ( + productKindMatched && + spuMatched && + peopleMatched && + quickScopeMatched + ); }); }, [activeQuickScope, appliedQuery, currentUser, products]); const filterFields: Array<{ - key: keyof typeof queryDraft; + key: Exclude; label: ReactNode; ariaLabel: string; placeholder: string; @@ -2349,7 +2740,31 @@ function NonIntegratedPendingDetail({ ))} -
+
+ {filterFields.map((filter) => (