diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx index 068bddc6..9a53b83f 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx @@ -17,6 +17,7 @@ import { cleanupSharedNonIntegratedAiWhiteBackgroundManifest, fetchSharedNonIntegratedAiWhiteBackgroundCapabilities, generateSharedNonIntegratedAiWhiteBackgroundSession, + listSharedNonIntegratedProductPage, previewSharedNonIntegratedDocumentImages, saveSharedNonIntegratedProduct, uploadSharedNonIntegratedAiWhiteBackgroundSession, @@ -181,6 +182,15 @@ jest.mock("../../../lib/model-preview-zip", () => ({ })); jest.mock("./nonIntegratedApi", () => ({ + listSharedNonIntegratedProductPage: jest.fn(async () => ({ + products: [], + pageInfo: { + page: 1, + pageSize: 10, + total: 0, + totalPages: 1, + }, + })), previewSharedNonIntegratedDocumentImages: jest.fn(), saveSharedNonIntegratedProduct: jest.fn(async (product) => product), fetchSharedNonIntegratedAiWhiteBackgroundCapabilities: jest.fn(), @@ -217,6 +227,9 @@ jest.mock("@/app/approval-workflows/approvalWorkflowApi", () => ({ const mockedPreviewSharedNonIntegratedDocumentImages = jest.mocked( previewSharedNonIntegratedDocumentImages, ); +const mockedListSharedNonIntegratedProductPage = jest.mocked( + listSharedNonIntegratedProductPage, +); const mockedFetchSharedNonIntegratedAiWhiteBackgroundCapabilities = jest.mocked( fetchSharedNonIntegratedAiWhiteBackgroundCapabilities, ); @@ -584,6 +597,16 @@ describe("NonIntegratedDetailClient", () => { (parseZipFile as jest.Mock).mockReset(); (extractPreviewModelFromZip as jest.Mock).mockReset(); mockedPreviewSharedNonIntegratedDocumentImages.mockReset(); + mockedListSharedNonIntegratedProductPage.mockReset(); + mockedListSharedNonIntegratedProductPage.mockResolvedValue({ + products: [], + pageInfo: { + page: 1, + pageSize: 10, + total: 0, + totalPages: 1, + }, + }); mockedPreviewSharedNonIntegratedDocumentImages.mockResolvedValue({ imageUrls: ["/api/intake/non-integrated/assets/document-previews/test/page-1.jpg"], }); @@ -1175,6 +1198,12 @@ describe("NonIntegratedDetailClient", () => { productCategory: "书桌", }), ); + expect(saveCall[0].skuItems[0]).toEqual( + expect.objectContaining({ + skuSequence: 10, + skuCode: "SZ023010", + }), + ); expect(saveCall[0].openingReference).toEqual( expect.objectContaining({ salesCategory: "桌类", @@ -1189,6 +1218,92 @@ describe("NonIntegratedDetailClient", () => { ); }); + it("searches stock product details when selecting an existing SPU for a new SKU flow", async () => { + jest.useFakeTimers(); + const actor = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + ( + globalThis as NonIntegratedRemoteAssetStorageTestGlobal + ).__NON_INTEGRATED_REMOTE_ASSET_STORAGE_TEST_OVERRIDE__ = true; + const stockProduct = createProductRecord({ + id: "jst-stock-dnz0384", + spu: "DNZ0384", + productCode: "DNZ0384", + formalSpuAssigned: true, + metadata: { + source: "jst_sku", + sourceSpu: "DNZ0384", + }, + productCategory: "涔︽", + openingReference: { + ...createEmptyNonIntegratedOpeningReference(), + productName: "DNZ0384瀛橀噺鍟嗗搧", + productCategory: "涔︽", + }, + skuItems: [ + createSkuItem({ + skuSequence: 16, + skuCode: "JST-OLD-SKU", + barcode: "JST-OLD-BARCODE", + }), + ], + }); + mockedListSharedNonIntegratedProductPage.mockResolvedValue({ + products: [stockProduct], + pageInfo: { + page: 1, + pageSize: 10, + total: 1, + totalPages: 1, + }, + }); + + try { + render( + , + ); + + await actor.click(screen.getByTestId("spu-create-mode-existing")); + const existingSpuSelect = await screen.findByTestId( + "existing-spu-create-select", + ); + await actor.click(existingSpuSelect); + await actor.type(existingSpuSelect, "DNZ0384"); + jest.advanceTimersByTime(300); + + await waitFor(() => + expect(mockedListSharedNonIntegratedProductPage).toHaveBeenCalledWith( + expect.objectContaining({ + pageSize: 10, + productKind: "stock", + spu: "DNZ0384", + }), + ), + ); + + const listboxes = await screen.findAllByRole("listbox"); + const activeListbox = listboxes[listboxes.length - 1]!; + const stockOption = Array.from( + activeListbox.querySelectorAll('[role="option"]'), + ).find((option) => option.value === "DNZ0384"); + expect(stockOption).toBeDefined(); + await actor.click(stockOption!); + + expect( + (screen.getByTestId("existing-spu-create-select") as HTMLInputElement) + .value, + ).toBe("DNZ0384"); + } finally { + jest.useRealTimers(); + } + }); + it("shows series component details from the barcode magnifier without leaking them into attribute columns", async () => { const actor = userEvent.setup(); const legacyComponentCodes = "SPJ01/SPJ07/SPJ09/SPJ10"; diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx index 006a7054..64f8892a 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx @@ -186,6 +186,7 @@ import { export { buildNonIntegratedBomDraftStorageKey } from "./workspaces/bom/bomDetailStateHelpers"; import { syncProductPatentRecordFromProduct } from "./workspaces/patent/productPatentSyncHelpers"; import { + listSharedNonIntegratedProductPage, saveSharedNonIntegratedProduct, } from "./nonIntegratedApi"; import { buildNonIntegratedBasicInfoOptions } from "./nonIntegratedBasicInfoOptions"; @@ -432,6 +433,10 @@ export default function NonIntegratedDetailClient({ const [spuCreateMode, setSpuCreateMode] = useState("auto"); const [selectedExistingSpu, setSelectedExistingSpu] = useState(""); + const [existingSpuRemoteQuery, setExistingSpuRemoteQuery] = useState(""); + const [remoteExistingSpuProducts, setRemoteExistingSpuProducts] = useState< + NonIntegratedProductRecord[] + >([]); const [stockProductSpu, setStockProductSpu] = useState(""); const [formValues, setFormValues] = useState(() => createFormValuesFromProduct(existingProduct), @@ -1012,6 +1017,54 @@ export default function NonIntegratedDetailClient({ seriesDraftSkuOptions, seriesNameOptions, }); + useEffect(() => { + if ( + mode !== "create" || + !canUseExistingSpuCreateMode || + spuCreateMode !== "existing" || + !shouldUseRemoteAssetStorage() + ) { + setRemoteExistingSpuProducts([]); + return; + } + + const normalizedQuery = existingSpuRemoteQuery.trim(); + if (normalizedQuery.length < 2) { + setRemoteExistingSpuProducts([]); + return; + } + + let cancelled = false; + const timeoutId = window.setTimeout(() => { + listSharedNonIntegratedProductPage({ + page: 1, + pageSize: 10, + productDevelopmentKind: "product", + productKind: "stock", + spu: normalizedQuery, + }) + .then((data) => { + if (!cancelled) { + setRemoteExistingSpuProducts(data.products); + } + }) + .catch(() => { + if (!cancelled) { + setRemoteExistingSpuProducts([]); + } + }); + }, 250); + + return () => { + cancelled = true; + window.clearTimeout(timeoutId); + }; + }, [ + canUseExistingSpuCreateMode, + existingSpuRemoteQuery, + mode, + spuCreateMode, + ]); const { existingSpuCreateOptions, existingSpuSelectOptions, @@ -1021,6 +1074,7 @@ export default function NonIntegratedDetailClient({ } = useExistingSpuCreateState({ canUseExistingSpuCreateMode, existingSpuProducts, + remoteExistingSpuProducts, selectedExistingSpu, spuCreateMode, spuEstimateRefreshToken, @@ -4593,6 +4647,7 @@ export default function NonIntegratedDetailClient({ selectedExistingSpuOption={ Boolean(selectedExistingSpuOption) } + onExistingSpuQueryChange={setExistingSpuRemoteQuery} seriesCreateEstimatedSpuLabel={ seriesCreateEstimatedSpuLabel } diff --git a/furniture-3d-system/src/app/intake/non-integrated/components/InlineControls.tsx b/furniture-3d-system/src/app/intake/non-integrated/components/InlineControls.tsx index a691f4e1..590956e8 100644 --- a/furniture-3d-system/src/app/intake/non-integrated/components/InlineControls.tsx +++ b/furniture-3d-system/src/app/intake/non-integrated/components/InlineControls.tsx @@ -488,6 +488,8 @@ export function SearchableInlineSelect({ optionClassName = "flex w-full items-center rounded-apple-md px-2.5 py-1.5 text-left text-xs transition", includeEmptyOption, emptyOptionDisabled = false, + maxVisibleOptions, + onQueryChange, onChange, }: { ariaLabel: string; @@ -502,6 +504,8 @@ export function SearchableInlineSelect({ optionClassName?: string; includeEmptyOption?: boolean; emptyOptionDisabled?: boolean; + maxVisibleOptions?: number; + onQueryChange?: (value: string) => void; onChange: (value: string) => void; }) { const listId = useId(); @@ -528,15 +532,27 @@ export function SearchableInlineSelect({ const normalizedDisplayValue = displayValue.trim().toLowerCase(); const shouldFilterOptions = normalizedQuery.length > 0 && normalizedQuery !== normalizedDisplayValue; - const visibleOptions = selectOptions.filter((option) => { + const filteredOptions = selectOptions.filter((option) => { if (!shouldFilterOptions) return true; return ( option.label.toLowerCase().includes(normalizedQuery) || option.value.toLowerCase().includes(normalizedQuery) ); }); - const hasOptions = !readOnly && selectOptions.length > 0; - const floatingPosition = useFloatingPanelPosition(isOpen && hasOptions, containerRef); + const normalizedMaxVisibleOptions = + typeof maxVisibleOptions === "number" && Number.isFinite(maxVisibleOptions) + ? Math.max(1, Math.floor(maxVisibleOptions)) + : null; + const visibleOptions = normalizedMaxVisibleOptions + ? filteredOptions.slice(0, normalizedMaxVisibleOptions) + : filteredOptions; + const hasOptions = + !readOnly && (selectOptions.length > 0 || Boolean(onQueryChange)); + const hasVisiblePanel = hasOptions && selectOptions.length > 0; + const floatingPosition = useFloatingPanelPosition( + isOpen && hasVisiblePanel, + containerRef, + ); useEffect(() => { if (isOpen) return; @@ -597,7 +613,9 @@ export function SearchableInlineSelect({ onFocus={openPanel} onChange={(event) => { if (!hasOptions) return; - setQuery(event.target.value); + const nextQuery = event.target.value; + setQuery(nextQuery); + onQueryChange?.(nextQuery); setIsOpen(true); }} onKeyDown={(event) => { @@ -641,7 +659,7 @@ export function SearchableInlineSelect({