Keep report item-code typeahead and multi-value filters unchanged. Co-authored-by: Cursor <[email protected]>production
| @@ -28,12 +28,8 @@ export interface LotLineInfo { | |||||
| export interface SearchInventoryLotLine extends Pageable { | export interface SearchInventoryLotLine extends Pageable { | ||||
| itemId: number; | itemId: number; | ||||
| uomId?: number; | |||||
| /** Non-expired lots with in > out; includes available and unavailable. */ | /** Non-expired lots with in > out; includes available and unavailable. */ | ||||
| stockIssueBadItem?: boolean; | stockIssueBadItem?: boolean; | ||||
| storeId?: string; | |||||
| warehouse?: string; | |||||
| area?: string; | |||||
| } | } | ||||
| export interface SearchStockIssueBadItemLotLine extends Pageable { | export interface SearchStockIssueBadItemLotLine extends Pageable { | ||||
| @@ -48,9 +44,6 @@ export interface SearchInventory extends Pageable { | |||||
| name: string; | name: string; | ||||
| type: string; | type: string; | ||||
| lotNo?: string; | lotNo?: string; | ||||
| storeId?: string; | |||||
| warehouse?: string; | |||||
| area?: string; | |||||
| } | } | ||||
| export interface InventoryResultByPage { | export interface InventoryResultByPage { | ||||
| @@ -179,8 +172,8 @@ async function fetchInventoriesLatestImpl(data: SearchInventory) { | |||||
| export const fetchInventories = cache(fetchInventoriesImpl); | export const fetchInventories = cache(fetchInventoriesImpl); | ||||
| /** | /** | ||||
| * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.4 | 2026-09-10 | |||||
| * Inventory search page: latest inventory row per item + stock UoM, with optional location filters. | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07 | |||||
| * Inventory search page: latest inventory row per item (no baseUnit/uomId filter). | |||||
| */ | */ | ||||
| export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); | export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); | ||||
| @@ -202,7 +195,6 @@ async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) { | |||||
| ); | ); | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.4 | 2026-09-10 */ | |||||
| export const fetchInventoryLotLines = cache(fetchInventoryLotLinesImpl); | export const fetchInventoryLotLines = cache(fetchInventoryLotLinesImpl); | ||||
| /** Bypass React cache() after mutations so lists show fresh qty. */ | /** Bypass React cache() after mutations so lists show fresh qty. */ | ||||
| @@ -14,7 +14,6 @@ export interface InventoryResult { | |||||
| onHoldQty: number; | onHoldQty: number; | ||||
| unavailableQty: number; | unavailableQty: number; | ||||
| availableQty: number; | availableQty: number; | ||||
| uomId?: number; | |||||
| uomCode: string; | uomCode: string; | ||||
| uomUdfudesc: string; | uomUdfudesc: string; | ||||
| uomShortDesc: string; | uomShortDesc: string; | ||||
| @@ -54,18 +54,15 @@ interface Props { | |||||
| totalCount: number; | totalCount: number; | ||||
| inventory: InventoryResult | null; | inventory: InventoryResult | null; | ||||
| filterLotNo?: string; | filterLotNo?: string; | ||||
| /** Location search: show only the slot (e.g. 00), not the full warehouse code. */ | |||||
| warehouseDisplay?: "full" | "slot"; | |||||
| onStockTransferSuccess?: () => void | Promise<void>; | onStockTransferSuccess?: () => void | Promise<void>; | ||||
| printerCombo?: PrinterCombo[]; | printerCombo?: PrinterCombo[]; | ||||
| onStockAdjustmentSuccess?: () => void | Promise<void>; | onStockAdjustmentSuccess?: () => void | Promise<void>; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.8 | 2026-09-10 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ | |||||
| const InventoryLotLineTable: React.FC<Props> = ({ | const InventoryLotLineTable: React.FC<Props> = ({ | ||||
| inventoryLotLines, pagingController, setPagingController, totalCount, inventory, | inventoryLotLines, pagingController, setPagingController, totalCount, inventory, | ||||
| filterLotNo, | filterLotNo, | ||||
| warehouseDisplay = "full", | |||||
| onStockTransferSuccess, printerCombo = [], | onStockTransferSuccess, printerCombo = [], | ||||
| onStockAdjustmentSuccess, | onStockAdjustmentSuccess, | ||||
| }) => { | }) => { | ||||
| @@ -482,12 +479,9 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||||
| }, | }, | ||||
| { | { | ||||
| name: "warehouse", | name: "warehouse", | ||||
| label: warehouseDisplay === "slot" ? t("Slot") : t("Warehouse"), | |||||
| label: t("Warehouse"), | |||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| const code = params.warehouse?.code ?? ""; | |||||
| if (warehouseDisplay !== "slot") return code; | |||||
| const parts = code.split("-").filter(Boolean); | |||||
| return parts[parts.length - 1] || code; | |||||
| return `${params.warehouse.code}` | |||||
| }, | }, | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -528,7 +522,7 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||||
| // } | // } | ||||
| // }, | // }, | ||||
| ], | ], | ||||
| [t, onDetailClick, downloadQrCode, handleStockTransfer, handlePrintClick, warehouseDisplay], | |||||
| [t, onDetailClick, downloadQrCode, handleStockTransfer, handlePrintClick], | |||||
| ); | ); | ||||
| @@ -20,19 +20,10 @@ import { fetchItemsByPage } from '@/app/api/settings/item/actions'; | |||||
| import { useSession } from 'next-auth/react'; | import { useSession } from 'next-auth/react'; | ||||
| import { AUTH, hasAbility } from '@/authorities'; | import { AUTH, hasAbility } from '@/authorities'; | ||||
| import { Button, Box } from '@mui/material'; | import { Button, Box } from '@mui/material'; | ||||
| import { WarehouseResult } from '@/app/api/warehouse'; | |||||
| import { fetchWarehouseListClient } from '@/app/api/warehouse/client'; | |||||
| import LocationFilterBar, { | |||||
| emptyLocationFilter, | |||||
| isLocationAll, | |||||
| LocationFilterValue, | |||||
| } from './LocationFilterBar'; | |||||
| interface Props { | interface Props { | ||||
| inventories: InventoryResult[]; | inventories: InventoryResult[]; | ||||
| printerCombo?: PrinterCombo[]; | printerCombo?: PrinterCombo[]; | ||||
| warehouses?: WarehouseResult[]; | |||||
| enableLocationFilter?: boolean; | |||||
| } | } | ||||
| type SearchQuery = Partial< | type SearchQuery = Partial< | ||||
| @@ -71,13 +62,8 @@ const extractItemRecords = (res: unknown): ItemLookupRow[] => { | |||||
| return []; | return []; | ||||
| }; | }; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.4 | 2026-09-10 */ | |||||
| const InventorySearch: React.FC<Props> = ({ | |||||
| inventories, | |||||
| printerCombo, | |||||
| warehouses = [], | |||||
| enableLocationFilter = false, | |||||
| }) => { | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07 */ | |||||
| const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| const { t } = useTranslation(['inventory', 'common', 'item']); | const { t } = useTranslation(['inventory', 'common', 'item']); | ||||
| const { data: session } = useSession(); | const { data: session } = useSession(); | ||||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | const abilities = session?.abilities ?? session?.user?.abilities ?? []; | ||||
| @@ -153,19 +139,6 @@ const InventorySearch: React.FC<Props> = ({ | |||||
| // Resolved lot no for filtering | // Resolved lot no for filtering | ||||
| const [lotNoFilter, setLotNoFilter] = useState(''); | const [lotNoFilter, setLotNoFilter] = useState(''); | ||||
| const [scannedItemId, setScannedItemId] = useState<number | null>(null); | const [scannedItemId, setScannedItemId] = useState<number | null>(null); | ||||
| const [location, setLocation] = useState<LocationFilterValue>(emptyLocationFilter); | |||||
| const [locationWarehouses, setLocationWarehouses] = useState<WarehouseResult[]>(warehouses); | |||||
| useEffect(() => { | |||||
| if (!enableLocationFilter) return; | |||||
| if (warehouses.length) { | |||||
| setLocationWarehouses(warehouses); | |||||
| return; | |||||
| } | |||||
| fetchWarehouseListClient() | |||||
| .then(setLocationWarehouses) | |||||
| .catch(console.error); | |||||
| }, [enableLocationFilter, warehouses]); | |||||
| const defaultInputs = useMemo( | const defaultInputs = useMemo( | ||||
| () => ({ | () => ({ | ||||
| @@ -212,43 +185,28 @@ const InventorySearch: React.FC<Props> = ({ | |||||
| ); | ); | ||||
| // Inventory | // Inventory | ||||
| const withLocationParams = useCallback( | |||||
| <T extends object>(params: T, loc: LocationFilterValue): T & Partial<LocationFilterValue> => { | |||||
| if (!enableLocationFilter) return params; | |||||
| return { | |||||
| ...params, | |||||
| ...(!isLocationAll(loc.storeId) ? { storeId: loc.storeId } : {}), | |||||
| ...(!isLocationAll(loc.warehouse) ? { warehouse: loc.warehouse } : {}), | |||||
| ...(!isLocationAll(loc.area) ? { area: loc.area } : {}), | |||||
| }; | |||||
| }, | |||||
| [enableLocationFilter], | |||||
| ); | |||||
| const refetchInventoryData = useCallback( | const refetchInventoryData = useCallback( | ||||
| async ( | async ( | ||||
| query: Record<SearchParamNames, string>, | query: Record<SearchParamNames, string>, | ||||
| actionType: 'reset' | 'search' | 'paging' | 'init', | actionType: 'reset' | 'search' | 'paging' | 'init', | ||||
| pagingController: typeof defaultPagingController, | pagingController: typeof defaultPagingController, | ||||
| lotNo: string, | lotNo: string, | ||||
| loc: LocationFilterValue = location, | |||||
| ) => { | ) => { | ||||
| //console.log('%c Action Type 1.', 'color:red', actionType); | |||||
| // Avoid loading data again | // Avoid loading data again | ||||
| if (actionType === 'paging' && pagingController === defaultPagingController) { | if (actionType === 'paging' && pagingController === defaultPagingController) { | ||||
| return; | return; | ||||
| } | } | ||||
| const params: SearchInventory = withLocationParams( | |||||
| { | |||||
| code: query?.itemCode ?? '', | |||||
| name: query?.itemName ?? '', | |||||
| type: query?.itemType.toLowerCase() === 'all' ? '' : query?.itemType ?? '', | |||||
| lotNo: lotNo?.trim() ? lotNo.trim() : undefined, | |||||
| pageNum: pagingController.pageNum - 1, | |||||
| pageSize: pagingController.pageSize, | |||||
| }, | |||||
| loc, | |||||
| ); | |||||
| // console.log('%c Action Type 2.', 'color:blue', actionType); | |||||
| const params: SearchInventory = { | |||||
| code: query?.itemCode ?? '', | |||||
| name: query?.itemName ?? '', | |||||
| type: query?.itemType.toLowerCase() === 'all' ? '' : query?.itemType ?? '', | |||||
| lotNo: lotNo?.trim() ? lotNo.trim() : undefined, | |||||
| pageNum: pagingController.pageNum - 1, | |||||
| pageSize: pagingController.pageSize, | |||||
| }; | |||||
| const response = await fetchInventoriesLatest(params); | const response = await fetchInventoriesLatest(params); | ||||
| @@ -262,21 +220,19 @@ const InventorySearch: React.FC<Props> = ({ | |||||
| break; | break; | ||||
| case 'paging': | case 'paging': | ||||
| setFilteredInventories((fi) => | setFilteredInventories((fi) => | ||||
| uniqBy([...fi, ...response.records], (row) => `${row.itemId}-${row.uomId ?? row.uomUdfudesc ?? ''}`), | |||||
| uniqBy([...fi, ...response.records], 'itemId'), | |||||
| ); | ); | ||||
| } | } | ||||
| } | } | ||||
| return response; | return response; | ||||
| }, | }, | ||||
| [enableLocationFilter, location, withLocationParams], | |||||
| [], | |||||
| ); | ); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| refetchInventoryData(defaultInputs, 'init', defaultPagingController, ''); | refetchInventoryData(defaultInputs, 'init', defaultPagingController, ''); | ||||
| // Mount / tab open only. Location changes search via handleLocationChange. | |||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [defaultInputs]); | |||||
| }, [defaultInputs, refetchInventoryData]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| // if (!isEqual(inventoriesPagingController, defaultPagingController)) { | // if (!isEqual(inventoriesPagingController, defaultPagingController)) { | ||||
| @@ -290,8 +246,6 @@ const InventorySearch: React.FC<Props> = ({ | |||||
| itemId: number | null, | itemId: number | null, | ||||
| actionType: 'reset' | 'search' | 'paging', | actionType: 'reset' | 'search' | 'paging', | ||||
| pagingController: typeof defaultPagingController, | pagingController: typeof defaultPagingController, | ||||
| loc: LocationFilterValue = location, | |||||
| uomId?: number, | |||||
| ) => { | ) => { | ||||
| if (!itemId) { | if (!itemId) { | ||||
| setSelectedInventory(null); | setSelectedInventory(null); | ||||
| @@ -305,15 +259,11 @@ const InventorySearch: React.FC<Props> = ({ | |||||
| return; | return; | ||||
| } | } | ||||
| const params: SearchInventoryLotLine = withLocationParams( | |||||
| { | |||||
| itemId, | |||||
| uomId: uomId || undefined, | |||||
| pageNum: pagingController.pageNum - 1, | |||||
| pageSize: pagingController.pageSize, | |||||
| }, | |||||
| loc, | |||||
| ); | |||||
| const params: SearchInventoryLotLine = { | |||||
| itemId, | |||||
| pageNum: pagingController.pageNum - 1, | |||||
| pageSize: pagingController.pageSize, | |||||
| }; | |||||
| const response = await fetchInventoryLotLines(params); | const response = await fetchInventoryLotLines(params); | ||||
| if (response) { | if (response) { | ||||
| @@ -328,27 +278,20 @@ const InventorySearch: React.FC<Props> = ({ | |||||
| } | } | ||||
| } | } | ||||
| }, | }, | ||||
| [location, withLocationParams], | |||||
| [], | |||||
| ); | ); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| // if (!isEqual(inventoryLotLinesPagingController, defaultPagingController)) { | // if (!isEqual(inventoryLotLinesPagingController, defaultPagingController)) { | ||||
| refetchInventoryLotLineData( | |||||
| selectedInventory?.itemId ?? null, | |||||
| 'paging', | |||||
| inventoryLotLinesPagingController, | |||||
| location, | |||||
| selectedInventory?.uomId, | |||||
| ) | |||||
| refetchInventoryLotLineData(selectedInventory?.itemId ?? null, 'paging', inventoryLotLinesPagingController) | |||||
| // } | // } | ||||
| }, [inventoryLotLinesPagingController]) | }, [inventoryLotLinesPagingController]) | ||||
| // Reset | // Reset | ||||
| const onReset = useCallback(() => { | const onReset = useCallback(() => { | ||||
| const clearedLocation = emptyLocationFilter(); | |||||
| setLocation(clearedLocation); | |||||
| refetchInventoryData(defaultInputs, 'reset', defaultPagingController, '', clearedLocation); | |||||
| refetchInventoryLotLineData(null, 'reset', defaultPagingController, clearedLocation); | |||||
| refetchInventoryData(defaultInputs, 'reset', defaultPagingController, ''); | |||||
| refetchInventoryLotLineData(null, 'reset', defaultPagingController); | |||||
| // setFilteredInventories(inventories); | |||||
| setLotNoFilter(''); | setLotNoFilter(''); | ||||
| setScannedItemId(null); | setScannedItemId(null); | ||||
| @@ -361,32 +304,14 @@ const InventorySearch: React.FC<Props> = ({ | |||||
| setInventoryLotLinesPagingController(() => defaultPagingController) | setInventoryLotLinesPagingController(() => defaultPagingController) | ||||
| }, [defaultInputs, qrScanner, refetchInventoryData, refetchInventoryLotLineData]); | }, [defaultInputs, qrScanner, refetchInventoryData, refetchInventoryLotLineData]); | ||||
| const handleLocationChange = useCallback( | |||||
| (next: LocationFilterValue) => { | |||||
| setLocation(next); | |||||
| setSelectedInventory(null); | |||||
| setFilteredInventoryLotLines([]); | |||||
| setInventoryLotLinesTotalCount(0); | |||||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||||
| setInventoriesPagingController(() => defaultPagingController); | |||||
| if (searchInFlightRef.current) return; | |||||
| searchInFlightRef.current = true; | |||||
| refetchInventoryData(inputs, 'search', defaultPagingController, lotNoFilter, next) | |||||
| .finally(() => { | |||||
| searchInFlightRef.current = false; | |||||
| }); | |||||
| }, | |||||
| [inputs, lotNoFilter, refetchInventoryData], | |||||
| ); | |||||
| // Click Row | // Click Row | ||||
| const onInventoryRowClick = useCallback( | const onInventoryRowClick = useCallback( | ||||
| (item: InventoryResult) => { | (item: InventoryResult) => { | ||||
| refetchInventoryLotLineData(item.itemId, 'search', defaultPagingController, location, item.uomId); | |||||
| refetchInventoryLotLineData(item.itemId, 'search', defaultPagingController); | |||||
| setSelectedInventory(item); | setSelectedInventory(item); | ||||
| setInventoryLotLinesPagingController(() => defaultPagingController); | setInventoryLotLinesPagingController(() => defaultPagingController); | ||||
| }, | }, | ||||
| [location, refetchInventoryLotLineData], | |||||
| [refetchInventoryLotLineData], | |||||
| ); | ); | ||||
| // On Search | // On Search | ||||
| @@ -409,7 +334,7 @@ const InventorySearch: React.FC<Props> = ({ | |||||
| setInventoryLotLinesPagingController(() => defaultPagingController); | setInventoryLotLinesPagingController(() => defaultPagingController); | ||||
| // No inventory rows: look up item master so 0-qty rows can be selected for stock adjustment. | // No inventory rows: look up item master so 0-qty rows can be selected for stock adjustment. | ||||
| if (canStockAdjust && !enableLocationFilter && invRes?.records?.length === 0) { | |||||
| if (canStockAdjust && invRes?.records?.length === 0) { | |||||
| try { | try { | ||||
| const items = await lookupItemsByCodeOrName(query.itemCode, query.itemName); | const items = await lookupItemsByCodeOrName(query.itemCode, query.itemName); | ||||
| const typeFilter = query.itemType?.trim(); | const typeFilter = query.itemType?.trim(); | ||||
| @@ -441,7 +366,6 @@ const InventorySearch: React.FC<Props> = ({ | |||||
| lookupItemsByCodeOrName, | lookupItemsByCodeOrName, | ||||
| applyItemsAsSyntheticInventories, | applyItemsAsSyntheticInventories, | ||||
| canStockAdjust, | canStockAdjust, | ||||
| enableLocationFilter, | |||||
| ], | ], | ||||
| ); | ); | ||||
| @@ -527,15 +451,6 @@ const InventorySearch: React.FC<Props> = ({ | |||||
| onSearch(query); | onSearch(query); | ||||
| }} | }} | ||||
| onReset={onReset} | onReset={onReset} | ||||
| extraCriteria={ | |||||
| enableLocationFilter ? ( | |||||
| <LocationFilterBar | |||||
| warehouses={locationWarehouses} | |||||
| value={location} | |||||
| onChange={handleLocationChange} | |||||
| /> | |||||
| ) : undefined | |||||
| } | |||||
| extraActions={ | extraActions={ | ||||
| <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}> | <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}> | ||||
| {scanUiMode === 'idle' ? ( | {scanUiMode === 'idle' ? ( | ||||
| @@ -569,20 +484,16 @@ const InventorySearch: React.FC<Props> = ({ | |||||
| totalCount={inventoryLotLinesTotalCount} | totalCount={inventoryLotLinesTotalCount} | ||||
| inventory={selectedInventory} | inventory={selectedInventory} | ||||
| filterLotNo={lotNoFilter} | filterLotNo={lotNoFilter} | ||||
| warehouseDisplay={enableLocationFilter ? "slot" : "full"} | |||||
| printerCombo={printerCombo ?? []} | printerCombo={printerCombo ?? []} | ||||
| onStockTransferSuccess={() => | onStockTransferSuccess={() => | ||||
| refetchInventoryLotLineData( | refetchInventoryLotLineData( | ||||
| selectedInventory?.itemId ?? null, | selectedInventory?.itemId ?? null, | ||||
| 'search', | 'search', | ||||
| inventoryLotLinesPagingController, | inventoryLotLinesPagingController, | ||||
| location, | |||||
| selectedInventory?.uomId, | |||||
| ) | ) | ||||
| } | } | ||||
| onStockAdjustmentSuccess={async () => { | onStockAdjustmentSuccess={async () => { | ||||
| const itemId = selectedInventory?.itemId ?? null; | const itemId = selectedInventory?.itemId ?? null; | ||||
| const uomId = selectedInventory?.uomId; | |||||
| // Refresh both blocks: | // Refresh both blocks: | ||||
| // - middle: InventoryTable (inventories list) | // - middle: InventoryTable (inventories list) | ||||
| @@ -598,15 +509,11 @@ const InventorySearch: React.FC<Props> = ({ | |||||
| itemId, | itemId, | ||||
| 'search', | 'search', | ||||
| inventoryLotLinesPagingController, | inventoryLotLinesPagingController, | ||||
| location, | |||||
| uomId, | |||||
| ); | ); | ||||
| // If inventory becomes available again after OPEN/ADJ, sync selected row. | // If inventory becomes available again after OPEN/ADJ, sync selected row. | ||||
| if (itemId != null && invRes?.records?.length) { | if (itemId != null && invRes?.records?.length) { | ||||
| const target = invRes.records.find( | |||||
| (r) => r.itemId === itemId && (uomId == null || r.uomId === uomId), | |||||
| ); | |||||
| const target = invRes.records.find((r) => r.itemId === itemId); | |||||
| if (target) setSelectedInventory(target); | if (target) setSelectedInventory(target); | ||||
| } | } | ||||
| }} | }} | ||||
| @@ -1,54 +0,0 @@ | |||||
| "use client"; | |||||
| import { InventoryResult } from "@/app/api/inventory"; | |||||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||||
| import { Box, Tab, Tabs } from "@mui/material"; | |||||
| import { useCallback, useState } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import InventorySearch from "./InventorySearch"; | |||||
| type TabValue = "item" | "location"; | |||||
| interface Props { | |||||
| inventories: InventoryResult[]; | |||||
| printerCombo?: PrinterCombo[]; | |||||
| warehouses?: WarehouseResult[]; | |||||
| } | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ | |||||
| const InventorySearchPage: React.FC<Props> = ({ | |||||
| inventories, | |||||
| printerCombo, | |||||
| warehouses = [], | |||||
| }) => { | |||||
| const { t } = useTranslation("inventory"); | |||||
| const [tab, setTab] = useState<TabValue>("item"); | |||||
| const handleTabChange = useCallback((_: React.SyntheticEvent, value: string) => { | |||||
| setTab(value as TabValue); | |||||
| }, []); | |||||
| return ( | |||||
| <Box> | |||||
| <Tabs value={tab} onChange={handleTabChange} sx={{ mb: 2 }}> | |||||
| <Tab value="item" label={t("Item Search")} /> | |||||
| <Tab value="location" label={t("Location Search")} /> | |||||
| </Tabs> | |||||
| {tab === "item" && ( | |||||
| <InventorySearch inventories={inventories} printerCombo={printerCombo} /> | |||||
| )} | |||||
| {tab === "location" && ( | |||||
| <InventorySearch | |||||
| inventories={inventories} | |||||
| printerCombo={printerCombo} | |||||
| warehouses={warehouses} | |||||
| enableLocationFilter | |||||
| /> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default InventorySearchPage; | |||||
| @@ -1,29 +1,20 @@ | |||||
| import React from "react"; | import React from "react"; | ||||
| import GeneralLoading from "../General/GeneralLoading"; | import GeneralLoading from "../General/GeneralLoading"; | ||||
| import { fetchInventories } from "@/app/api/inventory"; | import { fetchInventories } from "@/app/api/inventory"; | ||||
| import InventorySearchPage from "./InventorySearchPage"; | |||||
| import InventorySearch from "./InventorySearch"; | |||||
| import { fetchPrinterCombo } from "@/app/api/settings/printer"; | import { fetchPrinterCombo } from "@/app/api/settings/printer"; | ||||
| import { fetchWarehouseList } from "@/app/api/warehouse"; | |||||
| interface SubComponents { | interface SubComponents { | ||||
| Loading: typeof GeneralLoading; | Loading: typeof GeneralLoading; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ | |||||
| const InventorySearchWrapper: React.FC & SubComponents = async () => { | const InventorySearchWrapper: React.FC & SubComponents = async () => { | ||||
| const [inventories, printerCombo, warehouses] = await Promise.all([ | |||||
| const [inventories, printerCombo] = await Promise.all([ | |||||
| fetchInventories(), | fetchInventories(), | ||||
| fetchPrinterCombo(), | fetchPrinterCombo(), | ||||
| fetchWarehouseList().catch(() => []), | |||||
| ]); | ]); | ||||
| return ( | |||||
| <InventorySearchPage | |||||
| inventories={inventories} | |||||
| printerCombo={printerCombo ?? []} | |||||
| warehouses={warehouses ?? []} | |||||
| /> | |||||
| ); | |||||
| return <InventorySearch inventories={inventories} printerCombo={printerCombo ?? []} />; | |||||
| }; | }; | ||||
| InventorySearchWrapper.Loading = GeneralLoading; | InventorySearchWrapper.Loading = GeneralLoading; | ||||
| @@ -1,229 +0,0 @@ | |||||
| "use client"; | |||||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||||
| import { Box, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material"; | |||||
| import { useMemo } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| export const LOCATION_ALL = "ALL"; | |||||
| const BUTTONS_PER_ROW = 15; | |||||
| const BUTTON_WIDTH = 80; | |||||
| export type LocationFilterValue = { | |||||
| storeId: string; | |||||
| warehouse: string; | |||||
| area: string; | |||||
| }; | |||||
| const EMPTY_LOCATION: LocationFilterValue = { | |||||
| storeId: "", | |||||
| warehouse: "", | |||||
| area: "", | |||||
| }; | |||||
| export const emptyLocationFilter = (): LocationFilterValue => ({ ...EMPTY_LOCATION }); | |||||
| export const isLocationAll = (value?: string) => !value || value === LOCATION_ALL; | |||||
| const warehouseSegments = (w: WarehouseResult) => { | |||||
| const parts = (w.code || "").split("-"); | |||||
| return { | |||||
| storeId: w.store_id?.trim() || parts[0] || "", | |||||
| warehouse: w.warehouse?.trim() || parts[1] || "", | |||||
| area: w.area?.trim() || parts[2] || "", | |||||
| }; | |||||
| }; | |||||
| const compareAlphanumeric = (a: string, b: string) => | |||||
| a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); | |||||
| const chunk = <T,>(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; | |||||
| }; | |||||
| const withAllOption = (options: string[]) => | |||||
| options.length > 1 ? [LOCATION_ALL, ...options] : options; | |||||
| interface Props { | |||||
| warehouses: WarehouseResult[]; | |||||
| value: LocationFilterValue; | |||||
| onChange: (next: LocationFilterValue) => void; | |||||
| } | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ | |||||
| const LocationFilterBar: React.FC<Props> = ({ warehouses, value, onChange }) => { | |||||
| const { t } = useTranslation("inventory"); | |||||
| const floors = useMemo(() => { | |||||
| const set = new Set<string>(); | |||||
| warehouses.forEach((w) => { | |||||
| const storeId = warehouseSegments(w).storeId; | |||||
| if (storeId) set.add(storeId); | |||||
| }); | |||||
| return Array.from(set).sort(compareAlphanumeric); | |||||
| }, [warehouses]); | |||||
| const warehouseEnabled = Boolean(value.storeId); | |||||
| const areaEnabled = Boolean(value.storeId && value.warehouse); | |||||
| const warehouseZones = useMemo(() => { | |||||
| if (!value.storeId) return []; | |||||
| const set = new Set<string>(); | |||||
| warehouses.forEach((w) => { | |||||
| const seg = warehouseSegments(w); | |||||
| if (!isLocationAll(value.storeId) && seg.storeId !== value.storeId) return; | |||||
| if (seg.warehouse) set.add(seg.warehouse); | |||||
| }); | |||||
| return Array.from(set).sort(compareAlphanumeric); | |||||
| }, [warehouses, value.storeId]); | |||||
| const areas = useMemo(() => { | |||||
| if (!value.storeId || !value.warehouse) return []; | |||||
| const set = new Set<string>(); | |||||
| warehouses.forEach((w) => { | |||||
| const seg = warehouseSegments(w); | |||||
| if (!isLocationAll(value.storeId) && seg.storeId !== value.storeId) return; | |||||
| if (!isLocationAll(value.warehouse) && seg.warehouse !== value.warehouse) return; | |||||
| if (seg.area) set.add(seg.area); | |||||
| }); | |||||
| return Array.from(set).sort(compareAlphanumeric); | |||||
| }, [warehouses, value.storeId, value.warehouse]); | |||||
| const floorOptions = useMemo(() => withAllOption(floors), [floors]); | |||||
| const warehouseRows = useMemo( | |||||
| () => chunk(withAllOption(warehouseZones), BUTTONS_PER_ROW), | |||||
| [warehouseZones], | |||||
| ); | |||||
| const areaRows = useMemo( | |||||
| () => chunk(withAllOption(areas), BUTTONS_PER_ROW), | |||||
| [areas], | |||||
| ); | |||||
| return ( | |||||
| <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, mb: 1 }}> | |||||
| <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}> | |||||
| <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600 }}> | |||||
| {t("Floor")} | |||||
| </Typography> | |||||
| <ToggleButtonGroup | |||||
| exclusive | |||||
| size="small" | |||||
| value={value.storeId || null} | |||||
| onChange={(_, next: string | null) => { | |||||
| onChange({ storeId: next ?? "", warehouse: "", area: "" }); | |||||
| }} | |||||
| > | |||||
| {floorOptions.map((floor) => ( | |||||
| <ToggleButton key={floor} value={floor} sx={{ px: 1.5, textTransform: "none" }}> | |||||
| {floor === LOCATION_ALL ? t("All") : floor} | |||||
| </ToggleButton> | |||||
| ))} | |||||
| </ToggleButtonGroup> | |||||
| </Box> | |||||
| <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}> | |||||
| <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}> | |||||
| {t("Warehouse")} | |||||
| </Typography> | |||||
| <Box | |||||
| sx={{ | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| gap: 0.75, | |||||
| opacity: warehouseEnabled ? 1 : 0.45, | |||||
| }} | |||||
| > | |||||
| {warehouseRows.map((row, rowIndex) => ( | |||||
| <ToggleButtonGroup | |||||
| key={row.join("-") || rowIndex} | |||||
| exclusive | |||||
| size="small" | |||||
| disabled={!warehouseEnabled} | |||||
| value={warehouseEnabled ? value.warehouse || null : null} | |||||
| onChange={(_, next: string | null) => { | |||||
| if (next == null) return; | |||||
| onChange({ ...value, warehouse: next, area: "" }); | |||||
| }} | |||||
| sx={{ | |||||
| "& .MuiToggleButtonGroup-grouped": { | |||||
| width: BUTTON_WIDTH, | |||||
| minWidth: BUTTON_WIDTH, | |||||
| maxWidth: BUTTON_WIDTH, | |||||
| px: 0, | |||||
| boxSizing: "border-box", | |||||
| }, | |||||
| }} | |||||
| > | |||||
| {row.map((zone) => ( | |||||
| <ToggleButton key={zone} value={zone} sx={{ textTransform: "none" }}> | |||||
| {zone === LOCATION_ALL ? t("All") : zone} | |||||
| </ToggleButton> | |||||
| ))} | |||||
| </ToggleButtonGroup> | |||||
| ))} | |||||
| </Box> | |||||
| {!warehouseEnabled && ( | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}> | |||||
| {t("Select floor first")} | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}> | |||||
| <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}> | |||||
| {t("Area")} | |||||
| </Typography> | |||||
| <Box | |||||
| sx={{ | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| gap: 0.75, | |||||
| opacity: areaEnabled ? 1 : 0.45, | |||||
| }} | |||||
| > | |||||
| {areaRows.map((row, rowIndex) => ( | |||||
| <ToggleButtonGroup | |||||
| key={row.join("-") || rowIndex} | |||||
| exclusive | |||||
| size="small" | |||||
| disabled={!areaEnabled} | |||||
| value={areaEnabled ? value.area || null : null} | |||||
| onChange={(_, next: string | null) => { | |||||
| if (next == null) return; | |||||
| onChange({ ...value, area: next }); | |||||
| }} | |||||
| sx={{ | |||||
| "& .MuiToggleButtonGroup-grouped": { | |||||
| width: BUTTON_WIDTH, | |||||
| minWidth: BUTTON_WIDTH, | |||||
| maxWidth: BUTTON_WIDTH, | |||||
| px: 0, | |||||
| boxSizing: "border-box", | |||||
| }, | |||||
| }} | |||||
| > | |||||
| {row.map((area) => ( | |||||
| <ToggleButton key={area} value={area} sx={{ textTransform: "none" }}> | |||||
| {area === LOCATION_ALL ? t("All") : area} | |||||
| </ToggleButton> | |||||
| ))} | |||||
| </ToggleButtonGroup> | |||||
| ))} | |||||
| </Box> | |||||
| {!areaEnabled && ( | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}> | |||||
| {value.storeId ? t("Select warehouse first") : t("Select floor first")} | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default LocationFilterBar; | |||||
| @@ -124,19 +124,15 @@ interface Props<T extends string> { | |||||
| onReset?: () => void; | onReset?: () => void; | ||||
| /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */ | /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */ | ||||
| extraActions?: React.ReactNode; | extraActions?: React.ReactNode; | ||||
| /** Optional filters rendered above the standard criteria fields */ | |||||
| extraCriteria?: React.ReactNode; | |||||
| /** Disable inputs/actions while external task is running */ | /** Disable inputs/actions while external task is running */ | ||||
| disabled?: boolean; | disabled?: boolean; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ | |||||
| function SearchBox<T extends string>({ | function SearchBox<T extends string>({ | ||||
| criteria, | criteria, | ||||
| onSearch, | onSearch, | ||||
| onReset, | onReset, | ||||
| extraActions, | extraActions, | ||||
| extraCriteria, | |||||
| disabled = false, | disabled = false, | ||||
| }: Props<T>) { | }: Props<T>) { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| @@ -299,7 +295,6 @@ function SearchBox<T extends string>({ | |||||
| <Typography className="app-search-criteria-label" variant="overline" sx={{ display: "block", mb: 0.5 }}> | <Typography className="app-search-criteria-label" variant="overline" sx={{ display: "block", mb: 0.5 }}> | ||||
| {t("Search Criteria")} | {t("Search Criteria")} | ||||
| </Typography> | </Typography> | ||||
| {extraCriteria} | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | ||||
| {criteria.map((c) => { | {criteria.map((c) => { | ||||
| return ( | return ( | ||||
| @@ -16,14 +16,7 @@ | |||||
| "Download QR Code": "Download QR Code", | "Download QR Code": "Download QR Code", | ||||
| "Edit mode": "Edit mode", | "Edit mode": "Edit mode", | ||||
| "Enter item code or name to search": "Enter item code or name to search", | "Enter item code or name to search": "Enter item code or name to search", | ||||
| "Area": "Area", | |||||
| "Expiry Date": "Expiry Date", | "Expiry Date": "Expiry Date", | ||||
| "Floor": "Floor", | |||||
| "Item Search": "Item Search", | |||||
| "Location Search": "Location Search", | |||||
| "Select a floor to search by location.": "Select a floor to search by location.", | |||||
| "Select floor first": "Select a floor first", | |||||
| "Select warehouse first": "Select a warehouse first", | |||||
| "FG": "Finished good", | "FG": "Finished good", | ||||
| "Failed to transfer stock": "Failed to transfer stock", | "Failed to transfer stock": "Failed to transfer stock", | ||||
| "Failed to transfer stock. Please try again.": "Failed to transfer stock. Please try again.", | "Failed to transfer stock. Please try again.": "Failed to transfer stock. Please try again.", | ||||
| @@ -60,7 +53,6 @@ | |||||
| "Remove": "Remove", | "Remove": "Remove", | ||||
| "Reset": "Reset", | "Reset": "Reset", | ||||
| "SFG": "Semi-finished good", | "SFG": "Semi-finished good", | ||||
| "Slot": "Slot", | |||||
| "Save": "Save", | "Save": "Save", | ||||
| "Save failed": "Save failed", | "Save failed": "Save failed", | ||||
| "Saved successfully": "Saved successfully", | "Saved successfully": "Saved successfully", | ||||
| @@ -16,14 +16,7 @@ | |||||
| "Download QR Code": "下載", | "Download QR Code": "下載", | ||||
| "Edit mode": "編輯模式", | "Edit mode": "編輯模式", | ||||
| "Enter item code or name to search": "輸入貨品編號或名稱以搜索", | "Enter item code or name to search": "輸入貨品編號或名稱以搜索", | ||||
| "Area": "區域", | |||||
| "Expiry Date": "到期日", | "Expiry Date": "到期日", | ||||
| "Floor": "樓層", | |||||
| "Item Search": "貨品搜尋", | |||||
| "Location Search": "倉位搜尋", | |||||
| "Select a floor to search by location.": "請先選擇樓層以搜尋倉位。", | |||||
| "Select floor first": "請先選擇樓層", | |||||
| "Select warehouse first": "請先選擇倉庫", | |||||
| "FG": "成品", | "FG": "成品", | ||||
| "Failed to transfer stock": "轉倉失敗", | "Failed to transfer stock": "轉倉失敗", | ||||
| "Failed to transfer stock. Please try again.": "轉倉失敗,請重試。", | "Failed to transfer stock. Please try again.": "轉倉失敗,請重試。", | ||||
| @@ -60,7 +53,6 @@ | |||||
| "Remove": "移除", | "Remove": "移除", | ||||
| "Reset": "重置", | "Reset": "重置", | ||||
| "SFG": "半成品", | "SFG": "半成品", | ||||
| "Slot": "儲位", | |||||
| "Save": "儲存", | "Save": "儲存", | ||||
| "Save failed": "儲存失敗", | "Save failed": "儲存失敗", | ||||
| "Saved successfully": "儲存成功", | "Saved successfully": "儲存成功", | ||||