diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index 701b674..c919510 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -1,16 +1,36 @@ -"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 { isEqual, orderBy, uniq, uniqBy } from "lodash"; -import SearchResults, { Column } from "../SearchResults"; -import { CheckCircleOutline, DoDisturb } from "@mui/icons-material"; -import InventoryTable from "./InventoryTable"; -import { defaultPagingController } from "../SearchResults/SearchResults"; -import InventoryLotLineTable from "./InventoryLotLineTable"; -import { SearchInventory, SearchInventoryLotLine, fetchInventories, fetchInventoryLotLines } from "@/app/api/inventory/actions"; -import { PrinterCombo } from "@/app/api/settings/printer"; +'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 { + 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[]; @@ -35,7 +55,7 @@ type SearchQuery = Partial< type SearchParamNames = keyof SearchQuery; const InventorySearch: React.FC = ({ inventories, printerCombo }) => { - const { t } = useTranslation(["inventory", "common"]); + const { t } = useTranslation(['inventory', 'common', 'item']); // Inventory const [filteredInventories, setFilteredInventories] = useState([]); @@ -48,32 +68,42 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController) const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0) - const defaultInputs = useMemo(() => ({ - itemId: "", - itemCode: "", - itemName: "", - itemType: "", - onHandQty: "", - onHoldQty: "", - unavailableQty: "", - availableQty: "", - currencyName: "", - status: "", - baseUom: "", - uomShortDesc: "", - latestMarketUnitPrice: "", - latestMupUpdatedDate: "", - }), []) + // 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: '', + itemCode: '', + itemName: '', + itemType: '', + onHandQty: '', + onHoldQty: '', + unavailableQty: '', + availableQty: '', + currencyName: '', + status: '', + baseUom: '', + uomShortDesc: '', + latestMarketUnitPrice: '', + latestMupUpdatedDate: '', + }), + [], + ); const [inputs, setInputs] = useState>(defaultInputs); const searchCriteria: Criterion[] = useMemo( () => [ - { label: t("Code"), paramName: "itemCode", type: "text" }, - { label: t("Name"), paramName: "itemName", type: "text" }, + { label: t('Code'), paramName: 'itemCode', type: 'text' }, + { label: t('Name'), paramName: 'itemName', type: 'text' }, { - label: t("Type"), - paramName: "itemType", - type: "select", + label: t('Type'), + paramName: 'itemType', + type: 'select', options: uniq(inventories.map((i) => i.itemType)), }, // { @@ -83,112 +113,112 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { // options: uniq(inventories.map((i) => i.status)), // }, ], - [t], + [t, inventories], ); // Inventory - const refetchInventoryData = useCallback(async ( - query: Record, - actionType: "reset" | "search" | "paging" | "init", - pagingController: typeof defaultPagingController, - ) => { - 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 ?? '', - pageNum: pagingController.pageNum - 1, - pageSize: pagingController.pageSize - } + const refetchInventoryData = useCallback( + async ( + query: Record, + actionType: 'reset' | 'search' | 'paging' | 'init', + pagingController: typeof defaultPagingController, + ) => { + 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 ?? '', + 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) => - // orderBy( - uniqBy([...fi, ...response.records], "id") - // , ["id"], ["desc"]) - ); + 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'), + ); + } } - } - }, []) + }, + [], + ); useEffect(() => { - refetchInventoryData(defaultInputs, "init", defaultPagingController) - }, []) + refetchInventoryData(defaultInputs, 'init', defaultPagingController); + }, [defaultInputs, refetchInventoryData]); useEffect(() => { // if (!isEqual(inventoriesPagingController, defaultPagingController)) { - refetchInventoryData(inputs, "paging", inventoriesPagingController) + refetchInventoryData(inputs, 'paging', inventoriesPagingController) // } }, [inventoriesPagingController]) // 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 - } + 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 - } + // Avoid loading data again + if (actionType === 'paging' && pagingController === defaultPagingController) { + return; + } - const params: SearchInventoryLotLine = { - itemId: itemId, - pageNum: pagingController.pageNum - 1, - pageSize: pagingController.pageSize - } + 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) => - // orderBy( - uniqBy([...fi, ...response.records], "id"), - // ["id"], ["desc"]) - ); + 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) + refetchInventoryLotLineData(selectedInventory?.itemId ?? null, 'paging', inventoryLotLinesPagingController) // } }, [inventoryLotLinesPagingController]) // Reset const onReset = useCallback(() => { - refetchInventoryData(defaultInputs, "reset", defaultPagingController); - refetchInventoryLotLineData(null, "reset", defaultPagingController); + refetchInventoryData(defaultInputs, 'reset', defaultPagingController); + refetchInventoryLotLineData(null, 'reset', defaultPagingController); // setFilteredInventories(inventories); setInputs(() => defaultInputs) @@ -197,48 +227,147 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { }, []); // Click Row - const onInventoryRowClick = useCallback((item: InventoryResult) => { - refetchInventoryLotLineData(item.itemId, "search", defaultPagingController) - - setSelectedInventory(item) - setInventoryLotLinesPagingController(() => defaultPagingController) - }, []) + const onInventoryRowClick = useCallback( + (item: InventoryResult) => { + refetchInventoryLotLineData(item.itemId, 'search', defaultPagingController); + setSelectedInventory(item); + setInventoryLotLinesPagingController(() => defaultPagingController); + }, + [refetchInventoryLotLineData], + ); // On Search - const onSearch = useCallback((query: Record) => { - refetchInventoryData(query, "search", defaultPagingController) - refetchInventoryLotLineData(null, "search", defaultPagingController); + const onSearch = useCallback( + (query: Record) => { + refetchInventoryData(query, 'search', defaultPagingController); + refetchInventoryLotLineData(null, 'search', defaultPagingController); - setInputs(() => query) - setInventoriesPagingController(() => defaultPagingController) - setInventoryLotLinesPagingController(() => defaultPagingController) - }, [refetchInventoryData]) + setInputs(() => query); + setInventoriesPagingController(() => defaultPagingController); + setInventoryLotLinesPagingController(() => defaultPagingController); + }, + [refetchInventoryData, refetchInventoryLotLineData], + ); + + 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; + } - console.log("", "color: #666", inventoriesPagingController) + 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; + } + + // 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 ( <> { - onSearch(query) - // console.log(query) - // console.log(inventories) - // setInputs(() => query) - // refetchInventoryData(query, "search", defaultPagingController) - // setFilteredInventories( - // inventories.filter( - // (i) => - // i.itemCode.toLowerCase().includes(query.itemCode.toLowerCase()) && - // i.itemName.toLowerCase().includes(query.itemName.toLowerCase()) && - // (query.itemType == "All" || - // i.itemType.toLowerCase().includes(query.itemType.toLowerCase())) && - // (query.status == "All" || - // i.status.toLowerCase().includes(query.status.toLowerCase())), - // ), - // ); + onSearch(query); }} onReset={onReset} + extraActions={ + + } /> = ({ inventories, printerCombo }) => { inventory={selectedInventory} printerCombo={printerCombo ?? []} onStockTransferSuccess={() => - refetchInventoryLotLineData(selectedInventory?.itemId ?? null, "search", inventoryLotLinesPagingController) + refetchInventoryLotLineData( + selectedInventory?.itemId ?? null, + 'search', + inventoryLotLinesPagingController, + ) } onStockAdjustmentSuccess={() => - refetchInventoryLotLineData(selectedInventory?.itemId ?? null, "search", inventoryLotLinesPagingController) + refetchInventoryLotLineData( + selectedInventory?.itemId ?? null, + 'search', + inventoryLotLinesPagingController, + ) } /> + + 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('現有庫存')} + + + + {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 : '-'} + + + ); + })} + +
+ )} +
+ + + + +
); }; diff --git a/src/i18n/en/inventory.json b/src/i18n/en/inventory.json index 8816a4d..07041bf 100644 --- a/src/i18n/en/inventory.json +++ b/src/i18n/en/inventory.json @@ -1,4 +1,7 @@ { "Average unit price": "Average unit price", - "Latest market unit price": "Latest market unit price" + "Latest market unit price": "Latest market unit price", + "Add entry for items without inventory": "Add entry for items without inventory", + "Enter item code or name to search": "Enter item code or name to search", + "Current Stock": "Current Stock" } \ No newline at end of file diff --git a/src/i18n/en/items.json b/src/i18n/en/items.json index cf58fad..1acc8c5 100644 --- a/src/i18n/en/items.json +++ b/src/i18n/en/items.json @@ -16,5 +16,7 @@ "QC Checklist": "QC Checklist", "QC Type": "QC Type", "IPQC": "IPQC", - "EPQC": "EPQC" + "EPQC": "EPQC", + "Item": "Item", + "Code or name": "Code or name" } \ No newline at end of file diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 1cf2248..73cad78 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -567,5 +567,6 @@ "Upload row errors": "以下行有問題:", "item(s) updated": "個項目已更新。", "Average unit price": "平均單位價格", - "Latest market unit price": "最新市場價格" + "Latest market unit price": "最新市場價格", + "Current Stock": "現有庫存" } \ No newline at end of file diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index 8d320c5..b6b53df 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -268,6 +268,10 @@ "Confirm remove": "確認移除", "Adjusted Qty": "調整後倉存", "Average unit price": "平均單位價格", - "Latest market unit price": "最新市場價格" + "Latest market unit price": "最新市場價格", + "Add entry for items without inventory": "為無庫存貨品新增倉存", + "Enter item code or name to search": "輸入貨品編號或名稱以搜索", + "Current Stock": "現有庫存", + "No Data": "沒有數據" } diff --git a/src/i18n/zh/items.json b/src/i18n/zh/items.json index d2043a0..3fa7479 100644 --- a/src/i18n/zh/items.json +++ b/src/i18n/zh/items.json @@ -52,4 +52,7 @@ "QC Type": "質檢種類", "IPQC": "IPQC", "EPQC": "EPQC" +, + "Item": "貨品", + "Code or name": "編號或名稱" } \ No newline at end of file