FPSMS-frontend
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 

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