diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index ab467856..793d3454 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -405,6 +405,65 @@ export const getBusyStockTakeSections = async () => { ); }; +export type StockTakeCreateLotOption = { + inventoryLotLineId: number; + itemId: number; + itemCode: string | null; + itemName: string | null; + lotId: number; + lotNo: string | null; + expiryDate: string | null; + warehouseId: number; + warehouseCode: string | null; + stockTakeSection: string | null; + storeId: string | null; + availableQty: number; + busy: boolean; +}; + +export type StockTakeCreateItemOption = { + id: number; + code: string | null; + name: string | null; +}; + +/** 來源:All / PP / PF / other(與庫存批次現況報告一致) */ +export const getAvailableLotsForStockTakeCreate = async ( + itemIds: number[], + lotOrigin?: string | null, +) => { + const ids = itemIds.filter((id) => id > 0); + if (ids.length === 0) return [] as StockTakeCreateLotOption[]; + const params = new URLSearchParams(); + ids.forEach((id) => params.append("itemIds", String(id))); + const origin = lotOrigin?.trim(); + if (origin && origin.toLowerCase() !== "all") params.set("lotOrigin", origin); + return serverFetchJson( + `${BASE_API_URL}/stockTakeRecord/availableLotsForCreate?${params.toString()}`, + { method: "GET" }, + ); +}; + +export const searchItemsForStockTakeCreate = async (opts: { + code?: string | null; + name?: string | null; + lotOrigin?: string | null; +}) => { + const params = new URLSearchParams(); + const code = opts.code?.trim(); + const name = opts.name?.trim(); + const origin = opts.lotOrigin?.trim(); + if (code) params.set("code", code); + if (name) params.set("name", name); + if (origin && origin.toLowerCase() !== "all") params.set("lotOrigin", origin); + // 無編號/名稱且來源=全部 → 不查(避免一次拉全庫) + if (!params.toString()) return [] as StockTakeCreateItemOption[]; + return serverFetchJson( + `${BASE_API_URL}/stockTakeRecord/searchItemsForCreate?${params.toString()}`, + { method: "GET" }, + ); +}; + export const closeStockTakeRound = async (stockTakeRoundId: number) => { return serverFetchJson<{ success: boolean; message?: string; closedCount?: number }>( `${BASE_API_URL}/stockTakeRecord/closeRound`, @@ -431,9 +490,11 @@ export const createStockTakeForItems = async ( itemIds: number[], stockTakeRoundName?: string | null, planStart?: string | null, + inventoryLotLineIds?: number[] | null, ) => { const trimmedName = stockTakeRoundName?.trim(); const trimmedPlanStart = planStart?.trim(); + const lotLineIds = (inventoryLotLineIds || []).filter((id) => id > 0); return serverFetchJson>( `${BASE_API_URL}/stockTake/createForItems`, { @@ -443,6 +504,7 @@ export const createStockTakeForItems = async ( }, body: JSON.stringify({ itemIds, + ...(lotLineIds.length > 0 ? { inventoryLotLineIds: lotLineIds } : {}), ...(trimmedName ? { stockTakeRoundName: trimmedName } : {}), ...(trimmedPlanStart ? { planStart: trimmedPlanStart } : {}), }), diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index f1bbe23d..944129d1 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -1651,6 +1651,7 @@ const ApproverStockTakeAll: React.FC = ({ { if (next == null) return; @@ -1658,13 +1659,27 @@ const ApproverStockTakeAll: React.FC = ({ setSearchVarianceFilterInclusive(inclusive); handleSearch({ varianceFilterInclusive: inclusive }); }} + sx={{ + "& .MuiToggleButton-root": { + textTransform: "none", + px: 1.5, + fontWeight: 500, + borderColor: "primary.main", + color: "text.secondary", + "&.Mui-selected": { + bgcolor: "primary.main", + color: "primary.contrastText", + fontWeight: 700, + "&:hover": { + bgcolor: "primary.dark", + color: "primary.contrastText", + }, + }, + }, + }} > - - {t("Variance out of range")} - - - {t("Variance in range")} - + {t("Variance out of range")} + {t("Variance in range")} (items: T[], size: number): T[][] { type CreateDialogItemHit = { id: number; code: string; name: string }; -function itemRecordsFromPage(res: unknown): { id: string | number; code?: string; name?: string }[] { - const records = (res as { records?: unknown[] })?.records; - return Array.isArray(records) - ? (records as { id: string | number; code?: string; name?: string }[]) - : []; -} - function withAllOption(options: string[]): string[] { return options.length > 1 ? [LOCATION_ALL, ...options] : options; } @@ -351,15 +345,17 @@ const PickerCardList: React.FC = ({ const [createFloorTabIndex, setCreateFloorTabIndex] = useState(0); const [createDialogSearchQuery, setCreateDialogSearchQuery] = useState(""); const [createMode, setCreateMode] = useState<"section" | "item">("section"); - /** Open item round → hide 「依區域」; open section round → hide 「依貨品」 (symmetric). */ - const [blockSectionCreate, setBlockSectionCreate] = useState(false); - const [blockItemCreate, setBlockItemCreate] = useState(false); const [openingCreateDialog, setOpeningCreateDialog] = useState(false); const openCreateDialogLockRef = useRef(false); const [createDialogItemCode, setCreateDialogItemCode] = useState(""); const [createDialogItemName, setCreateDialogItemName] = useState(""); + /** 來源:All / PP / PF / other(同庫存批次現況報告) */ + const [createDialogLotOrigin, setCreateDialogLotOrigin] = useState("All"); const [createDialogItemHits, setCreateDialogItemHits] = useState([]); const [createDialogSelectedItems, setCreateDialogSelectedItems] = useState([]); + const [createDialogLotOptions, setCreateDialogLotOptions] = useState([]); + const [createDialogSelectedLotLineIds, setCreateDialogSelectedLotLineIds] = useState([]); + const [createDialogLotsLoading, setCreateDialogLotsLoading] = useState(false); const [createDialogItemSearching, setCreateDialogItemSearching] = useState(false); const [sectionsLoading, setSectionsLoading] = useState(false); const [stockTakeSectionRows, setStockTakeSectionRows] = useState([]); @@ -425,45 +421,18 @@ const PickerCardList: React.FC = ({ }; }, []); - const applyCreateModeBlocksFromRounds = useCallback( - (rounds: { open?: boolean; stockTakeRoundMode?: string | null }[]) => { - const openRounds = rounds || []; - const blockSection = openRounds.some( - (round) => - round.open && - String(round.stockTakeRoundMode ?? "").trim().toLowerCase() === "item", - ); - const blockItem = openRounds.some((round) => { - if (!round.open) return false; - const mode = String(round.stockTakeRoundMode ?? "section").trim().toLowerCase(); - return mode === "section" || mode === ""; - }); - setBlockSectionCreate(blockSection); - setBlockItemCreate(blockItem); - if (!blockSection) setCreateMode("section"); - else if (!blockItem) setCreateMode("item"); - }, - [], - ); - const openCreateDialog = useCallback(async () => { if (openCreateDialogLockRef.current) return; openCreateDialogLockRef.current = true; setOpeningCreateDialog(true); try { - const res = await getStockTakeRoundCards(); - applyCreateModeBlocksFromRounds(res.rounds || []); - } catch (e) { - console.error("Failed to check open stock take create modes:", e); - setBlockSectionCreate(false); - setBlockItemCreate(false); setCreateMode("section"); } finally { setOpeningCreateDialog(false); openCreateDialogLockRef.current = false; setOpenConfirmDialog(true); } - }, [applyCreateModeBlocksFromRounds]); + }, []); useEffect(() => { if (autoOpenCreate) void openCreateDialog(); @@ -789,14 +758,14 @@ const PickerCardList: React.FC = ({ if (createDialogPlanStart == null || !createDialogPlanStart.isValid()) return; if (createDialogPlanStart.startOf("day").isBefore(dayjs().startOf("day"))) return; if (createMode === "item") { - if (createDialogSelectedItems.length === 0) return; + if (createDialogSelectedLotLineIds.length === 0) return; } else if (createDialogSelectedSections.length === 0) { return; } setOpenCreateStockTakeSummaryConfirm(true); }, [ createDialogPlanStart, - createDialogSelectedItems.length, + createDialogSelectedLotLineIds.length, createDialogSelectedSections.length, createMode, ]); @@ -1070,7 +1039,7 @@ const PickerCardList: React.FC = ({ const handleCreateStockTake = useCallback(async () => { if (createStockTakeInFlightRef.current) return; if (createMode === "item") { - if (createDialogSelectedItems.length === 0) return; + if (createDialogSelectedLotLineIds.length === 0) return; } else if (createDialogSelectedSections.length === 0) { return; } @@ -1089,6 +1058,7 @@ const PickerCardList: React.FC = ({ createDialogSelectedItems.map((it) => it.id), createDialogRoundName, planStart, + createDialogSelectedLotLineIds, ) : await createStockTakeForSections( createDialogSelectedSections, @@ -1112,7 +1082,9 @@ const PickerCardList: React.FC = ({ setCreateDialogRoundName(""); setCreateDialogPlanStart(dayjs()); setCreateDialogSelectedItems([]); - setCreateMode(blockSectionCreate && !blockItemCreate ? "item" : "section"); + setCreateDialogLotOptions([]); + setCreateDialogSelectedLotLineIds([]); + setCreateMode("section"); } catch (e) { console.error(e); } finally { @@ -1120,11 +1092,10 @@ const PickerCardList: React.FC = ({ createStockTakeInFlightRef.current = false; } }, [ - blockItemCreate, - blockSectionCreate, createDialogPlanStart, createDialogRoundName, createDialogSelectedItems, + createDialogSelectedLotLineIds, createDialogSelectedSections, createMode, onListPageChange, @@ -1135,25 +1106,30 @@ const PickerCardList: React.FC = ({ if (!openConfirmDialog || createMode !== "item") return; const code = createDialogItemCode.trim(); const name = createDialogItemName.trim(); - if (!code && !name) { + const origin = createDialogLotOrigin.trim(); + const hasOrigin = origin !== "" && origin.toLowerCase() !== "all"; + // 來源=全部:需編號或名稱;來源=PP/PF/其他:可只依來源列出 + if (!code && !name && !hasOrigin) { setCreateDialogItemHits([]); return; } let cancelled = false; const timer = window.setTimeout(() => { setCreateDialogItemSearching(true); - const params: Record = { pageSize: 300, pageNum: 1 }; - if (code) params.code = code; - if (name) params.name = name; - fetchItemsByPage(params) - .then((page) => { + searchItemsForStockTakeCreate({ + code, + name, + lotOrigin: createDialogLotOrigin, + }) + .then((rows) => { if (cancelled) return; - const uniq: CreateDialogItemHit[] = []; - itemRecordsFromPage(page).forEach((row) => { - const id = Number(row.id); - if (!id) return; - uniq.push({ id, code: row.code || "", name: row.name || "" }); - }); + const uniq: CreateDialogItemHit[] = (Array.isArray(rows) ? rows : []) + .map((row) => ({ + id: Number(row.id), + code: row.code || "", + name: row.name || "", + })) + .filter((row) => row.id > 0); uniq.sort((a, b) => a.code.localeCompare(b.code, undefined, { numeric: true, sensitivity: "base" }), ); @@ -1171,7 +1147,56 @@ const PickerCardList: React.FC = ({ cancelled = true; window.clearTimeout(timer); }; - }, [openConfirmDialog, createMode, createDialogItemCode, createDialogItemName]); + }, [ + openConfirmDialog, + createMode, + createDialogItemCode, + createDialogItemName, + createDialogLotOrigin, + ]); + + useEffect(() => { + if (!openConfirmDialog || createMode !== "item") { + setCreateDialogLotOptions([]); + return; + } + const itemIds = createDialogSelectedItems.map((it) => it.id); + if (itemIds.length === 0) { + setCreateDialogLotOptions([]); + setCreateDialogSelectedLotLineIds([]); + return; + } + let cancelled = false; + setCreateDialogLotsLoading(true); + getAvailableLotsForStockTakeCreate(itemIds, createDialogLotOrigin) + .then((lots) => { + if (cancelled) return; + const list = Array.isArray(lots) ? lots : []; + setCreateDialogLotOptions(list); + setCreateDialogSelectedLotLineIds((prev) => { + const freeIds = list.filter((l) => !l.busy).map((l) => l.inventoryLotLineId); + const selectable = new Set(freeIds); + const kept = prev.filter((id) => selectable.has(id)); + const keptSet = new Set(kept); + // 保留已選,並自動勾上新貨品的可盤 lot + return [...kept, ...freeIds.filter((id) => !keptSet.has(id))]; + }); + }) + .catch((e) => { + console.error("Failed to load lots for stock take create:", e); + if (!cancelled) { + setCreateDialogLotOptions([]); + setCreateDialogSelectedLotLineIds([]); + } + }) + .finally(() => { + if (!cancelled) setCreateDialogLotsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [openConfirmDialog, createMode, createDialogSelectedItems, createDialogLotOrigin]); + useEffect(() => { setSectionsLoading(true); fetchStockTakeSections() @@ -1285,11 +1310,14 @@ const PickerCardList: React.FC = ({ setOpenConfirmDialog(false); setCreateDialogRoundName(""); setCreateDialogPlanStart(dayjs()); - setCreateMode(blockSectionCreate && !blockItemCreate ? "item" : "section"); + setCreateMode("section"); setCreateDialogItemCode(""); setCreateDialogItemName(""); + setCreateDialogLotOrigin("All"); setCreateDialogItemHits([]); setCreateDialogSelectedItems([]); + setCreateDialogLotOptions([]); + setCreateDialogSelectedLotLineIds([]); onCreateDialogClose?.(); }; return ( @@ -1801,34 +1829,16 @@ const PickerCardList: React.FC = ({ - {blockSectionCreate && blockItemCreate ? ( - - {t("Cannot create while both section and item rounds are open")} - - ) : ( { if (!v) return; - if (v === "section" && blockSectionCreate) return; - if (v === "item" && blockItemCreate) return; setCreateMode(v); }} > - {blockSectionCreate ? null : ( - - )} - {blockItemCreate ? null : ( - - )} + + - )} = ({ )} - {createMode === "item" ? t("Total selected items label") : t("Total selected sections label")}{" "} + {createMode === "item" ? t("Total selected lots label") : t("Total selected sections label")}{" "} {createMode === "item" - ? createDialogSelectedItems.length + ? createDialogSelectedLotLineIds.length : createDialogSelectedSections.length} {" "} - {createMode === "item" ? t("items unit") : t("sections unit")} + {createMode === "item" ? t("lots unit") : t("sections unit")} @@ -2050,9 +2060,23 @@ const PickerCardList: React.FC = ({ }} sx={createDialogCompactInputSx} /> + + {t("Lot Origin")} + + - {t("Create by item selected count", { count: createDialogSelectedItems.length })} + {t("Create by item hint")} {createDialogSelectedItems.map((it) => ( @@ -2066,54 +2090,152 @@ const PickerCardList: React.FC = ({ /> ))} - - {createDialogItemSearching ? ( - - - - ) : createDialogItemHits.length === 0 ? ( - - {createDialogItemCode.trim() || createDialogItemName.trim() - ? t("No data") - : t("Create by item search hint")} + + + + {t("Create by item search hint")} - ) : ( - - {createDialogItemHits.map((it) => { - const checked = createDialogSelectedItems.some((x) => x.id === it.id); - return ( - - { - setCreateDialogSelectedItems((prev) => - checked ? prev.filter((x) => x.id !== it.id) : [...prev, it], - ); + {createDialogItemSearching ? ( + + + + ) : createDialogItemHits.length === 0 ? ( + + {createDialogItemCode.trim() || + createDialogItemName.trim() || + (createDialogLotOrigin.trim() !== "" && + createDialogLotOrigin.trim().toLowerCase() !== "all") + ? t("No data") + : t("Create by item search hint")} + + ) : ( + + {createDialogItemHits.map((it) => { + const checked = createDialogSelectedItems.some((x) => x.id === it.id); + return ( + - - - {it.code} - - - {it.name} - + > + { + setCreateDialogSelectedItems((prev) => + checked ? prev.filter((x) => x.id !== it.id) : [...prev, it], + ); + }} + /> + + + {it.code} + + + {it.name} + + - - ); - })} + ); + })} + + )} + + + + + {t("Create by item lot detail", { + count: createDialogSelectedLotLineIds.length, + })} + + - )} + {createDialogLotsLoading ? ( + + + + ) : createDialogSelectedItems.length === 0 ? ( + + {t("Create by item select item first")} + + ) : createDialogLotOptions.length === 0 ? ( + + {t("No available lots for selected items")} + + ) : ( + + {createDialogLotOptions.map((lot) => { + const checked = createDialogSelectedLotLineIds.includes(lot.inventoryLotLineId); + return ( + + { + if (lot.busy) return; + setCreateDialogSelectedLotLineIds((prev) => + checked + ? prev.filter((id) => id !== lot.inventoryLotLineId) + : [...prev, lot.inventoryLotLineId], + ); + }} + /> + + + {lot.itemCode} · {lot.lotNo || "-"} + + + {lot.stockTakeSection || "-"} + {lot.warehouseCode ? ` · ${lot.warehouseCode}` : ""} + + {lot.busy ? ( + + {t("Lot busy in open stock take")} + + ) : null} + + + ); + })} + + )} + ) : activeCreateFloorKey != null ? ( @@ -2327,7 +2449,7 @@ const PickerCardList: React.FC = ({ disabled={ creating || (createMode === "item" - ? createDialogSelectedItems.length === 0 + ? createDialogSelectedLotLineIds.length === 0 : createDialogSelectedSections.length === 0) || createDialogPlanStart == null || !createDialogPlanStart.isValid() || @@ -2365,13 +2487,13 @@ const PickerCardList: React.FC = ({ : "-"} - {createMode === "item" ? t("Total selected items label") : t("Total selected sections label")}{" "} - + {createMode === "item" ? t("Total selected lots label") : t("Total selected sections label")}{" "} + {createMode === "item" - ? createDialogSelectedItems.length + ? createDialogSelectedLotLineIds.length : createStockTakeSummarySections.length} {" "} - {createMode === "item" ? t("items unit") : t("sections unit")} + {createMode === "item" ? t("lots unit") : t("sections unit")} = ({ > {createMode === "item" - ? createDialogSelectedItems.map((it) => ( - - - {it.code} - - - {it.name} - - - )) + ? createDialogLotOptions + .filter((lot) => createDialogSelectedLotLineIds.includes(lot.inventoryLotLineId)) + .map((lot) => ( + + + {lot.itemCode} · {lot.lotNo || "-"} + + + {lot.stockTakeSection || "-"} + {lot.warehouseCode ? ` · ${lot.warehouseCode}` : ""} + + + )) : createStockTakeSummarySections.map((section) => { const meta = getCreateDialogSectionMeta(section); return ( @@ -2428,7 +2553,7 @@ const PickerCardList: React.FC = ({ disabled={ creating || (createMode === "item" - ? createDialogSelectedItems.length === 0 + ? createDialogSelectedLotLineIds.length === 0 : createDialogSelectedSections.length === 0) } > diff --git a/src/i18n/en/stockTake.json b/src/i18n/en/stockTake.json index 923a455d..a30ef59d 100644 --- a/src/i18n/en/stockTake.json +++ b/src/i18n/en/stockTake.json @@ -61,12 +61,20 @@ "Create by item": "By item", "Create by section": "By section", "Cannot create while both section and item rounds are open": "Cannot create a new round while both section and item rounds are open", - "Create by item hint": "Search and select items. This round includes available lots of these items in every floor / warehouse.", - "Create by item search hint": "Enter an item code, an item name, or both. Both must match when filled.", + "Lot Origin": "Lot origin", + "Create by item hint": "Only items with available lots are listed. Pick origin (PP / PF / Other) to load the list, optionally narrow by code/name, then tick lots on the right.", + "Create by item search hint": "Pick an origin to list items, or also enter code/name to narrow. Both keywords must match when filled.", "Create by item selected count": "{{count}} item(s) selected", + "Create by item lot detail": "Lots ({{count}} selected)", + "Create by item select item first": "Select item(s) on the left first", + "No available lots for selected items": "No available lots for the selected items", + "Lot busy in open stock take": "Already in an open stock-take round", + "Select all free lots": "Select all free lots", "Search item code or name": "Search item code or name", "Total selected items label": "Selected items", + "Total selected lots label": "Selected lots", "items unit": "item(s)", + "lots unit": "lot(s)", "Create Stock Take (Select Sections)": "Create stock take (select sections)", "Created": "Created", "Creation date": "Creation date", diff --git a/src/i18n/zh/stockTake.json b/src/i18n/zh/stockTake.json index 05d1554c..86ba1945 100644 --- a/src/i18n/zh/stockTake.json +++ b/src/i18n/zh/stockTake.json @@ -144,12 +144,20 @@ "Create by item": "依貨品", "Create by section": "依區域", "Cannot create while both section and item rounds are open": "目前同時有開著的依區域與依貨品盤點輪,無法再建立新輪", - "Create by item hint": "搜尋並勾選貨品。此輪會納入這些貨在所有樓層/倉的可用庫存。", - "Create by item search hint": "可分別輸入貨品編號、貨品名稱;兩個都填時要同時符合", + "Lot Origin": "來源", + "Create by item hint": "只列出有可用庫存的貨品。選來源(PP/PF/其他)即可列出;也可再輸入編號/名稱縮小。先勾選貨品,再於右側勾選批號/位置。", + "Create by item search hint": "可只選來源列出;或再輸入編號/名稱縮小。兩個關鍵字都填時要同時符合", "Create by item selected count": "已選 {{count}} 項貨品", + "Create by item lot detail": "可選批號(已選 {{count}})", + "Create by item select item first": "請先在左側勾選貨品", + "No available lots for selected items": "所選貨品沒有可用批號", + "Lot busy in open stock take": "已在進行中的盤點輪次", + "Select all free lots": "全選可盤批號", "Search item code or name": "搜尋貨品編號或名稱", "Total selected items label": "已選貨品", + "Total selected lots label": "已選批號", "items unit": "項", + "lots unit": "筆", "Create Stock Take (Select Sections)": "建立盤點(選擇區域)", "Total Item Kind Number": "貨品種類數量", "View ReStockTake": "查看重新盤點",