From 1ebfc7df95bea78946dd327cff0729c3558e0ad4 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 8 Sep 2026 18:06:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BA=AB=E5=AD=98=E6=9F=A5=E8=A9=A2=20/=20?= =?UTF-8?q?=E7=9B=A4=E9=BB=9E=E8=AA=BF=E6=95=B4=E6=AC=8A=E9=99=90=20/=20?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=E7=8F=BE=E6=B3=81=E5=A0=B1=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/inventory/actions.ts | 2 +- .../InventorySearch/InventoryLotLineTable.tsx | 8 +- .../InventorySearch/InventorySearch.tsx | 396 +++++------------- 3 files changed, 116 insertions(+), 290 deletions(-) diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts index 5da0eeb6..fb7d8643 100644 --- a/src/app/api/inventory/actions.ts +++ b/src/app/api/inventory/actions.ts @@ -172,7 +172,7 @@ async function fetchInventoriesLatestImpl(data: SearchInventory) { export const fetchInventories = cache(fetchInventoriesImpl); /** - * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 + * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07 * Inventory search page: latest inventory row per item (no baseUnit/uomId filter). */ export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); diff --git a/src/components/InventorySearch/InventoryLotLineTable.tsx b/src/components/InventorySearch/InventoryLotLineTable.tsx index d9a2f448..fd6165d1 100644 --- a/src/components/InventorySearch/InventoryLotLineTable.tsx +++ b/src/components/InventorySearch/InventoryLotLineTable.tsx @@ -34,7 +34,7 @@ import dayjs from "dayjs"; import CheckIcon from "@mui/icons-material/Check"; import { submitStockAdjustment, StockAdjustmentLineRequest } from "@/app/api/stockAdjustment/actions"; import { useSession } from "next-auth/react"; -import { AUTH } from "@/authorities"; +import { AUTH, hasAbility } from "@/authorities"; type AdjustmentEntry = InventoryLotLineResult & { adjustedQty: number; @@ -59,7 +59,7 @@ interface Props { onStockAdjustmentSuccess?: () => void | Promise; } -/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.6 | 2026-09-07 */ const InventoryLotLineTable: React.FC = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory, filterLotNo, @@ -68,8 +68,8 @@ const InventoryLotLineTable: React.FC = ({ }) => { const { t } = useTranslation(["inventory"]); const { data: session } = useSession(); - const abilities = session?.user?.abilities ?? []; - const canStockAdjust = abilities.includes(AUTH.INVENTORY_ADJUST); + const abilities = session?.abilities ?? session?.user?.abilities ?? []; + const canStockAdjust = hasAbility(abilities, AUTH.INVENTORY_ADJUST); const PRINT_PRINTER_ID_KEY = 'inventoryLotLinePrintPrinterId'; const { setIsUploading } = useUploadContext(); const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false); diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index 778e6d66..a59ff224 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -2,7 +2,7 @@ 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { uniq, uniqBy } from 'lodash'; import InventoryTable from './InventoryTable'; import { defaultPagingController } from '../SearchResults/SearchResults'; @@ -16,23 +16,10 @@ import { fetchInventoryLotLines, } from '@/app/api/inventory/actions'; import { PrinterCombo } from '@/app/api/settings/printer'; -import { ItemCombo, fetchItemsWithDetails, ItemWithDetails } from '@/app/api/settings/item/actions'; -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - TextField, - Box, - CircularProgress, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Radio, -} from '@mui/material'; +import { fetchItemsByPage } from '@/app/api/settings/item/actions'; +import { useSession } from 'next-auth/react'; +import { AUTH, hasAbility } from '@/authorities'; +import { Button, Box } from '@mui/material'; interface Props { inventories: InventoryResult[]; @@ -56,42 +43,68 @@ type SearchQuery = Partial< >; type SearchParamNames = keyof SearchQuery; -/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 */ +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.3 | 2026-09-07 */ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { const { t } = useTranslation(['inventory', 'common', 'item']); - - const buildSyntheticInventory = useCallback( - (item: ItemWithDetails): InventoryResult => ({ + 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: item.id, + itemId: Number(item.id), itemCode: item.code, itemName: item.name, - itemType: 'Material', + itemType: item.type || 'mat', onHandQty: 0, onHoldQty: 0, unavailableQty: 0, availableQty: 0, - uomCode: item.uom, - uomUdfudesc: item.uomDesc, - uomShortDesc: item.uom, + uomCode: item.uom || uom, + uomUdfudesc: uom, + uomShortDesc: item.uom || uom, qtyPerSmallestUnit: 1, - baseUom: item.uom, + baseUom: uom, price: 0, currencyName: '', status: 'active', latestMarketUnitPrice: undefined, latestMupUpdatedDate: undefined, - }), - [], - ); - - const getFirstItemRecord = useCallback((res: any): ItemWithDetails | null => { - if (!res) return null; - if (Array.isArray(res)) return (res[0] as ItemWithDetails) ?? null; - if (Array.isArray(res?.records)) return (res.records[0] as ItemWithDetails) ?? null; - return null; + }; }, []); + const lookupItemsByCodeOrName = useCallback(async (code?: string, name?: string) => { + const trimmedCode = code?.trim(); + const trimmedName = name?.trim(); + if (!trimmedCode && !trimmedName) return []; + const params: Record = { 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([]); @@ -104,6 +117,20 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { 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'); @@ -113,13 +140,6 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { const [lotNoFilter, setLotNoFilter] = useState(''); const [scannedItemId, setScannedItemId] = useState(null); - // Opening inventory (pure opening stock for items without existing inventory) - const [openingItems, setOpeningItems] = useState([]); - const [openingModalOpen, setOpeningModalOpen] = useState(false); - const [openingSelectedItem, setOpeningSelectedItem] = useState(null); - const [openingLoading, setOpeningLoading] = useState(false); - const [openingSearchText, setOpeningSearchText] = useState(''); - const defaultInputs = useMemo( () => ({ itemId: '', @@ -297,46 +317,55 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { // On Search const onSearch = useCallback( async (query: Record) => { - 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); + 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); - // If there are no inventory rows, render a synthetic inventory so the "Stock Adjustment" chip can be used. - if (invRes?.records?.length === 0) { - try { - const code = query.itemCode?.trim?.(); - const name = query.itemName?.trim?.(); - const lookupParams = code ? { code } : name ? { name } : null; - - if (lookupParams) { - const itemRes = await fetchItemsWithDetails(lookupParams); - const firstItem = getFirstItemRecord(itemRes); - if (firstItem) { - setSelectedInventory(buildSyntheticInventory(firstItem)); - setFilteredInventoryLotLines([]); - 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); } - } catch (e) { - console.error('Failed to build synthetic inventory:', e); } + } finally { + searchInFlightRef.current = false; } }, [ qrScanner, refetchInventoryData, refetchInventoryLotLineData, - buildSyntheticInventory, - getFirstItemRecord, + lookupItemsByCodeOrName, + applyItemsAsSyntheticInventories, + canStockAdjust, ], ); @@ -382,13 +411,11 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { onInventoryRowClick(target); } else { refetchInventoryLotLineData(null, 'search', defaultPagingController); - // No inventory rows for this scanned item => show synthetic inventory with the existing chip workflow. - const itemRes = await fetchItemsWithDetails({ code: res?.itemCode }); - const firstItem = getFirstItemRecord(itemRes); - if (firstItem) { - setSelectedInventory(buildSyntheticInventory(firstItem)); - setFilteredInventoryLotLines([]); - setInventoryLotLinesPagingController(() => defaultPagingController); + if (canStockAdjust) { + const items = await lookupItemsByCodeOrName(res?.itemCode); + if (!applyItemsAsSyntheticInventories(items)) { + setSelectedInventory(null); + } } else { setSelectedInventory(null); } @@ -410,112 +437,12 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { qrScanner.result, refetchInventoryData, refetchInventoryLotLineData, - buildSyntheticInventory, - getFirstItemRecord, + lookupItemsByCodeOrName, + applyItemsAsSyntheticInventories, + canStockAdjust, 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 = { - 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; - } - - 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 ( <> = ({ inventories, printerCombo }) => { )} - - } /> @@ -600,98 +518,6 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { } }} /> - - setOpeningModalOpen(false)} - fullWidth - maxWidth="md" - > - {t('Add entry for items without inventory')} - - - setOpeningSearchText(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleOpeningSearch(); - } - }} - sx={{ flex: 2 }} - /> - - - - {openingItems.length === 0 && !openingLoading ? ( - - {openingSearchText - ? t('No data') - : t('Enter item code or name to search')} - - ) : ( - - - - - {t('Code')} - {t('Name')} - {t('UoM')} - {t('Current Stock')} - - - - {openingItems.map((it) => { - const [code, ...nameParts] = (it.label ?? '').split(' - '); - const name = nameParts.join(' - '); - const selected = openingSelectedItem?.id === it.id; - return ( - setOpeningSelectedItem(it)} - sx={{ cursor: 'pointer' }} - > - - - - {code} - {name} - {it.uomDesc || it.uom} - - {it.currentStockBalance != null ? it.currentStockBalance : '-'} - - - ); - })} - -
- )} -
- - - - -
); };