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.
 
 

607 lines
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, 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 { useQrCodeScannerContext } from '@/components/QrCodeScannerProvider/QrCodeScannerProvider';
  11. import {
  12. analyzeQrCode,
  13. SearchInventory,
  14. SearchInventoryLotLine,
  15. fetchInventories,
  16. fetchInventoryLotLines,
  17. } from '@/app/api/inventory/actions';
  18. import { PrinterCombo } from '@/app/api/settings/printer';
  19. import { ItemCombo, fetchItemsWithDetails } from '@/app/api/settings/item/actions';
  20. import {
  21. Button,
  22. Dialog,
  23. DialogActions,
  24. DialogContent,
  25. DialogTitle,
  26. TextField,
  27. Box,
  28. CircularProgress,
  29. Table,
  30. TableBody,
  31. TableCell,
  32. TableHead,
  33. TableRow,
  34. Radio,
  35. } from '@mui/material';
  36. interface Props {
  37. inventories: InventoryResult[];
  38. printerCombo?: PrinterCombo[];
  39. }
  40. type SearchQuery = Partial<
  41. Omit<
  42. InventoryResult,
  43. | "id"
  44. | "qty"
  45. | "uomCode"
  46. | "uomUdfudesc"
  47. | "germPerSmallestUnit"
  48. | "qtyPerSmallestUnit"
  49. | "itemSmallestUnit"
  50. | "price"
  51. | "description"
  52. | "category"
  53. >
  54. >;
  55. type SearchParamNames = keyof SearchQuery;
  56. const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
  57. const { t } = useTranslation(['inventory', 'common', 'item']);
  58. // Inventory
  59. const [filteredInventories, setFilteredInventories] = useState<InventoryResult[]>([]);
  60. const [inventoriesPagingController, setInventoriesPagingController] = useState(defaultPagingController)
  61. const [inventoriesTotalCount, setInventoriesTotalCount] = useState(0)
  62. const [selectedInventory, setSelectedInventory] = useState<InventoryResult | null>(null)
  63. // Inventory Lot Line
  64. const [filteredInventoryLotLines, setFilteredInventoryLotLines] = useState<InventoryLotLineResult[]>([]);
  65. const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController)
  66. const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0)
  67. // Scan-mode UI (hardware QR scanner via QrCodeScannerProvider)
  68. const qrScanner = useQrCodeScannerContext();
  69. const [scanUiMode, setScanUiMode] = useState<'idle' | 'scanning'>('idle');
  70. const [scanHoverCancel, setScanHoverCancel] = useState(false);
  71. // Resolved lot no for filtering
  72. const [lotNoFilter, setLotNoFilter] = useState('');
  73. const [scannedItemId, setScannedItemId] = useState<number | null>(null);
  74. // Opening inventory (pure opening stock for items without existing inventory)
  75. const [openingItems, setOpeningItems] = useState<ItemCombo[]>([]);
  76. const [openingModalOpen, setOpeningModalOpen] = useState(false);
  77. const [openingSelectedItem, setOpeningSelectedItem] = useState<ItemCombo | null>(null);
  78. const [openingLoading, setOpeningLoading] = useState(false);
  79. const [openingSearchText, setOpeningSearchText] = useState('');
  80. const defaultInputs = useMemo(
  81. () => ({
  82. itemId: '',
  83. itemCode: '',
  84. itemName: '',
  85. itemType: '',
  86. onHandQty: '',
  87. onHoldQty: '',
  88. unavailableQty: '',
  89. availableQty: '',
  90. currencyName: '',
  91. status: '',
  92. baseUom: '',
  93. uomShortDesc: '',
  94. latestMarketUnitPrice: '',
  95. latestMupUpdatedDate: '',
  96. }),
  97. [],
  98. );
  99. const [inputs, setInputs] = useState<Record<SearchParamNames, string>>(defaultInputs);
  100. const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
  101. () => [
  102. { label: t('Code'), paramName: 'itemCode', type: 'text' },
  103. { label: t('Name'), paramName: 'itemName', type: 'text' },
  104. {
  105. label: t('Type'),
  106. paramName: 'itemType',
  107. type: 'select',
  108. options: uniq(inventories.map((i) => i.itemType)),
  109. },
  110. // {
  111. // label: t("Status"),
  112. // paramName: "status",
  113. // type: "select",
  114. // options: uniq(inventories.map((i) => i.status)),
  115. // },
  116. ],
  117. [t, inventories],
  118. );
  119. // Inventory
  120. const refetchInventoryData = useCallback(
  121. async (
  122. query: Record<SearchParamNames, string>,
  123. actionType: 'reset' | 'search' | 'paging' | 'init',
  124. pagingController: typeof defaultPagingController,
  125. lotNo: string,
  126. ) => {
  127. console.log('%c Action Type 1.', 'color:red', actionType);
  128. // Avoid loading data again
  129. if (actionType === 'paging' && pagingController === defaultPagingController) {
  130. return;
  131. }
  132. console.log('%c Action Type 2.', 'color:blue', actionType);
  133. const params: SearchInventory = {
  134. code: query?.itemCode ?? '',
  135. name: query?.itemName ?? '',
  136. type: query?.itemType.toLowerCase() === 'all' ? '' : query?.itemType ?? '',
  137. lotNo: lotNo?.trim() ? lotNo.trim() : undefined,
  138. pageNum: pagingController.pageNum - 1,
  139. pageSize: pagingController.pageSize,
  140. };
  141. const response = await fetchInventories(params);
  142. if (response) {
  143. setInventoriesTotalCount(response.total);
  144. switch (actionType) {
  145. case 'init':
  146. case 'reset':
  147. case 'search':
  148. setFilteredInventories(() => response.records);
  149. break;
  150. case 'paging':
  151. setFilteredInventories((fi) =>
  152. uniqBy([...fi, ...response.records], 'id'),
  153. );
  154. }
  155. }
  156. return response;
  157. },
  158. [],
  159. );
  160. useEffect(() => {
  161. refetchInventoryData(defaultInputs, 'init', defaultPagingController, '');
  162. }, [defaultInputs, refetchInventoryData]);
  163. useEffect(() => {
  164. // if (!isEqual(inventoriesPagingController, defaultPagingController)) {
  165. refetchInventoryData(inputs, 'paging', inventoriesPagingController, lotNoFilter)
  166. // }
  167. }, [inventoriesPagingController, inputs, lotNoFilter, refetchInventoryData])
  168. // Inventory Lot Line
  169. const refetchInventoryLotLineData = useCallback(
  170. async (
  171. itemId: number | null,
  172. actionType: 'reset' | 'search' | 'paging',
  173. pagingController: typeof defaultPagingController,
  174. ) => {
  175. if (!itemId) {
  176. setSelectedInventory(null);
  177. setInventoryLotLinesTotalCount(0);
  178. setFilteredInventoryLotLines([]);
  179. return;
  180. }
  181. // Avoid loading data again
  182. if (actionType === 'paging' && pagingController === defaultPagingController) {
  183. return;
  184. }
  185. const params: SearchInventoryLotLine = {
  186. itemId,
  187. pageNum: pagingController.pageNum - 1,
  188. pageSize: pagingController.pageSize,
  189. };
  190. const response = await fetchInventoryLotLines(params);
  191. if (response) {
  192. setInventoryLotLinesTotalCount(response.total);
  193. switch (actionType) {
  194. case 'reset':
  195. case 'search':
  196. setFilteredInventoryLotLines(() => response.records);
  197. break;
  198. case 'paging':
  199. setFilteredInventoryLotLines((fi) => uniqBy([...fi, ...response.records], 'id'));
  200. }
  201. }
  202. },
  203. [],
  204. );
  205. useEffect(() => {
  206. // if (!isEqual(inventoryLotLinesPagingController, defaultPagingController)) {
  207. refetchInventoryLotLineData(selectedInventory?.itemId ?? null, 'paging', inventoryLotLinesPagingController)
  208. // }
  209. }, [inventoryLotLinesPagingController])
  210. // Reset
  211. const onReset = useCallback(() => {
  212. refetchInventoryData(defaultInputs, 'reset', defaultPagingController, '');
  213. refetchInventoryLotLineData(null, 'reset', defaultPagingController);
  214. // setFilteredInventories(inventories);
  215. setLotNoFilter('');
  216. setScannedItemId(null);
  217. setScanUiMode('idle');
  218. setScanHoverCancel(false);
  219. qrScanner.stopScan();
  220. qrScanner.resetScan();
  221. setInputs(() => defaultInputs)
  222. setInventoriesPagingController(() => defaultPagingController)
  223. setInventoryLotLinesPagingController(() => defaultPagingController)
  224. }, [defaultInputs, qrScanner, refetchInventoryData, refetchInventoryLotLineData]);
  225. // Click Row
  226. const onInventoryRowClick = useCallback(
  227. (item: InventoryResult) => {
  228. refetchInventoryLotLineData(item.itemId, 'search', defaultPagingController);
  229. setSelectedInventory(item);
  230. setInventoryLotLinesPagingController(() => defaultPagingController);
  231. },
  232. [refetchInventoryLotLineData],
  233. );
  234. // On Search
  235. const onSearch = useCallback(
  236. (query: Record<SearchParamNames, string>) => {
  237. setLotNoFilter('');
  238. setScannedItemId(null);
  239. setScanUiMode('idle');
  240. setScanHoverCancel(false);
  241. qrScanner.stopScan();
  242. qrScanner.resetScan();
  243. refetchInventoryData(query, 'search', defaultPagingController, '');
  244. refetchInventoryLotLineData(null, 'search', defaultPagingController);
  245. setInputs(() => query);
  246. setInventoriesPagingController(() => defaultPagingController);
  247. setInventoryLotLinesPagingController(() => defaultPagingController);
  248. },
  249. [qrScanner, refetchInventoryData, refetchInventoryLotLineData],
  250. );
  251. const startLotScan = useCallback(() => {
  252. setScanHoverCancel(false);
  253. setScanUiMode('scanning');
  254. qrScanner.resetScan();
  255. qrScanner.startScan();
  256. }, [qrScanner]);
  257. const cancelLotScan = useCallback(() => {
  258. qrScanner.stopScan();
  259. qrScanner.resetScan();
  260. setScanUiMode('idle');
  261. setScanHoverCancel(false);
  262. }, [qrScanner]);
  263. useEffect(() => {
  264. if (scanUiMode !== 'scanning') return;
  265. const itemId = qrScanner.result?.itemId;
  266. const stockInLineId = qrScanner.result?.stockInLineId;
  267. if (!itemId || !stockInLineId) return;
  268. (async () => {
  269. try {
  270. const res = await analyzeQrCode({
  271. itemId: Number(itemId),
  272. stockInLineId: Number(stockInLineId),
  273. });
  274. const resolvedLotNo = res?.scanned?.lotNo?.trim?.() ? res.scanned.lotNo.trim() : '';
  275. if (!resolvedLotNo) return;
  276. setLotNoFilter(resolvedLotNo);
  277. setScannedItemId(res?.itemId ?? Number(itemId));
  278. const invRes = await refetchInventoryData(inputs, 'search', defaultPagingController, resolvedLotNo);
  279. const records = invRes?.records ?? [];
  280. const target = records.find((r) => r.itemId === (res?.itemId ?? Number(itemId))) ?? null;
  281. if (target) {
  282. onInventoryRowClick(target);
  283. } else {
  284. refetchInventoryLotLineData(null, 'search', defaultPagingController);
  285. setSelectedInventory(null);
  286. }
  287. setInventoriesPagingController(() => defaultPagingController);
  288. setInventoryLotLinesPagingController(() => defaultPagingController);
  289. } catch (e) {
  290. console.error('Failed to analyze QR code:', e);
  291. } finally {
  292. // Always go back to initial state after a scan attempt
  293. cancelLotScan();
  294. }
  295. })();
  296. }, [
  297. cancelLotScan,
  298. inputs,
  299. onInventoryRowClick,
  300. qrScanner.result,
  301. refetchInventoryData,
  302. refetchInventoryLotLineData,
  303. scanUiMode,
  304. ]);
  305. console.log('', 'color: #666', inventoriesPagingController);
  306. const handleOpenOpeningInventoryModal = useCallback(() => {
  307. setOpeningSelectedItem(null);
  308. setOpeningItems([]);
  309. setOpeningSearchText('');
  310. setOpeningModalOpen(true);
  311. }, []);
  312. const handleOpeningSearch = useCallback(async () => {
  313. const trimmed = openingSearchText.trim();
  314. if (!trimmed) {
  315. setOpeningItems([]);
  316. return;
  317. }
  318. setOpeningLoading(true);
  319. try {
  320. const searchParams: Record<string, any> = {
  321. pageSize: 50,
  322. pageNum: 1,
  323. };
  324. // Heuristic: if input contains space, treat as name; otherwise treat as code.
  325. if (trimmed.includes(' ')) {
  326. searchParams.name = trimmed;
  327. } else {
  328. searchParams.code = trimmed;
  329. }
  330. const response = await fetchItemsWithDetails(searchParams);
  331. let records: any[] = [];
  332. if (response && typeof response === 'object') {
  333. const anyRes = response as any;
  334. if (Array.isArray(anyRes.records)) {
  335. records = anyRes.records;
  336. } else if (Array.isArray(anyRes)) {
  337. records = anyRes;
  338. }
  339. }
  340. const combos: ItemCombo[] = records.map((item: any) => ({
  341. id: item.id,
  342. label: `${item.code} - ${item.name}`,
  343. uomId: item.uomId,
  344. uom: item.uom,
  345. uomDesc: item.uomDesc,
  346. group: item.group,
  347. currentStockBalance: item.currentStockBalance,
  348. }));
  349. setOpeningItems(combos);
  350. } catch (e) {
  351. console.error('Failed to search items for opening inventory:', e);
  352. setOpeningItems([]);
  353. } finally {
  354. setOpeningLoading(false);
  355. }
  356. }, [openingSearchText]);
  357. const handleConfirmOpeningInventory = useCallback(() => {
  358. if (!openingSelectedItem) {
  359. setOpeningModalOpen(false);
  360. return;
  361. }
  362. // Try to split label into code and name if possible: "CODE - Name"
  363. const rawLabel = openingSelectedItem.label ?? '';
  364. const [codePart, ...nameParts] = rawLabel.split(' - ');
  365. const itemCode = codePart?.trim() || rawLabel;
  366. const itemName = nameParts.join(' - ').trim() || itemCode;
  367. const syntheticInventory: InventoryResult = {
  368. id: 0,
  369. itemId: Number(openingSelectedItem.id),
  370. itemCode,
  371. itemName,
  372. itemType: 'Material',
  373. onHandQty: 0,
  374. onHoldQty: 0,
  375. unavailableQty: 0,
  376. availableQty: 0,
  377. uomCode: openingSelectedItem.uom,
  378. uomUdfudesc: openingSelectedItem.uomDesc,
  379. uomShortDesc: openingSelectedItem.uom,
  380. qtyPerSmallestUnit: 1,
  381. baseUom: openingSelectedItem.uom,
  382. price: 0,
  383. currencyName: '',
  384. status: 'active',
  385. latestMarketUnitPrice: undefined,
  386. latestMupUpdatedDate: undefined,
  387. };
  388. // Use this synthetic inventory to drive the stock adjustment UI
  389. setSelectedInventory(syntheticInventory);
  390. setFilteredInventoryLotLines([]);
  391. setInventoryLotLinesPagingController(() => defaultPagingController);
  392. setOpeningModalOpen(false);
  393. }, [openingSelectedItem]);
  394. return (
  395. <>
  396. <SearchBox
  397. criteria={searchCriteria}
  398. onSearch={(query) => {
  399. onSearch(query);
  400. }}
  401. onReset={onReset}
  402. extraActions={
  403. <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
  404. {scanUiMode === 'idle' ? (
  405. <Button variant="contained" onClick={startLotScan}>
  406. {t('Search lot by QR code')}
  407. </Button>
  408. ) : (
  409. <>
  410. <Button variant="contained" disabled sx={{ bgcolor: 'grey.400', color: 'grey.800' }}>
  411. {t('Please scan...')}
  412. </Button>
  413. <Button variant="contained" color="error" onClick={cancelLotScan}>
  414. {t('Stop QR Scan')}
  415. </Button>
  416. </>
  417. )}
  418. <Button
  419. variant="outlined"
  420. color="secondary"
  421. onClick={handleOpenOpeningInventoryModal}
  422. >
  423. {t('Add entry for items without inventory')}
  424. </Button>
  425. </Box>
  426. }
  427. />
  428. <InventoryTable
  429. inventories={filteredInventories}
  430. pagingController={inventoriesPagingController}
  431. setPagingController={setInventoriesPagingController}
  432. totalCount={inventoriesTotalCount}
  433. onRowClick={onInventoryRowClick}
  434. />
  435. <InventoryLotLineTable
  436. inventoryLotLines={filteredInventoryLotLines}
  437. pagingController={inventoryLotLinesPagingController}
  438. setPagingController={setInventoryLotLinesPagingController}
  439. totalCount={inventoryLotLinesTotalCount}
  440. inventory={selectedInventory}
  441. filterLotNo={lotNoFilter}
  442. printerCombo={printerCombo ?? []}
  443. onStockTransferSuccess={() =>
  444. refetchInventoryLotLineData(
  445. selectedInventory?.itemId ?? null,
  446. 'search',
  447. inventoryLotLinesPagingController,
  448. )
  449. }
  450. onStockAdjustmentSuccess={() =>
  451. refetchInventoryLotLineData(
  452. selectedInventory?.itemId ?? null,
  453. 'search',
  454. inventoryLotLinesPagingController,
  455. )
  456. }
  457. />
  458. <Dialog
  459. open={openingModalOpen}
  460. onClose={() => setOpeningModalOpen(false)}
  461. fullWidth
  462. maxWidth="md"
  463. >
  464. <DialogTitle>{t('Add entry for items without inventory')}</DialogTitle>
  465. <DialogContent sx={{ pt: 2 }}>
  466. <Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
  467. <TextField
  468. label={t('Item')}
  469. fullWidth
  470. value={openingSearchText}
  471. onChange={(e) => setOpeningSearchText(e.target.value)}
  472. onKeyDown={(e) => {
  473. if (e.key === 'Enter') {
  474. e.preventDefault();
  475. handleOpeningSearch();
  476. }
  477. }}
  478. sx={{ flex: 2 }}
  479. />
  480. <Button
  481. variant="contained"
  482. onClick={handleOpeningSearch}
  483. disabled={openingLoading}
  484. sx={{ flex: 1 }}
  485. >
  486. {openingLoading ? <CircularProgress size={20} /> : t('common:Search')}
  487. </Button>
  488. </Box>
  489. {openingItems.length === 0 && !openingLoading ? (
  490. <Box sx={{ py: 1, color: 'text.secondary', fontSize: 14 }}>
  491. {openingSearchText
  492. ? t('No data')
  493. : t('Enter item code or name to search')}
  494. </Box>
  495. ) : (
  496. <Table size="small">
  497. <TableHead>
  498. <TableRow>
  499. <TableCell />
  500. <TableCell>{t('Code')}</TableCell>
  501. <TableCell>{t('Name')}</TableCell>
  502. <TableCell>{t('UoM')}</TableCell>
  503. <TableCell align="right">{t('現有庫存')}</TableCell>
  504. </TableRow>
  505. </TableHead>
  506. <TableBody>
  507. {openingItems.map((it) => {
  508. const [code, ...nameParts] = (it.label ?? '').split(' - ');
  509. const name = nameParts.join(' - ');
  510. const selected = openingSelectedItem?.id === it.id;
  511. return (
  512. <TableRow
  513. key={it.id}
  514. hover
  515. selected={selected}
  516. onClick={() => setOpeningSelectedItem(it)}
  517. sx={{ cursor: 'pointer' }}
  518. >
  519. <TableCell padding="checkbox">
  520. <Radio checked={selected} />
  521. </TableCell>
  522. <TableCell>{code}</TableCell>
  523. <TableCell>{name}</TableCell>
  524. <TableCell>{it.uomDesc || it.uom}</TableCell>
  525. <TableCell align="right">
  526. {it.currentStockBalance != null ? it.currentStockBalance : '-'}
  527. </TableCell>
  528. </TableRow>
  529. );
  530. })}
  531. </TableBody>
  532. </Table>
  533. )}
  534. </DialogContent>
  535. <DialogActions>
  536. <Button onClick={() => setOpeningModalOpen(false)}>
  537. {t('common:Cancel')}
  538. </Button>
  539. <Button
  540. variant="contained"
  541. onClick={handleConfirmOpeningInventory}
  542. disabled={!openingSelectedItem}
  543. >
  544. {t('common:Confirm')}
  545. </Button>
  546. </DialogActions>
  547. </Dialog>
  548. </>
  549. );
  550. };
  551. export default InventorySearch;