|
- 'use client';
- import { InventoryLotLineResult, InventoryResult } from '@/app/api/inventory';
- import { useTranslation } from 'react-i18next';
- import SearchBox, { Criterion } from '../SearchBox';
- import { useCallback, useEffect, useMemo, useState } from 'react';
- import { uniq, uniqBy } from 'lodash';
- import InventoryTable from './InventoryTable';
- import { defaultPagingController } from '../SearchResults/SearchResults';
- import InventoryLotLineTable from './InventoryLotLineTable';
- import { useQrCodeScannerContext } from '@/components/QrCodeScannerProvider/QrCodeScannerProvider';
- import {
- analyzeQrCode,
- SearchInventory,
- SearchInventoryLotLine,
- fetchInventories,
- fetchInventoryLotLines,
- } from '@/app/api/inventory/actions';
- import { PrinterCombo } from '@/app/api/settings/printer';
- import { ItemCombo, fetchItemsWithDetails } from '@/app/api/settings/item/actions';
- import {
- Button,
- Dialog,
- DialogActions,
- DialogContent,
- DialogTitle,
- TextField,
- Box,
- CircularProgress,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableRow,
- Radio,
- } from '@mui/material';
-
- interface Props {
- inventories: InventoryResult[];
- printerCombo?: PrinterCombo[];
- }
-
- type SearchQuery = Partial<
- Omit<
- InventoryResult,
- | "id"
- | "qty"
- | "uomCode"
- | "uomUdfudesc"
- | "germPerSmallestUnit"
- | "qtyPerSmallestUnit"
- | "itemSmallestUnit"
- | "price"
- | "description"
- | "category"
- >
- >;
- type SearchParamNames = keyof SearchQuery;
-
- const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
- const { t, i18n } = useTranslation(['inventory', 'common', 'item']);
-
- // #region agent log
- useEffect(() => {
- fetch('http://127.0.0.1:7242/ingest/8c332e19-29c4-4171-9317-8619340856a2', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-Debug-Session-Id': 'aca475',
- },
- body: JSON.stringify({
- sessionId: 'aca475',
- runId: 'run1',
- hypothesisId: 'H1',
- location: 'src/components/InventorySearch/InventorySearch.tsx:63',
- message: 'Check current stock translation lookup',
- data: {
- language: i18n.language,
- translatedCurrentStock: t('Current Stock'),
- translatedHardcodedLabel: t('現有庫存'),
- },
- timestamp: Date.now(),
- }),
- }).catch(() => {});
- }, [i18n.language, t]);
- // #endregion
-
- // Inventory
- const [filteredInventories, setFilteredInventories] = useState<InventoryResult[]>([]);
- const [inventoriesPagingController, setInventoriesPagingController] = useState(defaultPagingController)
- const [inventoriesTotalCount, setInventoriesTotalCount] = useState(0)
- const [selectedInventory, setSelectedInventory] = useState<InventoryResult | null>(null)
-
- // Inventory Lot Line
- const [filteredInventoryLotLines, setFilteredInventoryLotLines] = useState<InventoryLotLineResult[]>([]);
- const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController)
- const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0)
-
- // 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);
-
- // Opening inventory (pure opening stock for items without existing inventory)
- const [openingItems, setOpeningItems] = useState<ItemCombo[]>([]);
- const [openingModalOpen, setOpeningModalOpen] = useState(false);
- const [openingSelectedItem, setOpeningSelectedItem] = useState<ItemCombo | null>(null);
- const [openingLoading, setOpeningLoading] = useState(false);
- const [openingSearchText, setOpeningSearchText] = useState('');
-
- 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',
- options: uniq(inventories.map((i) => i.itemType)),
- },
- // {
- // 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 fetchInventories(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], 'id'),
- );
- }
- }
-
- 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],
- );
-
- // On Search
- const onSearch = useCallback(
- (query: Record<SearchParamNames, string>) => {
- setLotNoFilter('');
- setScannedItemId(null);
- setScanUiMode('idle');
- setScanHoverCancel(false);
- qrScanner.stopScan();
- qrScanner.resetScan();
- refetchInventoryData(query, 'search', defaultPagingController, '');
- refetchInventoryLotLineData(null, 'search', defaultPagingController);
-
- setInputs(() => query);
- setInventoriesPagingController(() => defaultPagingController);
- setInventoryLotLinesPagingController(() => defaultPagingController);
- },
- [qrScanner, refetchInventoryData, refetchInventoryLotLineData],
- );
-
- 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);
- 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,
- scanUiMode,
- ]);
-
- console.log('', 'color: #666', inventoriesPagingController);
-
- const handleOpenOpeningInventoryModal = useCallback(() => {
- setOpeningSelectedItem(null);
- setOpeningItems([]);
- setOpeningSearchText('');
- setOpeningModalOpen(true);
- }, []);
-
- const handleOpeningSearch = useCallback(async () => {
- const trimmed = openingSearchText.trim();
- if (!trimmed) {
- setOpeningItems([]);
- return;
- }
-
- setOpeningLoading(true);
- try {
- const searchParams: Record<string, any> = {
- pageSize: 50,
- pageNum: 1,
- };
-
- // Heuristic: if input contains space, treat as name; otherwise treat as code.
- if (trimmed.includes(' ')) {
- searchParams.name = trimmed;
- } else {
- searchParams.code = trimmed;
- }
-
- const response = await fetchItemsWithDetails(searchParams);
-
- let records: any[] = [];
- if (response && typeof response === 'object') {
- const anyRes = response as any;
- if (Array.isArray(anyRes.records)) {
- records = anyRes.records;
- } else if (Array.isArray(anyRes)) {
- records = anyRes;
- }
- }
-
- const combos: ItemCombo[] = records.map((item: any) => ({
- id: item.id,
- label: `${item.code} - ${item.name}`,
- uomId: item.uomId,
- uom: item.uom,
- uomDesc: item.uomDesc,
- group: item.group,
- currentStockBalance: item.currentStockBalance,
- }));
-
- setOpeningItems(combos);
- } catch (e) {
- console.error('Failed to search items for opening inventory:', e);
- setOpeningItems([]);
- } finally {
- setOpeningLoading(false);
- }
- }, [openingSearchText]);
-
- const handleConfirmOpeningInventory = useCallback(() => {
- if (!openingSelectedItem) {
- setOpeningModalOpen(false);
- return;
- }
-
- // Try to split label into code and name if possible: "CODE - Name"
- const rawLabel = openingSelectedItem.label ?? '';
- const [codePart, ...nameParts] = rawLabel.split(' - ');
- const itemCode = codePart?.trim() || rawLabel;
- const itemName = nameParts.join(' - ').trim() || itemCode;
-
- const syntheticInventory: InventoryResult = {
- id: 0,
- itemId: Number(openingSelectedItem.id),
- itemCode,
- itemName,
- itemType: 'Material',
- onHandQty: 0,
- onHoldQty: 0,
- unavailableQty: 0,
- availableQty: 0,
- uomCode: openingSelectedItem.uom,
- uomUdfudesc: openingSelectedItem.uomDesc,
- uomShortDesc: openingSelectedItem.uom,
- qtyPerSmallestUnit: 1,
- baseUom: openingSelectedItem.uom,
- price: 0,
- currencyName: '',
- status: 'active',
- latestMarketUnitPrice: undefined,
- latestMupUpdatedDate: undefined,
- };
-
- // Use this synthetic inventory to drive the stock adjustment UI
- setSelectedInventory(syntheticInventory);
- setFilteredInventoryLotLines([]);
- setInventoryLotLinesPagingController(() => defaultPagingController);
- setOpeningModalOpen(false);
- }, [openingSelectedItem]);
-
- 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>
- </>
- )}
-
- <Button
- variant="outlined"
- color="secondary"
- onClick={handleOpenOpeningInventoryModal}
- >
- {t('Add entry for items without inventory')}
- </Button>
- </Box>
- }
- />
- <InventoryTable
- inventories={filteredInventories}
- pagingController={inventoriesPagingController}
- setPagingController={setInventoriesPagingController}
- totalCount={inventoriesTotalCount}
- onRowClick={onInventoryRowClick}
- />
- <InventoryLotLineTable
- inventoryLotLines={filteredInventoryLotLines}
- pagingController={inventoryLotLinesPagingController}
- setPagingController={setInventoryLotLinesPagingController}
- totalCount={inventoryLotLinesTotalCount}
- inventory={selectedInventory}
- filterLotNo={lotNoFilter}
- printerCombo={printerCombo ?? []}
- onStockTransferSuccess={() =>
- refetchInventoryLotLineData(
- selectedInventory?.itemId ?? null,
- 'search',
- inventoryLotLinesPagingController,
- )
- }
- onStockAdjustmentSuccess={() =>
- refetchInventoryLotLineData(
- selectedInventory?.itemId ?? null,
- 'search',
- inventoryLotLinesPagingController,
- )
- }
- />
-
- <Dialog
- open={openingModalOpen}
- onClose={() => setOpeningModalOpen(false)}
- fullWidth
- maxWidth="md"
- >
- <DialogTitle>{t('Add entry for items without inventory')}</DialogTitle>
- <DialogContent sx={{ pt: 2 }}>
- <Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
- <TextField
- label={t('Item')}
- fullWidth
- value={openingSearchText}
- onChange={(e) => setOpeningSearchText(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- handleOpeningSearch();
- }
- }}
- sx={{ flex: 2 }}
- />
- <Button
- variant="contained"
- onClick={handleOpeningSearch}
- disabled={openingLoading}
- sx={{ flex: 1 }}
- >
- {openingLoading ? <CircularProgress size={20} /> : t('common:Search')}
- </Button>
- </Box>
-
- {openingItems.length === 0 && !openingLoading ? (
- <Box sx={{ py: 1, color: 'text.secondary', fontSize: 14 }}>
- {openingSearchText
- ? t('No data')
- : t('Enter item code or name to search')}
- </Box>
- ) : (
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell />
- <TableCell>{t('Code')}</TableCell>
- <TableCell>{t('Name')}</TableCell>
- <TableCell>{t('UoM')}</TableCell>
- <TableCell align="right">{t('Current Stock')}</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {openingItems.map((it) => {
- const [code, ...nameParts] = (it.label ?? '').split(' - ');
- const name = nameParts.join(' - ');
- const selected = openingSelectedItem?.id === it.id;
- return (
- <TableRow
- key={it.id}
- hover
- selected={selected}
- onClick={() => setOpeningSelectedItem(it)}
- sx={{ cursor: 'pointer' }}
- >
- <TableCell padding="checkbox">
- <Radio checked={selected} />
- </TableCell>
- <TableCell>{code}</TableCell>
- <TableCell>{name}</TableCell>
- <TableCell>{it.uomDesc || it.uom}</TableCell>
- <TableCell align="right">
- {it.currentStockBalance != null ? it.currentStockBalance : '-'}
- </TableCell>
- </TableRow>
- );
- })}
- </TableBody>
- </Table>
- )}
- </DialogContent>
- <DialogActions>
- <Button onClick={() => setOpeningModalOpen(false)}>
- {t('common:Cancel')}
- </Button>
- <Button
- variant="contained"
- onClick={handleConfirmOpeningInventory}
- disabled={!openingSelectedItem}
- >
- {t('common:Confirm')}
- </Button>
- </DialogActions>
- </Dialog>
- </>
- );
- };
-
- export default InventorySearch;
|