From 3e13a386d5b406da93b8e16edf5aff49d66bbf12 Mon Sep 17 00:00:00 2001 From: "cyril.tsui" Date: Fri, 25 Jul 2025 18:13:20 +0800 Subject: [PATCH] [Inventory] Update inventory --- .../InventorySearch/InventoryLotLineTable.tsx | 110 ++++++++ .../InventorySearch/InventorySearch.tsx | 246 +++++++++++------- .../InventorySearch/InventoryTable.tsx | 111 ++++++++ src/components/SearchBox/SearchBox.tsx | 2 +- .../SearchResults/SearchResults.tsx | 83 +++--- src/i18n/zh/common.json | 1 + src/i18n/zh/inventory.json | 12 +- 7 files changed, 429 insertions(+), 136 deletions(-) create mode 100644 src/components/InventorySearch/InventoryLotLineTable.tsx create mode 100644 src/components/InventorySearch/InventoryTable.tsx diff --git a/src/components/InventorySearch/InventoryLotLineTable.tsx b/src/components/InventorySearch/InventoryLotLineTable.tsx new file mode 100644 index 0000000..7db97bc --- /dev/null +++ b/src/components/InventorySearch/InventoryLotLineTable.tsx @@ -0,0 +1,110 @@ +import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory"; +import { Dispatch, SetStateAction, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Column } from "../SearchResults"; +import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults"; +import { CheckCircleOutline, DoDisturb } from "@mui/icons-material"; +import { arrayToDateString } from "@/app/utils/formatUtil"; +import { Typography } from "@mui/material"; +import { isFinite } from "lodash"; + +interface Props { + inventoryLotLines: InventoryLotLineResult[] | null; + setPagingController: defaultSetPagingController; + pagingController: typeof defaultPagingController; + totalCount: number; + item: InventoryResult | null; +} + +const InventoryLotLineTable: React.FC = ({ inventoryLotLines, pagingController, setPagingController, totalCount, item }) => { + const { t } = useTranslation(["inventory"]); + + const columns = useMemo[]>( + () => [ + // { + // name: "item", + // label: t("Code"), + // renderCell: (params) => { + // return params.item.code; + // }, + // }, + // { + // name: "item", + // label: t("Name"), + // renderCell: (params) => { + // return params.item.name; + // }, + // }, + { + name: "lotNo", + label: t("Lot No"), + }, + // { + // name: "item", + // label: t("Type"), + // renderCell: (params) => { + // return t(params.item.type); + // }, + // }, + { + name: "availableQty", + label: t("Available Qty"), + align: "right", + headerAlign: "right", + type: "integer", + }, + { + name: "uom", + label: t("Sales UoM"), + align: "left", + headerAlign: "left", + }, + { + name: "qtyPerSmallestUnit", + label: t("Available Qty Per Smallest Unit"), + align: "right", + headerAlign: "right", + type: "integer", + }, + { + name: "baseUom", + label: t("Base UoM"), + align: "left", + headerAlign: "left", + }, + { + name: "expiryDate", + label: t("Expiry Date"), + renderCell: (params) => { + return arrayToDateString(params.expiryDate) + }, + }, + { + name: "status", + label: t("Status"), + type: "icon", + icons: { + available: , + unavailable: , + }, + colors: { + available: "success", + unavailable: "error", + } + }, + ], + [t], + ); + return <> + {item ? `${t("Item selected")}: ${item.itemCode} | ${item.itemName} (${t(item.itemType)})` : t("No items are selected yet.")} + + items={inventoryLotLines ?? []} + columns={columns} + pagingController={pagingController} + setPagingController={setPagingController} + totalCount={totalCount} + /> + +} + +export default InventoryLotLineTable; \ No newline at end of file diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index b0440e2..d3866e6 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -1,11 +1,15 @@ "use client"; -import { InventoryResult } from "@/app/api/inventory"; +import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory"; import { useTranslation } from "react-i18next"; import SearchBox, { Criterion } from "../SearchBox"; -import { useCallback, useMemo, useState } from "react"; -import { uniq } from "lodash"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { 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"; interface Props { inventories: InventoryResult[]; @@ -31,7 +35,30 @@ type SearchParamNames = keyof SearchQuery; const InventorySearch: React.FC = ({ inventories }) => { const { t } = useTranslation(["inventory", "common"]); - const [filteredInventories, setFilteredInventories] = useState(inventories); + // Inventory + const [filteredInventories, setFilteredInventories] = useState([]); + const [inventoriesPagingController, setInventoriesPagingController] = useState(defaultPagingController) + const [inventoriesTotalCount, setInventoriesTotalCount] = useState(0) + const [item, setItem] = useState(null) + + // Inventory Lot Line + const [filteredInventoryLotLines, setFilteredInventoryLotLines] = useState([]); + const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController) + const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0) + + const [inputs, setInputs] = useState>({ + itemId: "", + itemCode: "", + itemName: "", + itemType: "", + onHandQty: "", + onHoldQty: "", + unavailableQty: "", + availableQty: "", + currencyName: "", + status: "", + baseUom: "", + }); const searchCriteria: Criterion[] = useMemo( () => [ @@ -43,88 +70,102 @@ const InventorySearch: React.FC = ({ inventories }) => { type: "select", options: uniq(inventories.map((i) => i.itemType)), }, - { - label: t("Status"), - paramName: "status", - type: "select", - options: uniq(inventories.map((i) => i.status)), - }, + // { + // label: t("Status"), + // paramName: "status", + // type: "select", + // options: uniq(inventories.map((i) => i.status)), + // }, ], [t], ); + const refetchInventoryData = useCallback(async ( + query: Record, + actionType: "reset" | "search" | "paging" + ) => { + const params: SearchInventory = { + code: query?.itemCode ?? '', + name: query?.itemName ?? '', + type: query?.itemType.toLowerCase() === "all" ? '' : query?.itemType ?? '', + pageNum: inventoriesPagingController.pageNum - 1, + pageSize: inventoriesPagingController.pageSize + } + + const response = await fetchInventories(params) + + if (response) { + console.log(response) + setInventoriesTotalCount(response.total); + switch (actionType) { + case "reset": + case "search": + setFilteredInventories(() => response.records); + break; + case "paging": + setFilteredInventories((fi) => + orderBy( + uniqBy([...fi, ...response.records], "id"), + ["id"], ["desc"] + )); + } + } + }, [inventoriesPagingController, setInventoriesPagingController]) + + useEffect(() => { + refetchInventoryData(inputs, "paging") + }, [inventoriesPagingController]) + + const refetchInventoryLotLineData = useCallback(async ( + itemId: number | null, + actionType: "reset" | "search" | "paging" + ) => { + if (!itemId) { + setItem(null) + setInventoryLotLinesTotalCount(0); + setFilteredInventoryLotLines([]) + return + } + + const params: SearchInventoryLotLine = { + itemId: itemId, + pageNum: inventoriesPagingController.pageNum - 1, + pageSize: inventoriesPagingController.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"] + )); + } + } + }, [inventoriesPagingController, setInventoriesPagingController]) + + useEffect(() => { + refetchInventoryData(inputs, "paging") + }, [inventoriesPagingController]) + const onReset = useCallback(() => { - setFilteredInventories(inventories); - }, [inventories]); + refetchInventoryData(inputs, "reset"); + refetchInventoryLotLineData(null, "reset"); + // setFilteredInventories(inventories); + }, []); - const columns = useMemo[]>( - () => [ - { - name: "itemCode", - label: t("Code"), - }, - { - name: "itemName", - label: t("Name"), - }, - { - name: "itemType", - label: t("Type"), - renderCell: (params) => { - return t(params.itemType); - }, - }, - { - name: "availableQty", - label: t("Qty"), - align: "right", - headerAlign: "right", - type: "integer", - }, - { - name: "uomUdfudesc", - label: t("UoM"), - }, - // { - // name: "qtyPerSmallestUnit", - // label: t("Qty Per Smallest Unit"), - // align: "right", - // headerAlign: "right", - // type: "decimal" - // }, - // { - // name: "smallestUnit", - // label: t("Smallest Unit"), - // }, - // { - // name: "price", - // label: t("Price"), - // align: "right", - // sx: { - // alignItems: "right", - // justifyContent: "end", - // } - // }, - // { - // name: "currencyName", - // label: t("Currency"), - // }, - // { - // name: "status", - // label: t("Status"), - // type: "icon", - // icons: { - // available: , - // unavailable: , - // }, - // colors: { - // available: "success", - // unavailable: "error", - // } - // }, - ], - [t], - ); + const onInventoryRowClick = useCallback((item: InventoryResult) => { + setItem(item) + refetchInventoryLotLineData(item.itemId, "search") + }, []) return ( <> @@ -133,28 +174,35 @@ const InventorySearch: React.FC = ({ inventories }) => { onSearch={(query) => { // console.log(query) // console.log(inventories) - 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())), - ), - ); + setInputs(() => query) + refetchInventoryData(query, "search") + // 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())), + // ), + // ); }} onReset={onReset} /> - - items={filteredInventories} - columns={columns} - pagingController={{ - pageNum: 0, - pageSize: 0, - // totalCount: 0, - }} + + ); diff --git a/src/components/InventorySearch/InventoryTable.tsx b/src/components/InventorySearch/InventoryTable.tsx new file mode 100644 index 0000000..8bdc452 --- /dev/null +++ b/src/components/InventorySearch/InventoryTable.tsx @@ -0,0 +1,111 @@ +import { InventoryResult } from "@/app/api/inventory"; +import { Dispatch, SetStateAction, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Column } from "../SearchResults"; +import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults"; + +interface Props { + inventories: InventoryResult[]; + setPagingController: defaultSetPagingController; + pagingController: typeof defaultPagingController; + totalCount: number; + onRowClick: (item: InventoryResult) => void; +} + +const InventoryTable: React.FC = ({ inventories, pagingController, setPagingController, totalCount, onRowClick }) => { + const { t } = useTranslation(["inventory"]); + + const columns = useMemo[]>( + () => [ + { + name: "itemCode", + label: t("Code"), + }, + { + name: "itemName", + label: t("Name"), + }, + { + name: "itemType", + label: t("Type"), + renderCell: (params) => { + return t(params.itemType); + }, + }, + { + name: "availableQty", + label: t("Available Qty"), + align: "right", + headerAlign: "right", + type: "integer", + }, + { + name: "uomUdfudesc", + label: t("Sales UoM"), + align: "left", + headerAlign: "left", + }, + { + name: "qtyPerSmallestUnit", + label: t("Available Qty Per Smallest Unit"), + align: "right", + headerAlign: "right", + type: "integer", + }, + { + name: "baseUom", + label: t("Base UoM"), + align: "left", + headerAlign: "left", + }, + // { + // name: "qtyPerSmallestUnit", + // label: t("Qty Per Smallest Unit"), + // align: "right", + // headerAlign: "right", + // type: "decimal" + // }, + // { + // name: "smallestUnit", + // label: t("Smallest Unit"), + // }, + // { + // name: "price", + // label: t("Price"), + // align: "right", + // sx: { + // alignItems: "right", + // justifyContent: "end", + // } + // }, + // { + // name: "currencyName", + // label: t("Currency"), + // }, + // { + // name: "status", + // label: t("Status"), + // type: "icon", + // icons: { + // available: , + // unavailable: , + // }, + // colors: { + // available: "success", + // unavailable: "error", + // } + // }, + ], + [t], + ); + return + items={inventories} + columns={columns} + pagingController={pagingController} + setPagingController={setPagingController} + totalCount={totalCount} + onRowClick={onRowClick} + /> +} + +export default InventoryTable; \ No newline at end of file diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index 01c91b1..59c5d5d 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -248,7 +248,7 @@ function SearchBox({ {t("All")} {c.options.map((option) => ( - {option} + {t(option)} ))} diff --git a/src/components/SearchResults/SearchResults.tsx b/src/components/SearchResults/SearchResults.tsx index 1130e0b..c4b42fb 100644 --- a/src/components/SearchResults/SearchResults.tsx +++ b/src/components/SearchResults/SearchResults.tsx @@ -100,6 +100,7 @@ interface Props { isAutoPaging?: boolean; checkboxIds?: (string | number)[]; setCheckboxIds?: Dispatch>; + onRowClick?: (item: T) => void; } function isActionColumn( @@ -138,8 +139,8 @@ function convertObjectKeysToLowercase( ): object | undefined { return obj ? Object.fromEntries( - Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value]), - ) + Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value]), + ) : undefined; } @@ -174,6 +175,14 @@ export const defaultPagingController: { pageNum: number; pageSize: number } = { pageNum: 1, pageSize: 10, }; + +export type defaultSetPagingController = Dispatch< + SetStateAction<{ + pageNum: number; + pageSize: number; + }> +> + function SearchResults({ items, columns, @@ -184,6 +193,7 @@ function SearchResults({ totalCount, checkboxIds = [], setCheckboxIds = undefined, + onRowClick = undefined, }: Props) { const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); @@ -279,40 +289,25 @@ function SearchResults({ {isAutoPaging ? items - .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) - .map((item) => { - return ( - { + return ( + { setCheckboxIds - ? (event) => handleRowClick(event, item, columns) + ? handleRowClick(event, item, columns) : undefined + + if (onRowClick) { + onRowClick(item) + } } - role={setCheckboxIds ? "checkbox" : undefined} - > - {columns.map((column, idx) => { - const columnName = column.name; - - return ( - - ); - })} - - ); - }) - : items.map((item) => { - return ( - + } + role={setCheckboxIds ? "checkbox" : undefined} + > {columns.map((column, idx) => { const columnName = column.name; @@ -329,7 +324,27 @@ function SearchResults({ })} ); - })} + }) + : items.map((item) => { + return ( + + {columns.map((column, idx) => { + const columnName = column.name; + + return ( + + ); + })} + + ); + })} diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 087db9d..abe577a 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -35,6 +35,7 @@ "Project":"專案", "Product":"產品", "Material":"材料", + "mat":"原料", "FG":"成品", "FG & Material Demand Forecast Detail":"成品及材料需求預測詳情", "View item In-out And inventory Ledger":"查看物料出入庫及庫存日誌", diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index 1e5a6ce..3928aee 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -7,5 +7,13 @@ "Qty": "數量", "UoM": "單位", "mat": "物料", - "fg": "成品" -} \ No newline at end of file + "fg": "成品", + "Available Qty": "可用數量 (銷售單位)", + "Sales UoM": "銷售單位", + "Available Qty Per Smallest Unit": "可用數量 (基本單位)", + "Base UoM": "基本單位", + "Lot No": "批號", + "Expiry Date": "到期日", + "No items are selected yet.": "未選擇項目", + "Item selected": "已選擇項目" +}