FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

582 line
19 KiB

  1. 'use client';
  2. import { InventoryLotLineResult, InventoryResult } from '@/app/api/inventory';
  3. import { useTranslation } from 'react-i18next';
  4. import SearchBox, { Criterion } from '../SearchBox';
  5. import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
  6. import { uniq, uniqBy } from 'lodash';
  7. import InventoryTable from './InventoryTable';
  8. import { defaultPagingController } from '../SearchResults/SearchResults';
  9. import InventoryLotLineTable from './InventoryLotLineTable';
  10. import InventoryItemLedger from './InventoryItemLedger';
  11. import InventoryItemUom from './InventoryItemUom';
  12. import InventoryQtyStory from './InventoryQtyStory';
  13. import { useQrCodeScannerContext } from '@/components/QrCodeScannerProvider/QrCodeScannerProvider';
  14. import {
  15. analyzeQrCode,
  16. SearchInventory,
  17. SearchInventoryLotLine,
  18. fetchInventoriesLatest,
  19. fetchInventoryLotLines,
  20. } from '@/app/api/inventory/actions';
  21. import { PrinterCombo } from '@/app/api/settings/printer';
  22. import { fetchItemsByPage } from '@/app/api/settings/item/actions';
  23. import { useSession } from 'next-auth/react';
  24. import { AUTH, hasAbility } from '@/authorities';
  25. import { Button, Box, Tab, Tabs } from '@mui/material';
  26. interface Props {
  27. inventories: InventoryResult[];
  28. printerCombo?: PrinterCombo[];
  29. }
  30. type SearchQuery = Partial<
  31. Omit<
  32. InventoryResult,
  33. | "id"
  34. | "qty"
  35. | "uomCode"
  36. | "uomUdfudesc"
  37. | "uomId"
  38. | "stockUomId"
  39. | "stockUomCode"
  40. | "germPerSmallestUnit"
  41. | "qtyPerSmallestUnit"
  42. | "itemSmallestUnit"
  43. | "price"
  44. | "description"
  45. | "category"
  46. >
  47. >;
  48. type SearchParamNames = keyof SearchQuery;
  49. type ItemLookupRow = {
  50. id: number;
  51. code: string;
  52. name: string;
  53. type?: string;
  54. uom?: string;
  55. uomDesc?: string;
  56. purchaseUnit?: string;
  57. };
  58. const extractItemRecords = (res: unknown): ItemLookupRow[] => {
  59. if (!res) return [];
  60. if (Array.isArray(res)) return res as ItemLookupRow[];
  61. if (typeof res === 'object' && Array.isArray((res as { records?: unknown }).records)) {
  62. return (res as { records: ItemLookupRow[] }).records;
  63. }
  64. return [];
  65. };
  66. /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.5 | 2026-09-21 */
  67. const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
  68. const { t } = useTranslation(['inventory', 'common', 'item']);
  69. const { data: session } = useSession();
  70. const abilities = session?.abilities ?? session?.user?.abilities ?? [];
  71. const canStockAdjust = hasAbility(abilities, AUTH.INVENTORY_ADJUST);
  72. const searchInFlightRef = useRef(false);
  73. const buildSyntheticInventory = useCallback((item: ItemLookupRow): InventoryResult => {
  74. const uom = item.uomDesc || item.uom || item.purchaseUnit || '';
  75. return {
  76. id: 0,
  77. itemId: Number(item.id),
  78. itemCode: item.code,
  79. itemName: item.name,
  80. itemType: item.type || 'mat',
  81. onHandQty: 0,
  82. onHoldQty: 0,
  83. unavailableQty: 0,
  84. availableQty: 0,
  85. uomCode: item.uom || uom,
  86. uomUdfudesc: uom,
  87. uomShortDesc: item.uom || uom,
  88. qtyPerSmallestUnit: 1,
  89. baseUom: uom,
  90. price: 0,
  91. currencyName: '',
  92. status: 'active',
  93. latestMarketUnitPrice: undefined,
  94. latestMupUpdatedDate: undefined,
  95. };
  96. }, []);
  97. const lookupItemsByCodeOrName = useCallback(async (code?: string, name?: string) => {
  98. const trimmedCode = code?.trim();
  99. const trimmedName = name?.trim();
  100. if (!trimmedCode && !trimmedName) return [];
  101. const params: Record<string, string | number> = { pageSize: 50, pageNum: 1 };
  102. if (trimmedCode) params.code = trimmedCode;
  103. else params.name = trimmedName as string;
  104. const itemRes = await fetchItemsByPage(params);
  105. return extractItemRecords(itemRes);
  106. }, []);
  107. // Inventory
  108. const [filteredInventories, setFilteredInventories] = useState<InventoryResult[]>([]);
  109. const [inventoriesPagingController, setInventoriesPagingController] = useState(defaultPagingController)
  110. const [inventoriesTotalCount, setInventoriesTotalCount] = useState(0)
  111. const [selectedInventory, setSelectedInventory] = useState<InventoryResult | null>(null)
  112. const [detailTab, setDetailTab] = useState(0)
  113. const tabsRef = useRef<HTMLDivElement>(null)
  114. // Inventory Lot Line
  115. const [filteredInventoryLotLines, setFilteredInventoryLotLines] = useState<InventoryLotLineResult[]>([]);
  116. const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController)
  117. const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0)
  118. const applyItemsAsSyntheticInventories = useCallback(
  119. (items: ItemLookupRow[]) => {
  120. if (!items.length) return false;
  121. const synthetics = items.map(buildSyntheticInventory);
  122. setFilteredInventories(synthetics);
  123. setInventoriesTotalCount(synthetics.length);
  124. setSelectedInventory(synthetics[0]);
  125. setFilteredInventoryLotLines([]);
  126. setInventoryLotLinesPagingController(() => defaultPagingController);
  127. return true;
  128. },
  129. [buildSyntheticInventory],
  130. );
  131. // Scan-mode UI (hardware QR scanner via QrCodeScannerProvider)
  132. const qrScanner = useQrCodeScannerContext();
  133. const [scanUiMode, setScanUiMode] = useState<'idle' | 'scanning'>('idle');
  134. const [scanHoverCancel, setScanHoverCancel] = useState(false);
  135. // Resolved lot no for filtering
  136. const [lotNoFilter, setLotNoFilter] = useState('');
  137. const [scannedItemId, setScannedItemId] = useState<number | null>(null);
  138. const defaultInputs = useMemo(
  139. () => ({
  140. itemId: '',
  141. itemCode: '',
  142. itemName: '',
  143. itemType: '',
  144. onHandQty: '',
  145. onHoldQty: '',
  146. unavailableQty: '',
  147. availableQty: '',
  148. currencyName: '',
  149. status: '',
  150. baseUom: '',
  151. uomShortDesc: '',
  152. latestMarketUnitPrice: '',
  153. latestMupUpdatedDate: '',
  154. }),
  155. [],
  156. );
  157. const [inputs, setInputs] = useState<Record<SearchParamNames, string>>(defaultInputs);
  158. const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
  159. () => [
  160. { label: t('Code'), paramName: 'itemCode', type: 'text' },
  161. { label: t('Name'), paramName: 'itemName', type: 'text' },
  162. {
  163. label: t('Type'),
  164. paramName: 'itemType',
  165. type: 'select-labelled',
  166. options: uniq(inventories.map((i) => i.itemType)).map((type) => ({
  167. value: type,
  168. label: t(type),
  169. })),
  170. },
  171. // {
  172. // label: t("Status"),
  173. // paramName: "status",
  174. // type: "select",
  175. // options: uniq(inventories.map((i) => i.status)),
  176. // },
  177. ],
  178. [t, inventories],
  179. );
  180. // Inventory
  181. const refetchInventoryData = useCallback(
  182. async (
  183. query: Record<SearchParamNames, string>,
  184. actionType: 'reset' | 'search' | 'paging' | 'init',
  185. pagingController: typeof defaultPagingController,
  186. lotNo: string,
  187. ) => {
  188. //console.log('%c Action Type 1.', 'color:red', actionType);
  189. // Avoid loading data again
  190. if (actionType === 'paging' && pagingController === defaultPagingController) {
  191. return;
  192. }
  193. // console.log('%c Action Type 2.', 'color:blue', actionType);
  194. const params: SearchInventory = {
  195. code: query?.itemCode ?? '',
  196. name: query?.itemName ?? '',
  197. type: query?.itemType.toLowerCase() === 'all' ? '' : query?.itemType ?? '',
  198. lotNo: lotNo?.trim() ? lotNo.trim() : undefined,
  199. pageNum: pagingController.pageNum - 1,
  200. pageSize: pagingController.pageSize,
  201. };
  202. const response = await fetchInventoriesLatest(params);
  203. if (response) {
  204. setInventoriesTotalCount(response.total);
  205. switch (actionType) {
  206. case 'init':
  207. case 'reset':
  208. case 'search':
  209. setFilteredInventories(() => response.records);
  210. break;
  211. case 'paging':
  212. setFilteredInventories((fi) =>
  213. uniqBy([...fi, ...response.records], 'itemId'),
  214. );
  215. }
  216. }
  217. return response;
  218. },
  219. [],
  220. );
  221. useEffect(() => {
  222. refetchInventoryData(defaultInputs, 'init', defaultPagingController, '');
  223. }, [defaultInputs, refetchInventoryData]);
  224. useEffect(() => {
  225. // if (!isEqual(inventoriesPagingController, defaultPagingController)) {
  226. refetchInventoryData(inputs, 'paging', inventoriesPagingController, lotNoFilter)
  227. // }
  228. }, [inventoriesPagingController, inputs, lotNoFilter, refetchInventoryData])
  229. // Inventory Lot Line
  230. const refetchInventoryLotLineData = useCallback(
  231. async (
  232. itemId: number | null,
  233. actionType: 'reset' | 'search' | 'paging',
  234. pagingController: typeof defaultPagingController,
  235. ) => {
  236. if (!itemId) {
  237. setSelectedInventory(null);
  238. setInventoryLotLinesTotalCount(0);
  239. setFilteredInventoryLotLines([]);
  240. return;
  241. }
  242. // Avoid loading data again
  243. if (actionType === 'paging' && pagingController === defaultPagingController) {
  244. return;
  245. }
  246. const params: SearchInventoryLotLine = {
  247. itemId,
  248. pageNum: pagingController.pageNum - 1,
  249. pageSize: pagingController.pageSize,
  250. };
  251. const response = await fetchInventoryLotLines(params);
  252. if (response) {
  253. setInventoryLotLinesTotalCount(response.total);
  254. switch (actionType) {
  255. case 'reset':
  256. case 'search':
  257. setFilteredInventoryLotLines(() => response.records);
  258. break;
  259. case 'paging':
  260. setFilteredInventoryLotLines((fi) => uniqBy([...fi, ...response.records], 'id'));
  261. }
  262. }
  263. },
  264. [],
  265. );
  266. useEffect(() => {
  267. // if (!isEqual(inventoryLotLinesPagingController, defaultPagingController)) {
  268. refetchInventoryLotLineData(selectedInventory?.itemId ?? null, 'paging', inventoryLotLinesPagingController)
  269. // }
  270. }, [inventoryLotLinesPagingController])
  271. // Reset
  272. const onReset = useCallback(() => {
  273. refetchInventoryData(defaultInputs, 'reset', defaultPagingController, '');
  274. refetchInventoryLotLineData(null, 'reset', defaultPagingController);
  275. // setFilteredInventories(inventories);
  276. setLotNoFilter('');
  277. setScannedItemId(null);
  278. setScanUiMode('idle');
  279. setScanHoverCancel(false);
  280. qrScanner.stopScan();
  281. qrScanner.resetScan();
  282. setInputs(() => defaultInputs)
  283. setInventoriesPagingController(() => defaultPagingController)
  284. setInventoryLotLinesPagingController(() => defaultPagingController)
  285. }, [defaultInputs, qrScanner, refetchInventoryData, refetchInventoryLotLineData]);
  286. // Click Row
  287. const onInventoryRowClick = useCallback(
  288. (item: InventoryResult) => {
  289. refetchInventoryLotLineData(item.itemId, 'search', defaultPagingController);
  290. setSelectedInventory(item);
  291. setInventoryLotLinesPagingController(() => defaultPagingController);
  292. },
  293. [refetchInventoryLotLineData],
  294. );
  295. useEffect(() => {
  296. if (selectedInventory?.itemId == null) return;
  297. const row = document.querySelector('[data-inventory-selected="true"]');
  298. const container = row?.closest('.MuiTableContainer-root');
  299. if (row instanceof HTMLElement && container instanceof HTMLElement) {
  300. const rowRect = row.getBoundingClientRect();
  301. const box = container.getBoundingClientRect();
  302. if (rowRect.top < box.top) {
  303. container.scrollTop -= box.top - rowRect.top;
  304. } else if (rowRect.bottom > box.bottom) {
  305. container.scrollTop += rowRect.bottom - box.bottom;
  306. }
  307. }
  308. const tabs = tabsRef.current;
  309. if (tabs) {
  310. const rect = tabs.getBoundingClientRect();
  311. if (rect.bottom > window.innerHeight - 8) {
  312. tabs.scrollIntoView({ block: 'end', behavior: 'smooth' });
  313. }
  314. }
  315. }, [selectedInventory?.itemId]);
  316. // On Search
  317. const onSearch = useCallback(
  318. async (query: Record<SearchParamNames, string>) => {
  319. if (searchInFlightRef.current) return;
  320. searchInFlightRef.current = true;
  321. try {
  322. setLotNoFilter('');
  323. setScannedItemId(null);
  324. setScanUiMode('idle');
  325. setScanHoverCancel(false);
  326. qrScanner.stopScan();
  327. qrScanner.resetScan();
  328. const invRes = await refetchInventoryData(query, 'search', defaultPagingController, '');
  329. await refetchInventoryLotLineData(null, 'search', defaultPagingController);
  330. setInputs(() => query);
  331. setInventoriesPagingController(() => defaultPagingController);
  332. setInventoryLotLinesPagingController(() => defaultPagingController);
  333. // No inventory rows: look up item master so 0-qty rows can be selected for stock adjustment.
  334. if (canStockAdjust && invRes?.records?.length === 0) {
  335. try {
  336. const items = await lookupItemsByCodeOrName(query.itemCode, query.itemName);
  337. const typeFilter = query.itemType?.trim();
  338. let filtered =
  339. typeFilter && typeFilter.toLowerCase() !== 'all'
  340. ? items.filter((it) => (it.type ?? '').toLowerCase() === typeFilter.toLowerCase())
  341. : items;
  342. const exactCode = query.itemCode?.trim().toLowerCase();
  343. if (exactCode) {
  344. filtered = [...filtered].sort((a, b) => {
  345. const aExact = a.code?.toLowerCase() === exactCode ? 0 : 1;
  346. const bExact = b.code?.toLowerCase() === exactCode ? 0 : 1;
  347. return aExact - bExact;
  348. });
  349. }
  350. applyItemsAsSyntheticInventories(filtered);
  351. } catch (e) {
  352. console.error('Failed to build synthetic inventory:', e);
  353. }
  354. }
  355. } finally {
  356. searchInFlightRef.current = false;
  357. }
  358. },
  359. [
  360. qrScanner,
  361. refetchInventoryData,
  362. refetchInventoryLotLineData,
  363. lookupItemsByCodeOrName,
  364. applyItemsAsSyntheticInventories,
  365. canStockAdjust,
  366. ],
  367. );
  368. const startLotScan = useCallback(() => {
  369. setScanHoverCancel(false);
  370. setScanUiMode('scanning');
  371. qrScanner.resetScan();
  372. qrScanner.startScan();
  373. }, [qrScanner]);
  374. const cancelLotScan = useCallback(() => {
  375. qrScanner.stopScan();
  376. qrScanner.resetScan();
  377. setScanUiMode('idle');
  378. setScanHoverCancel(false);
  379. }, [qrScanner]);
  380. useEffect(() => {
  381. if (scanUiMode !== 'scanning') return;
  382. const itemId = qrScanner.result?.itemId;
  383. const stockInLineId = qrScanner.result?.stockInLineId;
  384. if (!itemId || !stockInLineId) return;
  385. (async () => {
  386. try {
  387. const res = await analyzeQrCode({
  388. itemId: Number(itemId),
  389. stockInLineId: Number(stockInLineId),
  390. });
  391. const resolvedLotNo = res?.scanned?.lotNo?.trim?.() ? res.scanned.lotNo.trim() : '';
  392. if (!resolvedLotNo) return;
  393. setLotNoFilter(resolvedLotNo);
  394. setScannedItemId(res?.itemId ?? Number(itemId));
  395. const invRes = await refetchInventoryData(inputs, 'search', defaultPagingController, resolvedLotNo);
  396. const records = invRes?.records ?? [];
  397. const target = records.find((r) => r.itemId === (res?.itemId ?? Number(itemId))) ?? null;
  398. if (target) {
  399. onInventoryRowClick(target);
  400. } else {
  401. refetchInventoryLotLineData(null, 'search', defaultPagingController);
  402. if (canStockAdjust) {
  403. const items = await lookupItemsByCodeOrName(res?.itemCode);
  404. if (!applyItemsAsSyntheticInventories(items)) {
  405. setSelectedInventory(null);
  406. }
  407. } else {
  408. setSelectedInventory(null);
  409. }
  410. }
  411. setInventoriesPagingController(() => defaultPagingController);
  412. setInventoryLotLinesPagingController(() => defaultPagingController);
  413. } catch (e) {
  414. console.error('Failed to analyze QR code:', e);
  415. } finally {
  416. // Always go back to initial state after a scan attempt
  417. cancelLotScan();
  418. }
  419. })();
  420. }, [
  421. cancelLotScan,
  422. inputs,
  423. onInventoryRowClick,
  424. qrScanner.result,
  425. refetchInventoryData,
  426. refetchInventoryLotLineData,
  427. lookupItemsByCodeOrName,
  428. applyItemsAsSyntheticInventories,
  429. canStockAdjust,
  430. scanUiMode,
  431. ]);
  432. return (
  433. <>
  434. <SearchBox
  435. criteria={searchCriteria}
  436. onSearch={(query) => {
  437. onSearch(query);
  438. }}
  439. onReset={onReset}
  440. extraActions={
  441. <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
  442. {scanUiMode === 'idle' ? (
  443. <Button variant="contained" onClick={startLotScan}>
  444. {t('Search lot by QR code')}
  445. </Button>
  446. ) : (
  447. <>
  448. <Button variant="contained" disabled sx={{ bgcolor: 'grey.400', color: 'grey.800' }}>
  449. {t('Please scan...')}
  450. </Button>
  451. <Button variant="contained" color="error" onClick={cancelLotScan}>
  452. {t('Stop QR Scan')}
  453. </Button>
  454. </>
  455. )}
  456. </Box>
  457. }
  458. />
  459. <InventoryTable
  460. inventories={filteredInventories}
  461. pagingController={inventoriesPagingController}
  462. setPagingController={setInventoriesPagingController}
  463. totalCount={inventoriesTotalCount}
  464. onRowClick={onInventoryRowClick}
  465. selectedItemId={selectedInventory?.itemId}
  466. maxHeight={440}
  467. />
  468. <Box ref={tabsRef} sx={{ mt: 1 }}>
  469. <Tabs
  470. value={detailTab}
  471. onChange={(_, value) => setDetailTab(value)}
  472. sx={{ bgcolor: 'background.paper' }}
  473. >
  474. <Tab label={t('Lots')} />
  475. <Tab label={t('Stock Movements')} />
  476. <Tab label={t('Unit Conversion')} />
  477. </Tabs>
  478. </Box>
  479. {selectedInventory && <InventoryQtyStory inventory={selectedInventory} />}
  480. {detailTab === 0 ? (
  481. <InventoryLotLineTable
  482. inventoryLotLines={filteredInventoryLotLines}
  483. pagingController={inventoryLotLinesPagingController}
  484. setPagingController={setInventoryLotLinesPagingController}
  485. totalCount={inventoryLotLinesTotalCount}
  486. inventory={selectedInventory}
  487. filterLotNo={lotNoFilter}
  488. printerCombo={printerCombo ?? []}
  489. onStockTransferSuccess={() =>
  490. refetchInventoryLotLineData(
  491. selectedInventory?.itemId ?? null,
  492. 'search',
  493. inventoryLotLinesPagingController,
  494. )
  495. }
  496. onStockAdjustmentSuccess={async () => {
  497. const itemId = selectedInventory?.itemId ?? null;
  498. // Refresh both blocks:
  499. // - middle: InventoryTable (inventories list)
  500. // - bottom: InventoryLotLineTable (lot lines for selected item)
  501. const invRes = await refetchInventoryData(
  502. inputs,
  503. 'search',
  504. inventoriesPagingController,
  505. lotNoFilter,
  506. );
  507. await refetchInventoryLotLineData(
  508. itemId,
  509. 'search',
  510. inventoryLotLinesPagingController,
  511. );
  512. // If inventory becomes available again after OPEN/ADJ, sync selected row.
  513. if (itemId != null && invRes?.records?.length) {
  514. const target = invRes.records.find((r) => r.itemId === itemId);
  515. if (target) setSelectedInventory(target);
  516. }
  517. }}
  518. />
  519. ) : detailTab === 1 ? (
  520. <InventoryItemLedger
  521. key={selectedInventory?.itemId ?? 'none'}
  522. inventory={selectedInventory}
  523. />
  524. ) : (
  525. <InventoryItemUom
  526. key={selectedInventory?.itemId ?? 'none'}
  527. inventory={selectedInventory}
  528. />
  529. )}
  530. </>
  531. );
  532. };
  533. export default InventorySearch;