'use client'; import { InventoryLotLineResult, InventoryResult } from '@/app/api/inventory'; import { useTranslation } from 'react-i18next'; import SearchBox, { Criterion } from '../SearchBox'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { uniq, uniqBy } from 'lodash'; import InventoryTable from './InventoryTable'; import { defaultPagingController } from '../SearchResults/SearchResults'; import InventoryLotLineTable from './InventoryLotLineTable'; import InventoryItemLedger from './InventoryItemLedger'; import InventoryItemUom from './InventoryItemUom'; import InventoryQtyStory from './InventoryQtyStory'; import { useQrCodeScannerContext } from '@/components/QrCodeScannerProvider/QrCodeScannerProvider'; import { analyzeQrCode, SearchInventory, SearchInventoryLotLine, fetchInventoriesLatest, fetchInventoryLotLines, } from '@/app/api/inventory/actions'; import { PrinterCombo } from '@/app/api/settings/printer'; import { fetchItemsByPage } from '@/app/api/settings/item/actions'; import { useSession } from 'next-auth/react'; import { AUTH, hasAbility } from '@/authorities'; import { Button, Box, Tab, Tabs } from '@mui/material'; interface Props { inventories: InventoryResult[]; printerCombo?: PrinterCombo[]; } type SearchQuery = Partial< Omit< InventoryResult, | "id" | "qty" | "uomCode" | "uomUdfudesc" | "uomId" | "stockUomId" | "stockUomCode" | "germPerSmallestUnit" | "qtyPerSmallestUnit" | "itemSmallestUnit" | "price" | "description" | "category" > >; type SearchParamNames = keyof SearchQuery; type ItemLookupRow = { id: number; code: string; name: string; type?: string; uom?: string; uomDesc?: string; purchaseUnit?: string; }; const extractItemRecords = (res: unknown): ItemLookupRow[] => { if (!res) return []; if (Array.isArray(res)) return res as ItemLookupRow[]; if (typeof res === 'object' && Array.isArray((res as { records?: unknown }).records)) { return (res as { records: ItemLookupRow[] }).records; } return []; }; /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.5 | 2026-09-21 */ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { const { t } = useTranslation(['inventory', 'common', 'item']); const { data: session } = useSession(); const abilities = session?.abilities ?? session?.user?.abilities ?? []; const canStockAdjust = hasAbility(abilities, AUTH.INVENTORY_ADJUST); const searchInFlightRef = useRef(false); const buildSyntheticInventory = useCallback((item: ItemLookupRow): InventoryResult => { const uom = item.uomDesc || item.uom || item.purchaseUnit || ''; return { id: 0, itemId: Number(item.id), itemCode: item.code, itemName: item.name, itemType: item.type || 'mat', onHandQty: 0, onHoldQty: 0, unavailableQty: 0, availableQty: 0, uomCode: item.uom || uom, uomUdfudesc: uom, uomShortDesc: item.uom || uom, qtyPerSmallestUnit: 1, baseUom: uom, price: 0, currencyName: '', status: 'active', latestMarketUnitPrice: undefined, latestMupUpdatedDate: undefined, }; }, []); const lookupItemsByCodeOrName = useCallback(async (code?: string, name?: string) => { const trimmedCode = code?.trim(); const trimmedName = name?.trim(); if (!trimmedCode && !trimmedName) return []; const params: Record = { pageSize: 50, pageNum: 1 }; if (trimmedCode) params.code = trimmedCode; else params.name = trimmedName as string; const itemRes = await fetchItemsByPage(params); return extractItemRecords(itemRes); }, []); // Inventory const [filteredInventories, setFilteredInventories] = useState([]); const [inventoriesPagingController, setInventoriesPagingController] = useState(defaultPagingController) const [inventoriesTotalCount, setInventoriesTotalCount] = useState(0) const [selectedInventory, setSelectedInventory] = useState(null) const [detailTab, setDetailTab] = useState(0) const tabsRef = useRef(null) // Inventory Lot Line const [filteredInventoryLotLines, setFilteredInventoryLotLines] = useState([]); const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController) const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0) const applyItemsAsSyntheticInventories = useCallback( (items: ItemLookupRow[]) => { if (!items.length) return false; const synthetics = items.map(buildSyntheticInventory); setFilteredInventories(synthetics); setInventoriesTotalCount(synthetics.length); setSelectedInventory(synthetics[0]); setFilteredInventoryLotLines([]); setInventoryLotLinesPagingController(() => defaultPagingController); return true; }, [buildSyntheticInventory], ); // Scan-mode UI (hardware QR scanner via QrCodeScannerProvider) const qrScanner = useQrCodeScannerContext(); const [scanUiMode, setScanUiMode] = useState<'idle' | 'scanning'>('idle'); const [scanHoverCancel, setScanHoverCancel] = useState(false); // Resolved lot no for filtering const [lotNoFilter, setLotNoFilter] = useState(''); const [scannedItemId, setScannedItemId] = useState(null); const defaultInputs = useMemo( () => ({ itemId: '', itemCode: '', itemName: '', itemType: '', onHandQty: '', onHoldQty: '', unavailableQty: '', availableQty: '', currencyName: '', status: '', baseUom: '', uomShortDesc: '', latestMarketUnitPrice: '', latestMupUpdatedDate: '', }), [], ); const [inputs, setInputs] = useState>(defaultInputs); const searchCriteria: Criterion[] = useMemo( () => [ { label: t('Code'), paramName: 'itemCode', type: 'text' }, { label: t('Name'), paramName: 'itemName', type: 'text' }, { label: t('Type'), paramName: 'itemType', type: 'select-labelled', options: uniq(inventories.map((i) => i.itemType)).map((type) => ({ value: type, label: t(type), })), }, // { // label: t("Status"), // paramName: "status", // type: "select", // options: uniq(inventories.map((i) => i.status)), // }, ], [t, inventories], ); // Inventory const refetchInventoryData = useCallback( async ( query: Record, actionType: 'reset' | 'search' | 'paging' | 'init', pagingController: typeof defaultPagingController, lotNo: string, ) => { //console.log('%c Action Type 1.', 'color:red', actionType); // Avoid loading data again if (actionType === 'paging' && pagingController === defaultPagingController) { return; } // 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); if (response) { setInventoriesTotalCount(response.total); switch (actionType) { case 'init': case 'reset': case 'search': setFilteredInventories(() => response.records); break; case 'paging': setFilteredInventories((fi) => uniqBy([...fi, ...response.records], 'itemId'), ); } } return response; }, [], ); useEffect(() => { refetchInventoryData(defaultInputs, 'init', defaultPagingController, ''); }, [defaultInputs, refetchInventoryData]); useEffect(() => { // if (!isEqual(inventoriesPagingController, defaultPagingController)) { refetchInventoryData(inputs, 'paging', inventoriesPagingController, lotNoFilter) // } }, [inventoriesPagingController, inputs, lotNoFilter, refetchInventoryData]) // Inventory Lot Line const refetchInventoryLotLineData = useCallback( async ( itemId: number | null, actionType: 'reset' | 'search' | 'paging', pagingController: typeof defaultPagingController, ) => { if (!itemId) { setSelectedInventory(null); setInventoryLotLinesTotalCount(0); setFilteredInventoryLotLines([]); return; } // Avoid loading data again if (actionType === 'paging' && pagingController === defaultPagingController) { return; } const params: SearchInventoryLotLine = { itemId, pageNum: pagingController.pageNum - 1, pageSize: pagingController.pageSize, }; const response = await fetchInventoryLotLines(params); if (response) { setInventoryLotLinesTotalCount(response.total); switch (actionType) { case 'reset': case 'search': setFilteredInventoryLotLines(() => response.records); break; case 'paging': setFilteredInventoryLotLines((fi) => uniqBy([...fi, ...response.records], 'id')); } } }, [], ); useEffect(() => { // if (!isEqual(inventoryLotLinesPagingController, defaultPagingController)) { refetchInventoryLotLineData(selectedInventory?.itemId ?? null, 'paging', inventoryLotLinesPagingController) // } }, [inventoryLotLinesPagingController]) // Reset const onReset = useCallback(() => { refetchInventoryData(defaultInputs, 'reset', defaultPagingController, ''); refetchInventoryLotLineData(null, 'reset', defaultPagingController); // setFilteredInventories(inventories); setLotNoFilter(''); setScannedItemId(null); setScanUiMode('idle'); setScanHoverCancel(false); qrScanner.stopScan(); qrScanner.resetScan(); setInputs(() => defaultInputs) setInventoriesPagingController(() => defaultPagingController) setInventoryLotLinesPagingController(() => defaultPagingController) }, [defaultInputs, qrScanner, refetchInventoryData, refetchInventoryLotLineData]); // Click Row const onInventoryRowClick = useCallback( (item: InventoryResult) => { refetchInventoryLotLineData(item.itemId, 'search', defaultPagingController); setSelectedInventory(item); setInventoryLotLinesPagingController(() => defaultPagingController); }, [refetchInventoryLotLineData], ); useEffect(() => { if (selectedInventory?.itemId == null) return; const row = document.querySelector('[data-inventory-selected="true"]'); const container = row?.closest('.MuiTableContainer-root'); if (row instanceof HTMLElement && container instanceof HTMLElement) { const rowRect = row.getBoundingClientRect(); const box = container.getBoundingClientRect(); if (rowRect.top < box.top) { container.scrollTop -= box.top - rowRect.top; } else if (rowRect.bottom > box.bottom) { container.scrollTop += rowRect.bottom - box.bottom; } } const tabs = tabsRef.current; if (tabs) { const rect = tabs.getBoundingClientRect(); if (rect.bottom > window.innerHeight - 8) { tabs.scrollIntoView({ block: 'end', behavior: 'smooth' }); } } }, [selectedInventory?.itemId]); // On Search const onSearch = useCallback( async (query: Record) => { if (searchInFlightRef.current) return; searchInFlightRef.current = true; try { setLotNoFilter(''); setScannedItemId(null); setScanUiMode('idle'); setScanHoverCancel(false); qrScanner.stopScan(); qrScanner.resetScan(); const invRes = await refetchInventoryData(query, 'search', defaultPagingController, ''); await refetchInventoryLotLineData(null, 'search', defaultPagingController); setInputs(() => query); setInventoriesPagingController(() => defaultPagingController); setInventoryLotLinesPagingController(() => defaultPagingController); // No inventory rows: look up item master so 0-qty rows can be selected for stock adjustment. if (canStockAdjust && invRes?.records?.length === 0) { try { const items = await lookupItemsByCodeOrName(query.itemCode, query.itemName); const typeFilter = query.itemType?.trim(); let filtered = typeFilter && typeFilter.toLowerCase() !== 'all' ? items.filter((it) => (it.type ?? '').toLowerCase() === typeFilter.toLowerCase()) : items; const exactCode = query.itemCode?.trim().toLowerCase(); if (exactCode) { filtered = [...filtered].sort((a, b) => { const aExact = a.code?.toLowerCase() === exactCode ? 0 : 1; const bExact = b.code?.toLowerCase() === exactCode ? 0 : 1; return aExact - bExact; }); } applyItemsAsSyntheticInventories(filtered); } catch (e) { console.error('Failed to build synthetic inventory:', e); } } } finally { searchInFlightRef.current = false; } }, [ qrScanner, refetchInventoryData, refetchInventoryLotLineData, lookupItemsByCodeOrName, applyItemsAsSyntheticInventories, canStockAdjust, ], ); const startLotScan = useCallback(() => { setScanHoverCancel(false); setScanUiMode('scanning'); qrScanner.resetScan(); qrScanner.startScan(); }, [qrScanner]); const cancelLotScan = useCallback(() => { qrScanner.stopScan(); qrScanner.resetScan(); setScanUiMode('idle'); setScanHoverCancel(false); }, [qrScanner]); useEffect(() => { if (scanUiMode !== 'scanning') return; const itemId = qrScanner.result?.itemId; const stockInLineId = qrScanner.result?.stockInLineId; if (!itemId || !stockInLineId) return; (async () => { try { const res = await analyzeQrCode({ itemId: Number(itemId), stockInLineId: Number(stockInLineId), }); const resolvedLotNo = res?.scanned?.lotNo?.trim?.() ? res.scanned.lotNo.trim() : ''; if (!resolvedLotNo) return; setLotNoFilter(resolvedLotNo); setScannedItemId(res?.itemId ?? Number(itemId)); const invRes = await refetchInventoryData(inputs, 'search', defaultPagingController, resolvedLotNo); const records = invRes?.records ?? []; const target = records.find((r) => r.itemId === (res?.itemId ?? Number(itemId))) ?? null; if (target) { onInventoryRowClick(target); } else { refetchInventoryLotLineData(null, 'search', defaultPagingController); if (canStockAdjust) { const items = await lookupItemsByCodeOrName(res?.itemCode); if (!applyItemsAsSyntheticInventories(items)) { setSelectedInventory(null); } } else { setSelectedInventory(null); } } setInventoriesPagingController(() => defaultPagingController); setInventoryLotLinesPagingController(() => defaultPagingController); } catch (e) { console.error('Failed to analyze QR code:', e); } finally { // Always go back to initial state after a scan attempt cancelLotScan(); } })(); }, [ cancelLotScan, inputs, onInventoryRowClick, qrScanner.result, refetchInventoryData, refetchInventoryLotLineData, lookupItemsByCodeOrName, applyItemsAsSyntheticInventories, canStockAdjust, scanUiMode, ]); return ( <> { onSearch(query); }} onReset={onReset} extraActions={ {scanUiMode === 'idle' ? ( ) : ( <> )} } /> setDetailTab(value)} sx={{ bgcolor: 'background.paper' }} > {selectedInventory && } {detailTab === 0 ? ( refetchInventoryLotLineData( selectedInventory?.itemId ?? null, 'search', inventoryLotLinesPagingController, ) } onStockAdjustmentSuccess={async () => { const itemId = selectedInventory?.itemId ?? null; // Refresh both blocks: // - middle: InventoryTable (inventories list) // - bottom: InventoryLotLineTable (lot lines for selected item) const invRes = await refetchInventoryData( inputs, 'search', inventoriesPagingController, lotNoFilter, ); await refetchInventoryLotLineData( itemId, 'search', inventoryLotLinesPagingController, ); // If inventory becomes available again after OPEN/ADJ, sync selected row. if (itemId != null && invRes?.records?.length) { const target = invRes.records.find((r) => r.itemId === itemId); if (target) setSelectedInventory(target); } }} /> ) : detailTab === 1 ? ( ) : ( )} ); }; export default InventorySearch;