|
- '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<Props> = ({ 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<string, string | number> = { 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<InventoryResult[]>([]);
- const [inventoriesPagingController, setInventoriesPagingController] = useState(defaultPagingController)
- const [inventoriesTotalCount, setInventoriesTotalCount] = useState(0)
- const [selectedInventory, setSelectedInventory] = useState<InventoryResult | null>(null)
- const [detailTab, setDetailTab] = useState(0)
- const tabsRef = useRef<HTMLDivElement>(null)
-
- // Inventory Lot Line
- const [filteredInventoryLotLines, setFilteredInventoryLotLines] = useState<InventoryLotLineResult[]>([]);
- 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<number | null>(null);
-
- const defaultInputs = useMemo(
- () => ({
- itemId: '',
- itemCode: '',
- itemName: '',
- itemType: '',
- onHandQty: '',
- onHoldQty: '',
- unavailableQty: '',
- availableQty: '',
- currencyName: '',
- status: '',
- baseUom: '',
- uomShortDesc: '',
- latestMarketUnitPrice: '',
- latestMupUpdatedDate: '',
- }),
- [],
- );
- const [inputs, setInputs] = useState<Record<SearchParamNames, string>>(defaultInputs);
-
- const searchCriteria: Criterion<SearchParamNames>[] = 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<SearchParamNames, string>,
- 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<SearchParamNames, string>) => {
- 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 (
- <>
- <SearchBox
- criteria={searchCriteria}
- onSearch={(query) => {
- onSearch(query);
- }}
- onReset={onReset}
- extraActions={
- <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
- {scanUiMode === 'idle' ? (
- <Button variant="contained" onClick={startLotScan}>
- {t('Search lot by QR code')}
- </Button>
- ) : (
- <>
- <Button variant="contained" disabled sx={{ bgcolor: 'grey.400', color: 'grey.800' }}>
- {t('Please scan...')}
- </Button>
- <Button variant="contained" color="error" onClick={cancelLotScan}>
- {t('Stop QR Scan')}
- </Button>
- </>
- )}
- </Box>
- }
- />
- <InventoryTable
- inventories={filteredInventories}
- pagingController={inventoriesPagingController}
- setPagingController={setInventoriesPagingController}
- totalCount={inventoriesTotalCount}
- onRowClick={onInventoryRowClick}
- selectedItemId={selectedInventory?.itemId}
- maxHeight={440}
- />
- <Box ref={tabsRef} sx={{ mt: 1 }}>
- <Tabs
- value={detailTab}
- onChange={(_, value) => setDetailTab(value)}
- sx={{ bgcolor: 'background.paper' }}
- >
- <Tab label={t('Lots')} />
- <Tab label={t('Stock Movements')} />
- <Tab label={t('Unit Conversion')} />
- </Tabs>
- </Box>
- {selectedInventory && <InventoryQtyStory inventory={selectedInventory} />}
- {detailTab === 0 ? (
- <InventoryLotLineTable
- inventoryLotLines={filteredInventoryLotLines}
- pagingController={inventoryLotLinesPagingController}
- setPagingController={setInventoryLotLinesPagingController}
- totalCount={inventoryLotLinesTotalCount}
- inventory={selectedInventory}
- filterLotNo={lotNoFilter}
- printerCombo={printerCombo ?? []}
- onStockTransferSuccess={() =>
- 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 ? (
- <InventoryItemLedger
- key={selectedInventory?.itemId ?? 'none'}
- inventory={selectedInventory}
- />
- ) : (
- <InventoryItemUom
- key={selectedInventory?.itemId ?? 'none'}
- inventory={selectedInventory}
- />
- )}
- </>
- );
- };
-
- export default InventorySearch;
|