From e40405f419aadd98f1f1fcf1833645df5b421f44 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 30 Sep 2026 10:45:24 +0800 Subject: [PATCH] search ui update --- src/app/api/stockTake/actions.ts | 2 +- src/app/api/warehouse/client.ts | 12 + .../ApproverStockTakeAll.tsx | 898 +++++++++++++----- .../StockTakeManagement/PickerCardList.tsx | 789 ++++++++++++--- .../StockTakeManagement/StockTakeTab.tsx | 132 ++- src/i18n/en/stockTake.json | 8 + src/i18n/zh/stockTake.json | 8 + 7 files changed, 1434 insertions(+), 415 deletions(-) diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index 4118c777..53d567b9 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -114,7 +114,7 @@ export const getInventoryLotDetailsBySection = async ( }, ); - console.log('[API] Response received:', response); + //console.log('[API] Response received:', response); return response; } export interface SaveStockTakeRecordRequest { diff --git a/src/app/api/warehouse/client.ts b/src/app/api/warehouse/client.ts index aff2e234..ba833d2d 100644 --- a/src/app/api/warehouse/client.ts +++ b/src/app/api/warehouse/client.ts @@ -58,7 +58,19 @@ export const printWarehouseQrCode = async (data: PrintWarehouseQrCodeRequest): P } }; +let warehouseListCache: Promise | null = null; + export const fetchWarehouseListClient = async (): Promise => { + if (!warehouseListCache) { + warehouseListCache = fetchWarehouseListFromApi().catch((error) => { + warehouseListCache = null; + throw error; + }); + } + return warehouseListCache; +}; + +const fetchWarehouseListFromApi = async (): Promise => { const token = localStorage.getItem("accessToken"); const response = await fetch(`${NEXT_PUBLIC_API_URL}/warehouse`, { diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index 11f94ec4..18592b85 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -20,14 +20,16 @@ import { TableSortLabel, Card, CardContent, - CardActions, Grid, FormControl, InputLabel, Select, MenuItem, - Autocomplete, FormControlLabel, + Tab, + Tabs, + ToggleButton, + ToggleButtonGroup, Checkbox, Dialog, DialogTitle, @@ -50,9 +52,9 @@ import { getApproverInventoryLotDetailsAllPending, getApproverInventoryLotDetailsAllApproved, updateStockTakeRecordStatusToNotMatch, - type ApproverInventoryLotDetailsQuery, } from "@/app/api/stockTake/actions"; -import { fetchStockTakeSections } from "@/app/api/warehouse/actions"; +import { fetchWarehouseListClient } from "@/app/api/warehouse/client"; +import type { WarehouseResult } from "@/app/api/warehouse"; import { stockTakeQtyEndAdornment, StockTakeQtyWithUnit } from "./stockTakeQtyAdornment"; import StockTakeQtyGapHint from "./StockTakeQtyGapHint"; import { stockTakeHiddenOnHand, stockTakeQtyGapWarnText } from "./stockTakeQtyGapWarning"; @@ -76,6 +78,7 @@ interface ApproverStockTakeAllProps { } type QtySelectionType = "first" | "second" | "approver"; +type ApproverSearchKind = "location" | "section"; type ApprovedSortKey = | "stockTakeEndTime" @@ -84,6 +87,64 @@ type ApprovedSortKey = | "stockTakerName" | "variance"; +const APPROVER_SEARCH_FIELD_HEIGHT = 56; +const APPROVER_MENU_MAX_HEIGHT = 320; +const APPROVER_LOCATION_ALL = "ALL"; +const APPROVER_LOCATION_BUTTONS_PER_ROW = 15; +const APPROVER_LOCATION_BUTTON_WIDTH = 80; + +const approverSearchFieldSx = { + "& .MuiOutlinedInput-root": { + height: APPROVER_SEARCH_FIELD_HEIGHT, + minHeight: APPROVER_SEARCH_FIELD_HEIGHT, + boxSizing: "border-box", + alignItems: "center", + }, + "& .MuiSelect-select": { + height: `${APPROVER_SEARCH_FIELD_HEIGHT}px !important`, + minHeight: "0 !important", + display: "flex", + alignItems: "center", + boxSizing: "border-box", + py: "0 !important", + }, +} as const; + +const approverSearchMenuProps = { + PaperProps: { sx: { maxHeight: APPROVER_MENU_MAX_HEIGHT } }, +} as const; + +function chunkApproverOptions(items: T[], size: number): T[][] { + const rows: T[][] = []; + for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size)); + return rows; +} + +function withApproverAllOption(options: string[]): string[] { + return options.length > 1 ? [APPROVER_LOCATION_ALL, ...options] : options; +} + +function compareApproverLabel(a: string, b: string): number { + return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); +} + +function selectedApproverSectionCodes(value: string): string[] { + return value.split(",").map((part) => part.trim()).filter(Boolean); +} + +type ApproverLocationPick = { storeId: string; warehouse: string; area: string; slot: string }; + +function approverWarehouseKeyword(pick: ApproverLocationPick): string { + const warehouse = pick.warehouse && pick.warehouse !== APPROVER_LOCATION_ALL ? pick.warehouse : ""; + const area = pick.area && pick.area !== APPROVER_LOCATION_ALL ? pick.area : ""; + const slot = pick.slot && pick.slot !== APPROVER_LOCATION_ALL ? pick.slot : ""; + if (warehouse && area && slot) return `${warehouse}-${area}-${slot}`; + if (warehouse && area) return `${warehouse}-${area}`; + if (warehouse) return warehouse; + if (pick.storeId && pick.storeId !== APPROVER_LOCATION_ALL) return pick.storeId; + return ""; +} + type ApproverSearchFilters = { sectionDescription: string; stockTakeSession: string; @@ -96,19 +157,95 @@ type ApproverSearchFilters = { varianceFilterStrict: boolean; }; -function buildApproverInventoryQuery(filters: ApproverSearchFilters): ApproverInventoryLotDetailsQuery { - const tolerance = filters.variancePercentTolerance.trim(); +function defaultApproverSearchFilters(mode: "pending" | "approved"): ApproverSearchFilters { return { - sectionDescription: filters.sectionDescription !== "All" ? filters.sectionDescription : undefined, - stockTakeSections: filters.stockTakeSession.trim() ? filters.stockTakeSession.trim() : undefined, - itemKeyword: filters.itemKeyword.trim() ? filters.itemKeyword.trim() : undefined, - warehouseKeyword: filters.warehouseKeyword.trim() ? filters.warehouseKeyword.trim() : undefined, - variancePercentTolerance: tolerance !== "" ? tolerance : undefined, - varianceFilterInclusive: filters.varianceFilterInclusive, - varianceFilterStrict: filters.varianceFilterStrict, + sectionDescription: "All", + stockTakeSession: "", + itemKeyword: "", + warehouseKeyword: "", + storeId: "All", + status: mode === "pending" ? "pass" : "All", + variancePercentTolerance: "5", + varianceFilterInclusive: false, + varianceFilterStrict: false, }; } +function approverVariancePercent(detail: InventoryLotDetailResponse): number { + const second = detail.secondStockTakeQty; + const first = detail.firstStockTakeQty; + const selected = second != null && second >= 0 ? second : first ?? 0; + const book = detail.bookQty != null ? detail.bookQty : detail.availableQty ?? 0; + const difference = selected - book; + const raw = + book !== 0 ? (difference / book) * 100 : difference === 0 ? 0 : difference > 0 ? 100 : -100; + return roundDownPercent(raw); +} + +function matchesApproverDetailFilters( + detail: InventoryLotDetailResponse, + filters: ApproverSearchFilters, + mode: "pending" | "approved" +): boolean { + if (filters.sectionDescription && filters.sectionDescription !== "All") { + const desc = (detail.stockTakeSectionDescription || "").trim().toLowerCase(); + if (desc !== filters.sectionDescription.trim().toLowerCase()) return false; + } + const sections = selectedApproverSectionCodes(filters.stockTakeSession); + if (sections.length > 0) { + const sec = (detail.stockTakeSection || "").trim().toLowerCase(); + const hit = sections.some((part) => { + const wanted = part.toLowerCase(); + return sec === wanted || sec.includes(wanted); + }); + if (!hit) return false; + } + const itemParts = filters.itemKeyword.split(",").map((part) => part.trim()).filter(Boolean); + if (itemParts.length > 0) { + const code = detail.itemCode || ""; + const name = detail.itemName || ""; + const hit = itemParts.some( + (part) => + code.toLowerCase().includes(part.toLowerCase()) || name.toLowerCase().includes(part.toLowerCase()) + ); + if (!hit) return false; + } + const warehouseParts = filters.warehouseKeyword.split(",").map((part) => part.trim()).filter(Boolean); + if (warehouseParts.length > 0) { + const code = detail.warehouseCode || ""; + if (!warehouseParts.some((part) => code.toLowerCase().includes(part.toLowerCase()))) return false; + } + if (filters.storeId && filters.storeId !== "All") { + if ((detail.storeId || "").trim().toLowerCase() !== filters.storeId.trim().toLowerCase()) return false; + } + const statusFilter = mode === "pending" ? filters.status || "pass" : "All"; + if (statusFilter !== "All") { + const rowStatus = String(detail.stockTakeRecordStatus ?? "") + .trim() + .toLowerCase() + .replaceAll("_", ""); + const wanted = statusFilter.trim().toLowerCase().replaceAll("_", ""); + if (rowStatus !== wanted) return false; + } + const tolerance = Number(filters.variancePercentTolerance); + if (filters.variancePercentTolerance.trim() !== "" && Number.isFinite(tolerance) && Math.abs(tolerance) > 0) { + const vp = approverVariancePercent(detail); + const limit = Math.abs(tolerance); + const negative = -limit; + const inclusive = filters.varianceFilterInclusive; + const strict = filters.varianceFilterStrict; + const keep = inclusive + ? strict + ? vp > negative && vp < limit + : vp >= negative && vp <= limit + : strict + ? vp < negative || vp > limit + : vp <= negative || vp >= limit; + if (!keep) return false; + } + return true; +} + function isBlankApproverField(value: string | undefined): boolean { return value == null || String(value).trim() === ""; } @@ -250,17 +387,20 @@ const ApproverStockTakeAll: React.FC = ({ const [total, setTotal] = useState(0); const [approvedSortKey, setApprovedSortKey] = useState(null); const [approvedSortDir, setApprovedSortDir] = useState<"asc" | "desc">("asc"); - const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState([]); - const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState([]); - const [storeIdOptions, setStoreIdOptions] = useState(["2F", "4F"]); + const [locationWarehouses, setLocationWarehouses] = useState([]); + const [searchKind, setSearchKind] = useState("location"); + const [locationPick, setLocationPick] = useState({ storeId: "", warehouse: "", area: "", slot: "" }); const [searchSectionDescription, setSearchSectionDescription] = useState("All"); const [searchStockTakeSession, setSearchStockTakeSession] = useState(""); const [searchItemKeyword, setSearchItemKeyword] = useState(""); const [searchWarehouseKeyword, setSearchWarehouseKeyword] = useState(""); const [searchStoreId, setSearchStoreId] = useState("All"); const [searchStatus, setSearchStatus] = useState(mode === "pending" ? "pass" : "All"); + const locationOwnedSectionRef = useRef(""); const [showFilters, setShowFilters] = useState(true) - const [appliedFilters, setAppliedFilters] = useState(null); + const [appliedFilters, setAppliedFilters] = useState(() => + defaultApproverSearchFilters(mode) + ); const [openBatchSaveConfirmDialog, setOpenBatchSaveConfirmDialog] = useState(false); const batchSaveInFlightRef = useRef(false); @@ -283,21 +423,40 @@ const ApproverStockTakeAll: React.FC = ({ [] ); - const handleSearch = useCallback(() => { + const handleSearch = useCallback((override?: Partial & { searchKind?: ApproverSearchKind }) => { + const kind = override?.searchKind ?? searchKind; + const sectionDescription = kind === "location" + ? "All" + : (override?.sectionDescription ?? (searchSectionDescription || "All")); + const stockTakeSession = kind === "location" + ? (override?.stockTakeSession ?? locationOwnedSectionRef.current) + : (override?.stockTakeSession ?? (searchStockTakeSession || "")); + const warehouseKeyword = kind === "location" + ? (override?.warehouseKeyword ?? (searchWarehouseKeyword || "")) + : ""; + const storeId = kind === "location" + ? (override?.storeId ?? (searchStoreId || "All")) + : "All"; const next: ApproverSearchFilters = { - sectionDescription: searchSectionDescription || "All", - stockTakeSession: searchStockTakeSession || "", - itemKeyword: searchItemKeyword || "", - warehouseKeyword: searchWarehouseKeyword || "", - storeId: searchStoreId || "All", - status: mode === "pending" ? (searchStatus || "pass") : "All", - variancePercentTolerance: searchVariancePercentTolerance, - varianceFilterInclusive: searchVarianceFilterInclusive, - varianceFilterStrict: searchVarianceFilterStrict, + sectionDescription, + stockTakeSession, + itemKeyword: override?.itemKeyword ?? (searchItemKeyword || ""), + warehouseKeyword, + storeId, + status: mode === "pending" ? (override?.status ?? searchStatus ?? "pass") : "All", + variancePercentTolerance: override?.variancePercentTolerance ?? searchVariancePercentTolerance, + varianceFilterInclusive: override?.varianceFilterInclusive ?? searchVarianceFilterInclusive, + varianceFilterStrict: override?.varianceFilterStrict ?? searchVarianceFilterStrict, }; + if (kind === "section" && override?.sectionDescription != null) setSearchSectionDescription(override.sectionDescription); + if (kind === "section" && override?.stockTakeSession != null) setSearchStockTakeSession(override.stockTakeSession); + if (kind === "location" && override?.warehouseKeyword != null) setSearchWarehouseKeyword(override.warehouseKeyword); + if (kind === "location" && override?.storeId != null) setSearchStoreId(override.storeId); + if (override?.status != null) setSearchStatus(override.status); setAppliedFilters(next); setPage(0); }, [ + searchKind, searchSectionDescription, searchStockTakeSession, searchItemKeyword, @@ -312,6 +471,8 @@ const ApproverStockTakeAll: React.FC = ({ const handleResetSearch = useCallback(() => { const defaultStatus = mode === "pending" ? "pass" : "All"; + locationOwnedSectionRef.current = ""; + setLocationPick({ storeId: "", warehouse: "", area: "", slot: "" }); setSearchSectionDescription("All"); setSearchStockTakeSession(""); setSearchItemKeyword(""); @@ -321,39 +482,45 @@ const ApproverStockTakeAll: React.FC = ({ setSearchVariancePercentTolerance("5"); setSearchVarianceFilterInclusive(false); setSearchVarianceFilterStrict(false); - setAppliedFilters(null); + setAppliedFilters(defaultApproverSearchFilters(mode)); setPage(0); - setInventoryLotDetails([]); - setTotal(0); }, [mode]); - const loadDetails = useCallback(async (filters: ApproverSearchFilters) => { + const switchSearchKind = useCallback((nextKind: ApproverSearchKind) => { + if (nextKind === searchKind) return; + setSearchKind(nextKind); + if (nextKind === "location") { + const specificWarehouse = Boolean(locationPick.warehouse && locationPick.warehouse !== APPROVER_LOCATION_ALL); + handleSearch({ + searchKind: nextKind, + stockTakeSession: locationOwnedSectionRef.current, + warehouseKeyword: approverWarehouseKeyword(locationPick), + storeId: !specificWarehouse && locationPick.storeId && locationPick.storeId !== APPROVER_LOCATION_ALL ? locationPick.storeId : "All", + }); + return; + } + handleSearch({ searchKind: nextKind }); + }, [handleSearch, locationPick, searchKind]); + + const loadDetails = useCallback(async () => { setLoadingDetails(true); - console.time("🔥 Total time from API call to DataGrid ready"); try { - const searchQuery: ApproverInventoryLotDetailsQuery = buildApproverInventoryQuery(filters); - - // Always request ALL rows (max 5000) + // Load this round once; search filters run on the loaded rows. const response = mode === "approved" ? await getApproverInventoryLotDetailsAllApproved( selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, 0, - 5000, - searchQuery + 5000 ) : await getApproverInventoryLotDetailsAllPending( selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, 0, - 5000, - searchQuery + 5000 ); - console.timeEnd("🔥 Total time from API call to DataGrid ready"); - setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); setTotal(response.total ?? response.records?.length ?? 0); - console.log(`Loaded ${response.records?.length || 0} rows from backend`); } catch (e) { console.error(e); setInventoryLotDetails([]); @@ -364,43 +531,184 @@ const ApproverStockTakeAll: React.FC = ({ useEffect(() => { - if (!appliedFilters) return; - loadDetails(appliedFilters); - }, [appliedFilters, loadDetails]); + loadDetails(); + }, [loadDetails]); useEffect(() => { - fetchStockTakeSections() - .then((sections) => { - const descSet = new Set(); - const sectionSet = new Set(); - const storeSet = new Set(["2F", "4F"]); - sections.forEach((s) => { - const section = s.stockTakeSection?.trim(); - if (section) sectionSet.add(section); - const desc = s.stockTakeSectionDescription?.trim(); - if (desc) descSet.add(desc); - const storeId = s.storeId?.trim(); - if (storeId) storeSet.add(storeId); - }); - setStockTakeSectionOptions(Array.from(sectionSet).sort((a, b) => a.localeCompare(b))); - setSectionDescriptionOptions(Array.from(descSet).sort((a, b) => a.localeCompare(b))); - setStoreIdOptions(Array.from(storeSet).sort((a, b) => a.localeCompare(b))); + let cancelled = false; + fetchWarehouseListClient() + .then((rows) => { + if (!cancelled) setLocationWarehouses(rows ?? []); }) - .catch((e) => { - console.error("Failed to load section descriptions for approver search:", e); - }); + .catch((e) => console.error("Failed to load warehouses for approver search:", e)); + return () => { + cancelled = true; + }; }, []); + const roundSectionCodeSet = useMemo(() => { + const set = new Set(); + inventoryLotDetails.forEach((row) => { + const code = row.stockTakeSection?.trim(); + if (code) set.add(code); + }); + return set; + }, [inventoryLotDetails]); + + const roundWarehouses = useMemo( + () => + locationWarehouses.filter((row) => { + const code = row.stockTakeSection?.trim(); + if (!code) return false; + if (roundSectionCodeSet.size === 0) return true; + return roundSectionCodeSet.has(code); + }), + [locationWarehouses, roundSectionCodeSet] + ); + + const sectionDescriptionOptions = useMemo(() => { + const pickedCodes = new Set(selectedApproverSectionCodes(searchStockTakeSession)); + const set = new Set(); + roundWarehouses.forEach((row) => { + const code = row.stockTakeSection?.trim(); + if (pickedCodes.size > 0 && (!code || !pickedCodes.has(code))) return; + const desc = row.stockTakeSectionDescription?.trim(); + if (desc) set.add(desc); + }); + return Array.from(set).sort(compareApproverLabel); + }, [roundWarehouses, searchStockTakeSession]); + + const stockTakeSectionOptions = useMemo(() => { + const descFilter = searchSectionDescription !== "All" ? searchSectionDescription : ""; + const set = new Set(); + roundWarehouses.forEach((row) => { + if (descFilter && (row.stockTakeSectionDescription?.trim() || "") !== descFilter) return; + const code = row.stockTakeSection?.trim(); + if (code) set.add(code); + }); + return Array.from(set).sort(compareApproverLabel); + }, [roundWarehouses, searchSectionDescription]); + + const floorOptions = useMemo(() => { + const set = new Set(); + roundWarehouses.forEach((row) => { + if (row.store_id) set.add(row.store_id); + }); + return withApproverAllOption(Array.from(set).sort(compareApproverLabel)); + }, [roundWarehouses]); + + const warehouseOptions = useMemo(() => { + if (!locationPick.storeId) return []; + const set = new Set(); + roundWarehouses.forEach((row) => { + if (locationPick.storeId !== APPROVER_LOCATION_ALL && (row.store_id ?? "").trim() !== locationPick.storeId) return; + const zone = row.warehouse?.trim(); + if (zone) set.add(zone); + }); + return withApproverAllOption(Array.from(set).sort(compareApproverLabel)); + }, [roundWarehouses, locationPick.storeId]); + + const areaOptions = useMemo(() => { + if (!locationPick.storeId || !locationPick.warehouse) return []; + const set = new Set(); + roundWarehouses.forEach((row) => { + if (locationPick.storeId !== APPROVER_LOCATION_ALL && (row.store_id ?? "").trim() !== locationPick.storeId) return; + if (locationPick.warehouse !== APPROVER_LOCATION_ALL && (row.warehouse ?? "").trim() !== locationPick.warehouse) return; + const area = row.area?.trim(); + if (area) set.add(area); + }); + return withApproverAllOption(Array.from(set).sort(compareApproverLabel)); + }, [roundWarehouses, locationPick.storeId, locationPick.warehouse]); + + const slotOptions = useMemo(() => { + if (!locationPick.storeId || !locationPick.warehouse || !locationPick.area) return []; + const set = new Set(); + roundWarehouses.forEach((row) => { + if (locationPick.storeId !== APPROVER_LOCATION_ALL && (row.store_id ?? "").trim() !== locationPick.storeId) return; + if (locationPick.warehouse !== APPROVER_LOCATION_ALL && (row.warehouse ?? "").trim() !== locationPick.warehouse) return; + if (locationPick.area !== APPROVER_LOCATION_ALL && (row.area ?? "").trim() !== locationPick.area) return; + const slot = row.slot?.trim(); + if (slot) set.add(slot); + }); + return withApproverAllOption(Array.from(set).sort(compareApproverLabel)); + }, [roundWarehouses, locationPick.storeId, locationPick.warehouse, locationPick.area]); + + const locationWarehouseRows = useMemo( + () => chunkApproverOptions(warehouseOptions, APPROVER_LOCATION_BUTTONS_PER_ROW), + [warehouseOptions] + ); + const locationAreaRows = useMemo( + () => chunkApproverOptions(areaOptions, APPROVER_LOCATION_BUTTONS_PER_ROW), + [areaOptions] + ); + const locationSlotRows = useMemo( + () => chunkApproverOptions(slotOptions, APPROVER_LOCATION_BUTTONS_PER_ROW), + [slotOptions] + ); + + const applyLocationPick = useCallback((pick: ApproverLocationPick) => { + const specificWarehouse = Boolean(pick.warehouse && pick.warehouse !== APPROVER_LOCATION_ALL); + const specificArea = Boolean(pick.area && pick.area !== APPROVER_LOCATION_ALL); + const specificSlot = Boolean(pick.slot && pick.slot !== APPROVER_LOCATION_ALL); + const matching = roundWarehouses.filter((row) => { + if (pick.storeId && pick.storeId !== APPROVER_LOCATION_ALL && row.store_id !== pick.storeId) return false; + if (specificWarehouse && row.warehouse !== pick.warehouse) return false; + if (specificArea && row.area !== pick.area) return false; + if (specificSlot && row.slot !== pick.slot) return false; + return true; + }); + const codes = Array.from( + new Set(matching.map((row) => row.stockTakeSection?.trim()).filter((code): code is string => Boolean(code))) + ).sort(compareApproverLabel); + const owned = specificWarehouse || specificArea || specificSlot ? codes.join(", ") : ""; + locationOwnedSectionRef.current = owned; + const keyword = approverWarehouseKeyword(pick); + const storeId = !specificWarehouse && pick.storeId && pick.storeId !== APPROVER_LOCATION_ALL ? pick.storeId : "All"; + setLocationPick(pick); + handleSearch({ + searchKind: "location", + stockTakeSession: owned, + warehouseKeyword: keyword, + storeId, + }); + }, [roundWarehouses, handleSearch]); + + useEffect(() => { + if (searchKind !== "location") return; + if (!locationPick.storeId && !locationPick.warehouse && !locationPick.area && !locationPick.slot) return; + if (locationWarehouses.length === 0) return; + const floorOk = !locationPick.storeId || floorOptions.includes(locationPick.storeId); + const warehouseOk = !locationPick.warehouse || warehouseOptions.includes(locationPick.warehouse); + const areaOk = !locationPick.area || areaOptions.includes(locationPick.area); + const slotOk = !locationPick.slot || slotOptions.includes(locationPick.slot); + if (floorOk && warehouseOk && areaOk && slotOk) return; + const next: ApproverLocationPick = { + storeId: floorOk ? locationPick.storeId : "", + warehouse: floorOk && warehouseOk ? locationPick.warehouse : "", + area: floorOk && warehouseOk && areaOk ? locationPick.area : "", + slot: floorOk && warehouseOk && areaOk && slotOk ? locationPick.slot : "", + }; + applyLocationPick(next); + }, [applyLocationPick, areaOptions, floorOptions, locationPick, locationWarehouses.length, searchKind, slotOptions, warehouseOptions]); + // 切换模式或盘次时,清空选择与搜索;approved 模式需要以后端结果为准。 useEffect(() => { setQtySelection({}); setApproverQty({}); setApproverBadQty({}); - setAppliedFilters(null); + locationOwnedSectionRef.current = ""; + setLocationPick({ storeId: "", warehouse: "", area: "", slot: "" }); + setSearchSectionDescription("All"); + setSearchStockTakeSession(""); + setSearchItemKeyword(""); + setSearchWarehouseKeyword(""); + setSearchStoreId("All"); setSearchStatus(mode === "pending" ? "pass" : "All"); + setSearchVariancePercentTolerance("5"); + setSearchVarianceFilterInclusive(false); + setSearchVarianceFilterStrict(false); + setAppliedFilters(defaultApproverSearchFilters(mode)); setPage(0); - setInventoryLotDetails([]); - setTotal(0); }, [mode, selectedSession.stockTakeId]); useEffect(() => { @@ -519,27 +827,8 @@ const ApproverStockTakeAll: React.FC = ({ }, [approverQty, approverBadQty] ); - const normalizeStatus = (v: unknown) => - String(v ?? "") - .trim() - .toLowerCase() - .replaceAll("_", ""); const filteredDetails = useMemo(() => { - const statusFilter = mode === "pending" ? (appliedFilters?.status ?? "pass") : "All"; - const storeIdFilter = appliedFilters?.storeId ?? "All"; - return inventoryLotDetails.filter((detail) => { - if (storeIdFilter !== "All") { - if ((detail.storeId || "").trim().toLowerCase() !== storeIdFilter.trim().toLowerCase()) { - return false; - } - } - if (statusFilter !== "All") { - const rowStatus = normalizeStatus(detail.stockTakeRecordStatus); - const wanted = normalizeStatus(statusFilter); - if (rowStatus !== wanted) return false; - } - return true; - }); + return inventoryLotDetails.filter((detail) => matchesApproverDetailFilters(detail, appliedFilters, mode)); }, [inventoryLotDetails, appliedFilters, mode]); const sortedDetails = useMemo(() => { @@ -590,37 +879,6 @@ const ApproverStockTakeAll: React.FC = ({ return cmp * mul; }); }, [filteredDetails, mode, approvedSortKey, approvedSortDir]); - useEffect(() => { - if (inventoryLotDetails.length === 0) return; - - console.group(` ApproverStockTakeAll - ${inventoryLotDetails.length} rows`); - console.time("1. After API response"); - - // Timing point 1: right after data is set - console.timeEnd("1. After API response"); - - // Timing point 2: after inferSelection - console.time("2. inferSelection useEffect"); - }, [inventoryLotDetails]); - - // Add this useEffect to measure inferSelection - useEffect(() => { - if (inventoryLotDetails.length === 0) return; - console.timeEnd("2. inferSelection useEffect"); - console.time("3. filteredDetails memo"); - }, [inventoryLotDetails, qtySelection]); - - // Add this to measure filteredDetails - useEffect(() => { - console.timeEnd("3. filteredDetails memo"); - console.time("4. sortedDetails memo"); - }, [filteredDetails]); - - // Add this to measure sortedDetails - useEffect(() => { - console.timeEnd("4. sortedDetails memo"); - console.time("5. Before DataGrid render"); - }, [sortedDetails]); const handleApprovedSort = useCallback((property: ApprovedSortKey) => { if (approvedSortKey === property) { setApprovedSortDir((d) => (d === "asc" ? "desc" : "asc")); @@ -853,8 +1111,8 @@ const ApproverStockTakeAll: React.FC = ({ result.errorCount > 0 ? "warning" : "success", ); - if (appliedFilters && result.successCount > 0) { - await loadDetails(appliedFilters); + if (result.successCount > 0) { + await loadDetails(); } } catch (e: unknown) { console.error("handleBatchSubmitAll (all): Error:", e); @@ -878,7 +1136,6 @@ const ApproverStockTakeAll: React.FC = ({ t, onSnackbar, loadDetails, - appliedFilters, ]); const formatNumber = (num: number | null | undefined): string => { @@ -1365,24 +1622,6 @@ const ApproverStockTakeAll: React.FC = ({ ]); const effectivePageSize = pageSize === "all" ? Math.max(total || 0, 0) : (pageSize as number); - useEffect(() => { - setStockTakeSectionOptions((prev) => { - const sectionSet = new Set(prev); - inventoryLotDetails.forEach((item) => { - const section = item.stockTakeSection?.trim(); - if (section) sectionSet.add(section); - }); - return Array.from(sectionSet).sort((a, b) => a.localeCompare(b)); - }); - setStoreIdOptions((prev) => { - const storeSet = new Set([...prev, "2F", "4F"]); - inventoryLotDetails.forEach((item) => { - const storeId = item.storeId?.trim(); - if (storeId) storeSet.add(storeId); - }); - return Array.from(storeSet).sort((a, b) => a.localeCompare(b)); - }); - }, [inventoryLotDetails]); return ( @@ -1426,24 +1665,227 @@ const ApproverStockTakeAll: React.FC = ({ elevation={0} sx={{ mb: 2, border: "1px solid", borderColor: "divider", borderRadius: 1 }} > - }> + } + sx={{ + minHeight: 36, + px: 2, + "&.Mui-expanded": { minHeight: 36 }, + "& .MuiAccordionSummary-content, & .MuiAccordionSummary-content.Mui-expanded": { my: 0.75 }, + }} + > {({t("Search Criteria")})} - + - - + + + + switchSearchKind(value)} + sx={{ minHeight: 36 }} + > + + + + + {searchKind === "location" && ( + + + + + {t("Store ID")} + + { + applyLocationPick({ storeId: next ?? "", warehouse: "", area: "", slot: "" }); + }} + > + {floorOptions.map((floor) => ( + + {floor === APPROVER_LOCATION_ALL ? t("All") : floor} + + ))} + + + + + {t("Warehouse")} + + + {locationWarehouseRows.map((row, rowIndex) => ( + { + if (next == null) return; + applyLocationPick({ ...locationPick, warehouse: next, area: "", slot: "" }); + }} + sx={{ + "& .MuiToggleButtonGroup-grouped": { + width: APPROVER_LOCATION_BUTTON_WIDTH, + minWidth: APPROVER_LOCATION_BUTTON_WIDTH, + maxWidth: APPROVER_LOCATION_BUTTON_WIDTH, + px: 0, + }, + }} + > + {row.map((zone) => ( + + {zone === APPROVER_LOCATION_ALL ? t("All") : zone} + + ))} + + ))} + + {!locationPick.storeId ? ( + + {t("Select floor first")} + + ) : null} + + + + {t("Area")} + + + {locationAreaRows.map((row, rowIndex) => ( + { + if (next == null) return; + applyLocationPick({ ...locationPick, area: next, slot: "" }); + }} + sx={{ + "& .MuiToggleButtonGroup-grouped": { + width: APPROVER_LOCATION_BUTTON_WIDTH, + minWidth: APPROVER_LOCATION_BUTTON_WIDTH, + maxWidth: APPROVER_LOCATION_BUTTON_WIDTH, + px: 0, + }, + }} + > + {row.map((area) => ( + + {area === APPROVER_LOCATION_ALL ? t("All") : area} + + ))} + + ))} + + {!locationPick.warehouse ? ( + + {locationPick.storeId ? t("Select warehouse first") : t("Select floor first")} + + ) : null} + + + + {t("Slot")} + + + {locationSlotRows.map((row, rowIndex) => ( + { + if (next == null) return; + applyLocationPick({ ...locationPick, slot: next }); + }} + sx={{ + "& .MuiToggleButtonGroup-grouped": { + width: APPROVER_LOCATION_BUTTON_WIDTH, + minWidth: APPROVER_LOCATION_BUTTON_WIDTH, + maxWidth: APPROVER_LOCATION_BUTTON_WIDTH, + px: 0, + }, + }} + > + {row.map((slot) => ( + + {slot === APPROVER_LOCATION_ALL ? t("All") : slot} + + ))} + + ))} + + {!locationPick.area ? ( + + {locationPick.warehouse ? t("Select area first") : locationPick.storeId ? t("Select warehouse first") : t("Select floor first")} + + ) : null} + + + + )} + {searchKind === "section" && ( - + {t("Stock Take Section")} + )} + {searchKind === "section" && ( - setSearchStockTakeSession(newValue)} - renderInput={(params) => ( - - )} - /> + + {t("Stock Take Section code")} + + + )} setSearchItemKeyword(e.target.value)} + onBlur={() => handleSearch({ itemKeyword: searchItemKeyword })} + onKeyDown={(e) => { + if (e.key === "Enter") handleSearch({ itemKeyword: searchItemKeyword }); + }} /> - - setSearchWarehouseKeyword(e.target.value)} - /> - - - - {t("Store ID")} - - - {mode === "pending" && ( - - {t("Record Status")} - - + + + {t("Record Status")} + + + + + + + )} @@ -1526,10 +1994,13 @@ const ApproverStockTakeAll: React.FC = ({ type="number" label={t("Variance %")} value={searchVariancePercentTolerance} - onChange={(e) => - setSearchVariancePercentTolerance(sanitizePositiveDecimalInput(e.target.value)) - } + onChange={(e) => { + const next = sanitizePositiveDecimalInput(e.target.value); + setSearchVariancePercentTolerance(next); + handleSearch({ variancePercentTolerance: next }); + }} inputProps={{ min: 0, step: 0.1 }} + sx={approverSearchFieldSx} /> = ({ setSearchVarianceFilterInclusive(e.target.checked)} + onChange={(e) => { + setSearchVarianceFilterInclusive(e.target.checked); + handleSearch({ varianceFilterInclusive: e.target.checked }); + }} /> } label={ @@ -1555,17 +2029,19 @@ const ApproverStockTakeAll: React.FC = ({ op: searchVarianceFilterStrict ? ">" : "≥", })} + {mode !== "pending" && ( + + + + + )} - - - - @@ -1574,11 +2050,6 @@ const ApproverStockTakeAll: React.FC = ({ {t("Total")}: {total} | {t("Shown")}: {sortedDetails.length} - {loadingDetails ? ( - - - - ) : ( = ({ }} /> - )} (items: T[], size: number): T[][] { + const rows: T[][] = []; + for (let i = 0; i < items.length; i += size) { + rows.push(items.slice(i, i + size)); + } + return rows; +} + +function withAllOption(options: string[]): string[] { + return options.length > 1 ? [LOCATION_ALL, ...options] : options; +} + +function compareLocationLabel(a: string, b: string): number { + return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); +} + +function matchesPickerCardFilters( + session: AllPickedStockTakeListReponse, + filters: PickerCardListFilters, +): boolean { + if (filters.sectionDescription && filters.sectionDescription !== "All") { + if (session.stockTakeSectionDescription !== filters.sectionDescription) return false; + } + const sectionQuery = filters.stockTakeSession?.trim() ?? ""; + if (sectionQuery) { + const parts = sectionQuery.split(",").map((part) => part.trim()).filter(Boolean); + const name = (session.stockTakeSession || "").toLowerCase(); + const hit = parts.some((part) => { + const key = part.toLowerCase(); + return name === key || name.includes(key); + }); + if (!hit) return false; + } + if (filters.status && filters.status !== "All") { + const normalized = filters.status.trim().toLowerCase(); + const accepted = normalized === "stocktaking" + ? ["stocktaking", "processing", "in_progress"] + : [normalized]; + if (!accepted.includes((session.status || "").trim().toLowerCase())) return false; + } + const area = filters.area?.trim() ?? ""; + if (area && !(session.warehouseArea || "").toLowerCase().includes(area.toLowerCase())) return false; + if (filters.storeId && filters.storeId !== "All") { + const floor = filters.storeId.trim().toLowerCase(); + const itemFloor = (session.storeId || "").toLowerCase(); + if (itemFloor !== floor && !itemFloor.includes(floor)) return false; + } + return true; +} /** 移動超過此距離才視為框選(Windows 橡皮筋),否則視為單擊 */ const MARQUEE_DRAG_THRESHOLD_PX = 4; /** 左側樓層列表超過此數量才啟用捲動 */ const FLOOR_LIST_SCROLL_THRESHOLD = 8; +const SEARCH_FIELD_HEIGHT = 56; +/** 下拉最多先顯示約 8 列,其餘在選單內捲動 */ +const SEARCH_MENU_MAX_HEIGHT = 320; +const searchMenuProps = { + PaperProps: { + sx: { maxHeight: SEARCH_MENU_MAX_HEIGHT }, + }, +} as const; + +const searchFieldSx = { + "& .MuiOutlinedInput-root": { + height: SEARCH_FIELD_HEIGHT, + minHeight: SEARCH_FIELD_HEIGHT, + boxSizing: "border-box", + alignItems: "center", + }, + "& .MuiSelect-select": { + height: `${SEARCH_FIELD_HEIGHT}px !important`, + minHeight: "0 !important", + display: "flex", + alignItems: "center", + boxSizing: "border-box", + py: "0 !important", + }, +} as const; + +function selectedSectionCodes(value: string): string[] { + if (!value.trim() || value.trim() === LOCATION_NONE_SECTION) return []; + return value + .split(",") + .map((part) => part.trim()) + .filter((part) => part && part !== LOCATION_NONE_SECTION); +} + const createDialogCompactInputSx = { mt: 0, mb: 0, @@ -105,16 +197,81 @@ interface PickerCardListProps { appliedFilters: PickerCardListFilters; onSearchFiltersChange: (next: PickerCardListFilters) => void; onAppliedFiltersChange: (next: PickerCardListFilters) => void; + /** 樓層/倉庫/倉位,由父層保存,從明細返回時仍顯示 */ + locationPick: PickerLocationPick; + onLocationPickChange: (next: PickerLocationPick) => void; } +export type PickerSearchKind = "location" | "section"; + export type PickerCardListFilters = { sectionDescription: string; stockTakeSession: string; status: string; area: string; storeId: string; + searchKind: PickerSearchKind; }; +function locationQueryFromPick( + pick: PickerLocationPick, + warehouses: WarehouseResult[], +): { stockTakeSession: string; storeId: string } { + const useSections = + (pick.warehouse !== "" && pick.warehouse !== LOCATION_ALL) || + (pick.area !== "" && pick.area !== LOCATION_ALL) || + (pick.slot !== "" && pick.slot !== LOCATION_ALL); + const sections = new Set(); + if (useSections) { + warehouses.forEach((w) => { + const section = w.stockTakeSection?.trim(); + if (!section) return; + if (pick.storeId && pick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== pick.storeId) return; + if (pick.warehouse && pick.warehouse !== LOCATION_ALL && (w.warehouse ?? "").trim() !== pick.warehouse) return; + if (pick.area && pick.area !== LOCATION_ALL && (w.area ?? "").trim() !== pick.area) return; + if (pick.slot && pick.slot !== LOCATION_ALL && (w.slot ?? "").trim() !== pick.slot) return; + sections.add(section); + }); + } + return { + stockTakeSession: useSections + ? Array.from(sections).sort(compareLocationLabel).join(",") || LOCATION_NONE_SECTION + : "", + storeId: !pick.storeId || pick.storeId === LOCATION_ALL || useSections ? "All" : pick.storeId, + }; +} + +function appliedPickerFilters( + filters: PickerCardListFilters, + pick: PickerLocationPick, + warehouses: WarehouseResult[], +): PickerCardListFilters { + const kind = filters.searchKind ?? "location"; + if (kind === "section") { + return { + sectionDescription: filters.sectionDescription || "All", + stockTakeSession: filters.stockTakeSession || "", + status: filters.status || "All", + area: "", + storeId: "All", + searchKind: "section", + }; + } + const location = locationQueryFromPick(pick, warehouses); + return { + sectionDescription: "All", + stockTakeSession: location.stockTakeSession, + status: filters.status || "All", + area: "", + storeId: location.storeId, + searchKind: "location", + }; +} + +export type PickerLocationPick = { storeId: string; warehouse: string; area: string; slot: string }; + +export const EMPTY_PICKER_LOCATION: PickerLocationPick = { storeId: "", warehouse: "", area: "", slot: "" }; + const PickerCardList: React.FC = ({ page, pageSize, @@ -125,14 +282,16 @@ const PickerCardList: React.FC = ({ appliedFilters, onSearchFiltersChange, onAppliedFiltersChange, + locationPick, + onLocationPickChange, }) => { const { t } = useTranslation(["stockTake", "common"]); const router = useRouter(); dayjs.extend(duration); - const [loading, setLoading] = useState(false); - const [stockTakeSessions, setStockTakeSessions] = useState([]); - const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + /** 這一輪全部卡片,篩選在前端做,點選不再重打 API */ + const [roundRecords, setRoundRecords] = useState([]); const { data: session } = useSession() as { data: SessionWithTokens | null }; const abilities = session?.abilities ?? session?.user?.abilities ?? []; const canManageStockTake = abilities.some((a) => a.trim() === AUTH.ADMIN); @@ -171,22 +330,20 @@ const PickerCardList: React.FC = ({ } | null>(null); const [marqueeRect, setMarqueeRect] = useState(null); const [marqueePreviewIds, setMarqueePreviewIds] = useState>(() => new Set()); - const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState([]); const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState([]); - const [storeIdOptions, setStoreIdOptions] = useState(["2F", "4F"]); + const [locationWarehouses, setLocationWarehouses] = useState([]); + /** 由選倉寫入的盤點區域字串;使用者改過區域欄後就不再覆寫 */ + const locationOwnedSectionRef = useRef( + (locationPick.warehouse && locationPick.warehouse !== LOCATION_ALL) || + (locationPick.area && locationPick.area !== LOCATION_ALL) || + (locationPick.slot && locationPick.slot !== LOCATION_ALL) + ? searchFilters.stockTakeSession + : null, + ); const statusOptions = ["pending", "stockTaking", "approving", "completed"]; - const handleSearch = () => { - onAppliedFiltersChange({ - sectionDescription: searchFilters.sectionDescription || "All", - stockTakeSession: searchFilters.stockTakeSession || "", - status: searchFilters.status || "All", - area: searchFilters.area || "", - storeId: searchFilters.storeId || "All", - }); - onListPageChange(0); - }; + const searchKind = searchFilters.searchKind ?? "location"; const handleResetSearch = () => { const resetFilters: PickerCardListFilters = { @@ -195,34 +352,47 @@ const PickerCardList: React.FC = ({ status: "All", area: "", storeId: "All", + searchKind, }; onSearchFiltersChange(resetFilters); onAppliedFiltersChange(resetFilters); + onLocationPickChange(EMPTY_PICKER_LOCATION); + locationOwnedSectionRef.current = null; onListPageChange(0); }; useEffect(() => { let cancelled = false; - setLoading(true); - getStockTakeRecordsPaged(page, pageSize, { - sectionDescription: appliedFilters.sectionDescription, - stockTakeSections: appliedFilters.stockTakeSession, - status: appliedFilters.status, - area: appliedFilters.area, - storeId: appliedFilters.storeId, + fetchWarehouseListClient() + .then((rows) => { + if (!cancelled) setLocationWarehouses(Array.isArray(rows) ? rows : []); + }) + .catch((e) => { + console.error("Failed to load warehouses for stock take location filter:", e); + if (!cancelled) setLocationWarehouses([]); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; + getStockTakeRecordsPaged(0, 10000, { + sectionDescription: "All", + stockTakeSections: "", + status: "All", + area: "", + storeId: "All", onlyLatestRound: true, }) .then((res) => { if (cancelled) return; - setStockTakeSessions(Array.isArray(res.records) ? res.records : []); - setTotal(res.total || 0); + setRoundRecords(Array.isArray(res.records) ? res.records : []); }) .catch((e) => { - console.error(e); - if (!cancelled) { - setStockTakeSessions([]); - setTotal(0); - } + console.error("Failed to load this round's stock take sections:", e); + if (!cancelled) setRoundRecords([]); }) .finally(() => { if (!cancelled) setLoading(false); @@ -230,7 +400,196 @@ const PickerCardList: React.FC = ({ return () => { cancelled = true; }; - }, [page, pageSize, appliedFilters, listRefreshNonce]); + }, [listRefreshNonce]); + + const roundSectionCodes = useMemo( + () => + Array.from( + new Set( + roundRecords + .map((row) => row.stockTakeSession?.trim()) + .filter((code): code is string => Boolean(code)), + ), + ), + [roundRecords], + ); + + const filteredSessions = useMemo( + () => roundRecords.filter((session) => matchesPickerCardFilters(session, appliedFilters)), + [appliedFilters, roundRecords], + ); + const total = filteredSessions.length; + const stockTakeSessions = useMemo( + () => filteredSessions.slice(page * pageSize, page * pageSize + pageSize), + [filteredSessions, page, pageSize], + ); + + const stockTakeLocationWarehouses = useMemo(() => { + if (roundSectionCodes.length === 0) return []; + const inRound = new Set(roundSectionCodes); + return locationWarehouses.filter((w) => { + const section = w.stockTakeSection?.trim(); + return Boolean(section && inRound.has(section)); + }); + }, [locationWarehouses, roundSectionCodes]); + + const locationFloorOptions = useMemo(() => { + const set = new Set(); + stockTakeLocationWarehouses.forEach((w) => { + const floor = w.store_id?.trim(); + if (floor) set.add(floor); + }); + return withAllOption(Array.from(set).sort(compareLocationLabel)); + }, [stockTakeLocationWarehouses]); + + const locationWarehouseOptions = useMemo(() => { + if (!locationPick.storeId) return []; + const set = new Set(); + stockTakeLocationWarehouses.forEach((w) => { + if (locationPick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== locationPick.storeId) return; + const zone = w.warehouse?.trim(); + if (zone) set.add(zone); + }); + return withAllOption(Array.from(set).sort(compareLocationLabel)); + }, [stockTakeLocationWarehouses, locationPick.storeId]); + + const locationAreaOptions = useMemo(() => { + if (!locationPick.storeId || !locationPick.warehouse) return []; + const set = new Set(); + stockTakeLocationWarehouses.forEach((w) => { + if (locationPick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== locationPick.storeId) return; + if (locationPick.warehouse !== LOCATION_ALL && (w.warehouse ?? "").trim() !== locationPick.warehouse) return; + const area = w.area?.trim(); + if (area) set.add(area); + }); + return withAllOption(Array.from(set).sort(compareLocationLabel)); + }, [stockTakeLocationWarehouses, locationPick.storeId, locationPick.warehouse]); + + const locationSlotOptions = useMemo(() => { + if (!locationPick.storeId || !locationPick.warehouse || !locationPick.area) return []; + const set = new Set(); + stockTakeLocationWarehouses.forEach((w) => { + if (locationPick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== locationPick.storeId) return; + if (locationPick.warehouse !== LOCATION_ALL && (w.warehouse ?? "").trim() !== locationPick.warehouse) return; + if (locationPick.area !== LOCATION_ALL && (w.area ?? "").trim() !== locationPick.area) return; + const slot = w.slot?.trim(); + if (slot) set.add(slot); + }); + return withAllOption(Array.from(set).sort(compareLocationLabel)); + }, [stockTakeLocationWarehouses, locationPick.storeId, locationPick.warehouse, locationPick.area]); + + const locationWarehouseRows = useMemo( + () => chunkList(locationWarehouseOptions, LOCATION_BUTTONS_PER_ROW), + [locationWarehouseOptions], + ); + const locationAreaRows = useMemo( + () => chunkList(locationAreaOptions, LOCATION_BUTTONS_PER_ROW), + [locationAreaOptions], + ); + const locationSlotRows = useMemo( + () => chunkList(locationSlotOptions, LOCATION_BUTTONS_PER_ROW), + [locationSlotOptions], + ); + + const applyFilters = useCallback((next: PickerCardListFilters) => { + const withKind = { ...next, searchKind: next.searchKind ?? "location" }; + onSearchFiltersChange(withKind); + onAppliedFiltersChange(appliedPickerFilters(withKind, locationPick, stockTakeLocationWarehouses)); + onListPageChange(0); + }, [locationPick, onAppliedFiltersChange, onListPageChange, onSearchFiltersChange, stockTakeLocationWarehouses]); + + /** 依盤點區域搜索時用這一輪全部區域;依位置搜索時下拉不顯示 */ + const searchSectionCodes = useMemo(() => { + if (searchKind === "section" || !locationPick.storeId) return [...roundSectionCodes].sort(compareLocationLabel); + const codes = new Set(); + stockTakeLocationWarehouses.forEach((w) => { + const section = w.stockTakeSection?.trim(); + if (!section) return; + if (locationPick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== locationPick.storeId) return; + if (locationPick.warehouse && locationPick.warehouse !== LOCATION_ALL && (w.warehouse ?? "").trim() !== locationPick.warehouse) return; + if (locationPick.area && locationPick.area !== LOCATION_ALL && (w.area ?? "").trim() !== locationPick.area) return; + if (locationPick.slot && locationPick.slot !== LOCATION_ALL && (w.slot ?? "").trim() !== locationPick.slot) return; + codes.add(section); + }); + return Array.from(codes).sort(compareLocationLabel); + }, [locationPick, roundSectionCodes, searchKind, stockTakeLocationWarehouses]); + + const sectionDescriptionByCode = useMemo(() => { + const map = new Map(); + stockTakeSectionRows.forEach((row) => { + const section = row.stockTakeSection?.trim(); + const text = row.stockTakeSectionDescription?.trim(); + if (section && text) map.set(section, text); + }); + roundRecords.forEach((session) => { + const section = session.stockTakeSession?.trim(); + const text = session.stockTakeSectionDescription?.trim(); + if (section && text && !map.has(section)) map.set(section, text); + }); + return map; + }, [stockTakeSectionRows, roundRecords]); + + const searchSectionDescriptionOptions = useMemo(() => { + const picked = selectedSectionCodes(searchFilters.stockTakeSession).filter((code) => + searchSectionCodes.includes(code) + ); + const source = picked.length > 0 ? picked : searchSectionCodes; + const desc = new Set(); + source.forEach((code) => { + const text = sectionDescriptionByCode.get(code); + if (text) desc.add(text); + }); + return Array.from(desc).sort((a, b) => a.localeCompare(b)); + }, [searchFilters.stockTakeSession, searchSectionCodes, sectionDescriptionByCode]); + + const sectionCodeMenuOptions = useMemo(() => { + const descFilter = searchFilters.sectionDescription !== "All" ? searchFilters.sectionDescription : ""; + if (!descFilter) return searchSectionCodes; + return searchSectionCodes.filter((code) => sectionDescriptionByCode.get(code) === descFilter); + }, [searchFilters.sectionDescription, searchSectionCodes, sectionDescriptionByCode]); + + useEffect(() => { + if (searchKind !== "section") return; + const current = searchFilters.sectionDescription; + if (!current || current === "All") return; + if (roundSectionCodes.length === 0) return; + if (searchSectionDescriptionOptions.includes(current)) return; + applyFilters({ ...searchFilters, sectionDescription: "All", searchKind: "section" }); + }, [ + applyFilters, + roundSectionCodes.length, + searchFilters, + searchKind, + searchSectionDescriptionOptions, + ]); + + const applyLocationPick = useCallback( + (pick: PickerLocationPick) => { + const location = locationQueryFromPick(pick, stockTakeLocationWarehouses); + locationOwnedSectionRef.current = location.stockTakeSession || null; + const next: PickerCardListFilters = { + ...searchFilters, + searchKind: "location", + }; + onSearchFiltersChange(next); + onAppliedFiltersChange(appliedPickerFilters(next, pick, stockTakeLocationWarehouses)); + onListPageChange(0); + }, + [onAppliedFiltersChange, onListPageChange, onSearchFiltersChange, searchFilters, stockTakeLocationWarehouses], + ); + + useEffect(() => { + if (searchKind !== "location") return; + if (!locationPick.storeId && !locationPick.warehouse && !locationPick.area && !locationPick.slot) return; + if (roundSectionCodes.length === 0 || locationWarehouses.length === 0) return; + const floorOk = !locationPick.storeId || locationFloorOptions.includes(locationPick.storeId); + const warehouseOk = !locationPick.warehouse || locationWarehouseOptions.includes(locationPick.warehouse); + const areaOk = !locationPick.area || locationAreaOptions.includes(locationPick.area); + const slotOk = !locationPick.slot || locationSlotOptions.includes(locationPick.slot); + if (floorOk && warehouseOk && areaOk && slotOk) return; + onLocationPickChange(EMPTY_PICKER_LOCATION); + applyLocationPick(EMPTY_PICKER_LOCATION); + }, [applyLocationPick, locationAreaOptions, locationFloorOptions, locationPick, locationSlotOptions, locationWarehouseOptions, locationWarehouses.length, onLocationPickChange, roundSectionCodes.length]); useEffect(() => { let cancelled = false; @@ -315,7 +674,7 @@ const PickerCardList: React.FC = ({ const sid = row.storeId?.trim(); if (sid) sectionToFloor.set(sec, sid); }); - stockTakeSessions.forEach((session) => { + roundRecords.forEach((session) => { const sec = session.stockTakeSession?.trim(); if (!sec) return; const sid = session.storeId?.trim(); @@ -344,7 +703,7 @@ const PickerCardList: React.FC = ({ out.set(key, Array.from(set).sort((a, b) => a.localeCompare(b))); }); return out; - }, [stockTakeSectionRows, stockTakeSessions, stockTakeSectionOptions]); + }, [stockTakeSectionRows, roundRecords, stockTakeSectionOptions]); const sortedFloorKeys = useMemo(() => { const keys = Array.from(sectionsByStore.keys()); @@ -393,14 +752,14 @@ const PickerCardList: React.FC = ({ const getCreateDialogSectionMeta = useCallback( (section: string) => { const row = stockTakeSectionRows.find((r) => r.stockTakeSection?.trim() === section); - const fromList = stockTakeSessions.find((s) => s.stockTakeSession?.trim() === section); + const fromList = roundRecords.find((s) => s.stockTakeSession?.trim() === section); const desc = row?.stockTakeSectionDescription?.trim() || fromList?.stockTakeSectionDescription?.trim() || ""; const area = row?.warehouseArea?.trim() || fromList?.warehouseArea?.trim() || ""; // const store = row?.storeId?.trim() || fromList?.storeId?.trim() || ""; const parts = [desc, area].filter((v) => Boolean(v && v.trim())); return parts.length ? parts.join(" / ") : ""; }, - [stockTakeSectionRows, stockTakeSessions], + [stockTakeSectionRows, roundRecords], ); const activeCreateFloorKey = useMemo(() => { @@ -603,20 +962,12 @@ const PickerCardList: React.FC = ({ fetchStockTakeSections() .then((sections) => { setStockTakeSectionRows(Array.isArray(sections) ? sections : []); - const descSet = new Set(); const sectionSet = new Set(); - const storeIdSet = new Set(["2F", "4F"]); sections.forEach((s) => { const section = s.stockTakeSection?.trim(); if (section) sectionSet.add(section); - const desc = s.stockTakeSectionDescription?.trim(); - if (desc) descSet.add(desc); - const storeId = s.storeId?.trim(); - if (storeId) storeIdSet.add(storeId); }); setStockTakeSectionOptions(Array.from(sectionSet).sort((a, b) => a.localeCompare(b))); - setSectionDescriptionOptions(Array.from(descSet).sort((a, b) => a.localeCompare(b))); - setStoreIdOptions(Array.from(storeIdSet).sort((a, b) => a.localeCompare(b))); }) .catch((e) => { console.error("Failed to load section descriptions for filter:", e); @@ -628,23 +979,13 @@ const PickerCardList: React.FC = ({ useEffect(() => { setStockTakeSectionOptions((prev) => { const sectionSet = new Set(prev); - stockTakeSessions.forEach((item) => { + roundRecords.forEach((item) => { const section = item.stockTakeSession?.trim(); if (section) sectionSet.add(section); }); return Array.from(sectionSet).sort((a, b) => a.localeCompare(b)); }); - }, [stockTakeSessions]); - useEffect(() => { - setStoreIdOptions((prev) => { - const storeIdSet = new Set([...prev, "2F", "4F"]); - stockTakeSessions.forEach((item) => { - const storeId = item.storeId?.trim(); - if (storeId) storeIdSet.add(storeId); - }); - return Array.from(storeIdSet).sort((a, b) => a.localeCompare(b)); - }); - }, [stockTakeSessions]); + }, [roundRecords]); const getStatusColor = (status: string) => { const statusLower = status.toLowerCase(); if (statusLower === "completed") return "success"; @@ -726,23 +1067,59 @@ const PickerCardList: React.FC = ({ {t("Search Criteria")} - - - + + + applyFilters({ ...searchFilters, searchKind: value })} + sx={{ minHeight: 36 }} + > + + + + + {searchKind === "section" && ( + + {t("Stock Take Section")} - - - onSearchFiltersChange({ - ...searchFilters, - stockTakeSession: newValue || "", - }) - } - onInputChange={(_, newValue) => - onSearchFiltersChange({ - ...searchFilters, - stockTakeSession: newValue, - }) - } - renderInput={(params) => ( - - )} - /> + )} + {searchKind === "section" && ( + + + {t("Stock Take Section code")} + + - - + )} + + {t("Status")} - - - onSearchFiltersChange({ - ...searchFilters, - area: e.target.value, - }) - } - /> - - - - {t("Store ID")} - - + {searchKind === "location" && ( + + + + + {t("Store ID")} + + { + const pick = { storeId: next ?? "", warehouse: "", area: "", slot: "" }; + onLocationPickChange(pick); + applyLocationPick(pick); + }} + > + {locationFloorOptions.map((floor) => ( + + {floor === LOCATION_ALL ? t("All") : floor} + + ))} + + + + + {t("Warehouse")} + + + {locationWarehouseRows.map((row, rowIndex) => ( + { + if (next == null) return; + const pick = { ...locationPick, warehouse: next, area: "", slot: "" }; + onLocationPickChange(pick); + applyLocationPick(pick); + }} + sx={{ + "& .MuiToggleButtonGroup-grouped": { + width: LOCATION_BUTTON_WIDTH, + minWidth: LOCATION_BUTTON_WIDTH, + maxWidth: LOCATION_BUTTON_WIDTH, + px: 0, + }, + }} + > + {row.map((zone) => ( + + {zone === LOCATION_ALL ? t("All") : zone} + + ))} + + ))} + + {!locationPick.storeId ? ( + + {t("Select floor first")} + + ) : null} + + + + {t("Area")} + + + {locationAreaRows.map((row, rowIndex) => ( + { + if (next == null) return; + const pick = { ...locationPick, area: next, slot: "" }; + onLocationPickChange(pick); + applyLocationPick(pick); + }} + sx={{ + "& .MuiToggleButtonGroup-grouped": { + width: LOCATION_BUTTON_WIDTH, + minWidth: LOCATION_BUTTON_WIDTH, + maxWidth: LOCATION_BUTTON_WIDTH, + px: 0, + }, + }} + > + {row.map((area) => ( + + {area === LOCATION_ALL ? t("All") : area} + + ))} + + ))} + + {!locationPick.warehouse ? ( + + {locationPick.storeId ? t("Select warehouse first") : t("Select floor first")} + + ) : null} + + + + {t("Slot")} + + + {locationSlotRows.map((row, rowIndex) => ( + { + if (next == null) return; + const pick = { ...locationPick, slot: next }; + onLocationPickChange(pick); + applyLocationPick(pick); + }} + sx={{ + "& .MuiToggleButtonGroup-grouped": { + width: LOCATION_BUTTON_WIDTH, + minWidth: LOCATION_BUTTON_WIDTH, + maxWidth: LOCATION_BUTTON_WIDTH, + px: 0, + }, + }} + > + {row.map((slot) => ( + + {slot === LOCATION_ALL ? t("All") : slot} + + ))} + + ))} + + {!locationPick.area ? ( + + {locationPick.warehouse ? t("Select area first") : locationPick.storeId ? t("Select warehouse first") : t("Select floor first")} + + ) : null} + + + )} - diff --git a/src/components/StockTakeManagement/StockTakeTab.tsx b/src/components/StockTakeManagement/StockTakeTab.tsx index 957e1901..f15fdb26 100644 --- a/src/components/StockTakeManagement/StockTakeTab.tsx +++ b/src/components/StockTakeManagement/StockTakeTab.tsx @@ -7,8 +7,9 @@ import { useTranslation } from "react-i18next"; import { AUTH } from "@/authorities"; import { SessionWithTokens } from "@/config/authConfig"; import { AllPickedStockTakeListReponse, getLatestApproverStockTakeHeader } from "@/app/api/stockTake/actions"; +import { fetchWarehouseListClient } from "@/app/api/warehouse/client"; import PickerCardList from "./PickerCardList"; -import type { PickerCardListFilters } from "./PickerCardList"; +import { EMPTY_PICKER_LOCATION, type PickerCardListFilters, type PickerLocationPick } from "./PickerCardList"; import PickerStockTake from "./PickerStockTake"; import PickerReStockTake from "./PickerReStockTake"; import ApproverStockTakeAll from "./ApproverStockTakeAll"; @@ -26,6 +27,7 @@ const DEFAULT_PICKER_CARD_LIST_FILTERS: PickerCardListFilters = { status: "All", area: "", storeId: "All", + searchKind: "location", }; const StockTakeTab: React.FC = () => { @@ -44,11 +46,13 @@ const StockTakeTab: React.FC = () => { const [viewScope, setViewScope] = useState("picker"); const [approverSession, setApproverSession] = useState(null); const [approverLoading, setApproverLoading] = useState(false); + const approverHeaderLoadedRef = useRef(false); /** 從卡片列表進入明細後返回時保留分頁 */ const [pickerListPage, setPickerListPage] = useState(0); const [pickerListPageSize] = useState(6); const [pickerSearchFilters, setPickerSearchFilters] = useState(DEFAULT_PICKER_CARD_LIST_FILTERS); const [pickerAppliedFilters, setPickerAppliedFilters] = useState(DEFAULT_PICKER_CARD_LIST_FILTERS); + const [pickerLocationPick, setPickerLocationPick] = useState(EMPTY_PICKER_LOCATION); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; @@ -108,8 +112,14 @@ const StockTakeTab: React.FC = () => { } }, [handleSnackbar, isAdmin, qtyGapDraft, t]); + useEffect(() => { + fetchWarehouseListClient().catch((e) => console.error("Failed to prefetch warehouses:", e)); + }, []); + useEffect(() => { if (tabValue !== 1 && tabValue !== 2) return; + if (approverHeaderLoadedRef.current) return; + approverHeaderLoadedRef.current = true; setApproverLoading(true); getLatestApproverStockTakeHeader() .then((header) => { @@ -122,41 +132,26 @@ const StockTakeTab: React.FC = () => { .finally(() => setApproverLoading(false)); }, [tabValue]); - if (selectedSession && viewScope === "picker") { - return ( - - {viewScope === "picker" && ( - tabValue === 0 ? ( - viewMode === "reStockTake" ? ( - - ) : ( - - ) - ) : null - )} - setSnackbar({ ...snackbar, open: false })} - > - setSnackbar({ ...snackbar, open: false })} severity={snackbar.severity}> - {snackbar.message} - - - - ); - } + const showingPickerDetail = Boolean(selectedSession) && viewScope === "picker" && tabValue === 0; return ( + {showingPickerDetail && selectedSession ? ( + viewMode === "reStockTake" ? ( + + ) : ( + + ) + ) : null} + {isAdmin && ( { appliedFilters={pickerAppliedFilters} onSearchFiltersChange={setPickerSearchFilters} onAppliedFiltersChange={setPickerAppliedFilters} + locationPick={pickerLocationPick} + onLocationPickChange={setPickerLocationPick} onCardClick={(session) => { setViewScope("picker"); handleCardClick(session); @@ -230,40 +227,41 @@ const StockTakeTab: React.FC = () => { onReStockTakeClick={handleReStockTakeClick} /> )} - {tabValue === 1 && ( - - {approverLoading ? ( - - - - ) : approverSession ? ( - - ) : ( - - {t("No data")} - - )} - - )} - {tabValue === 2 && ( - - {approverSession ? ( - - ) : ( - - {t("No data")} - - )} - - )} + {approverHeaderLoadedRef.current || tabValue === 1 || tabValue === 2 ? ( + <> + + {approverLoading && !approverSession ? ( + + + + ) : approverSession ? ( + + ) : approverHeaderLoadedRef.current ? ( + + {t("No data")} + + ) : null} + + + {approverSession ? ( + + ) : approverHeaderLoadedRef.current && !approverLoading ? ( + + {t("No data")} + + ) : null} + + + ) : null} +