| @@ -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<Props> = ({ inventoryLotLines, pagingController, setPagingController, totalCount, item }) => { | |||
| const { t } = useTranslation(["inventory"]); | |||
| const columns = useMemo<Column<InventoryLotLineResult>[]>( | |||
| () => [ | |||
| // { | |||
| // 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: <CheckCircleOutline fontSize="small"/>, | |||
| unavailable: <DoDisturb fontSize="small"/>, | |||
| }, | |||
| colors: { | |||
| available: "success", | |||
| unavailable: "error", | |||
| } | |||
| }, | |||
| ], | |||
| [t], | |||
| ); | |||
| return <> | |||
| <Typography variant="h6">{item ? `${t("Item selected")}: ${item.itemCode} | ${item.itemName} (${t(item.itemType)})` : t("No items are selected yet.")}</Typography> | |||
| <SearchResults<InventoryLotLineResult> | |||
| items={inventoryLotLines ?? []} | |||
| columns={columns} | |||
| pagingController={pagingController} | |||
| setPagingController={setPagingController} | |||
| totalCount={totalCount} | |||
| /> | |||
| </> | |||
| } | |||
| export default InventoryLotLineTable; | |||
| @@ -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<Props> = ({ inventories }) => { | |||
| const { t } = useTranslation(["inventory", "common"]); | |||
| const [filteredInventories, setFilteredInventories] = useState(inventories); | |||
| // Inventory | |||
| const [filteredInventories, setFilteredInventories] = useState<InventoryResult[]>([]); | |||
| const [inventoriesPagingController, setInventoriesPagingController] = useState(defaultPagingController) | |||
| const [inventoriesTotalCount, setInventoriesTotalCount] = useState(0) | |||
| const [item, setItem] = useState<InventoryResult | null>(null) | |||
| // Inventory Lot Line | |||
| const [filteredInventoryLotLines, setFilteredInventoryLotLines] = useState<InventoryLotLineResult[]>([]); | |||
| const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController) | |||
| const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0) | |||
| const [inputs, setInputs] = useState<Record<SearchParamNames, string>>({ | |||
| itemId: "", | |||
| itemCode: "", | |||
| itemName: "", | |||
| itemType: "", | |||
| onHandQty: "", | |||
| onHoldQty: "", | |||
| unavailableQty: "", | |||
| availableQty: "", | |||
| currencyName: "", | |||
| status: "", | |||
| baseUom: "", | |||
| }); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| @@ -43,88 +70,102 @@ const InventorySearch: React.FC<Props> = ({ 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<SearchParamNames, string>, | |||
| 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<Column<InventoryResult>[]>( | |||
| () => [ | |||
| { | |||
| 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: <CheckCircleOutline fontSize="small"/>, | |||
| // unavailable: <DoDisturb fontSize="small"/>, | |||
| // }, | |||
| // 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<Props> = ({ 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} | |||
| /> | |||
| <SearchResults<InventoryResult> | |||
| items={filteredInventories} | |||
| columns={columns} | |||
| pagingController={{ | |||
| pageNum: 0, | |||
| pageSize: 0, | |||
| // totalCount: 0, | |||
| }} | |||
| <InventoryTable | |||
| inventories={filteredInventories} | |||
| pagingController={inventoriesPagingController} | |||
| setPagingController={setInventoriesPagingController} | |||
| totalCount={inventoriesTotalCount} | |||
| onRowClick={onInventoryRowClick} | |||
| /> | |||
| <InventoryLotLineTable | |||
| inventoryLotLines={filteredInventoryLotLines} | |||
| pagingController={inventoryLotLinesPagingController} | |||
| setPagingController={setInventoryLotLinesPagingController} | |||
| totalCount={inventoryLotLinesTotalCount} | |||
| item={item} | |||
| /> | |||
| </> | |||
| ); | |||
| @@ -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<Props> = ({ inventories, pagingController, setPagingController, totalCount, onRowClick }) => { | |||
| const { t } = useTranslation(["inventory"]); | |||
| const columns = useMemo<Column<InventoryResult>[]>( | |||
| () => [ | |||
| { | |||
| 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: <CheckCircleOutline fontSize="small"/>, | |||
| // unavailable: <DoDisturb fontSize="small"/>, | |||
| // }, | |||
| // colors: { | |||
| // available: "success", | |||
| // unavailable: "error", | |||
| // } | |||
| // }, | |||
| ], | |||
| [t], | |||
| ); | |||
| return <SearchResults<InventoryResult> | |||
| items={inventories} | |||
| columns={columns} | |||
| pagingController={pagingController} | |||
| setPagingController={setPagingController} | |||
| totalCount={totalCount} | |||
| onRowClick={onRowClick} | |||
| /> | |||
| } | |||
| export default InventoryTable; | |||
| @@ -248,7 +248,7 @@ function SearchBox<T extends string>({ | |||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | |||
| {c.options.map((option) => ( | |||
| <MenuItem key={option} value={option}> | |||
| {option} | |||
| {t(option)} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| @@ -100,6 +100,7 @@ interface Props<T extends ResultWithId> { | |||
| isAutoPaging?: boolean; | |||
| checkboxIds?: (string | number)[]; | |||
| setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>; | |||
| onRowClick?: (item: T) => void; | |||
| } | |||
| function isActionColumn<T extends ResultWithId>( | |||
| @@ -138,8 +139,8 @@ function convertObjectKeysToLowercase<T extends object>( | |||
| ): 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<T extends ResultWithId>({ | |||
| items, | |||
| columns, | |||
| @@ -184,6 +193,7 @@ function SearchResults<T extends ResultWithId>({ | |||
| totalCount, | |||
| checkboxIds = [], | |||
| setCheckboxIds = undefined, | |||
| onRowClick = undefined, | |||
| }: Props<T>) { | |||
| const [page, setPage] = React.useState(0); | |||
| const [rowsPerPage, setRowsPerPage] = React.useState(10); | |||
| @@ -279,40 +289,25 @@ function SearchResults<T extends ResultWithId>({ | |||
| <TableBody> | |||
| {isAutoPaging | |||
| ? items | |||
| .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) | |||
| .map((item) => { | |||
| return ( | |||
| <TableRow | |||
| hover | |||
| tabIndex={-1} | |||
| key={item.id} | |||
| onClick={ | |||
| .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) | |||
| .map((item) => { | |||
| return ( | |||
| <TableRow | |||
| hover | |||
| tabIndex={-1} | |||
| key={item.id} | |||
| onClick={(event) => { | |||
| 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 ( | |||
| <TabelCells | |||
| key={`${columnName.toString()}-${idx}`} | |||
| column={column} | |||
| columnName={columnName} | |||
| idx={idx} | |||
| item={item} | |||
| checkboxIds={checkboxIds} | |||
| /> | |||
| ); | |||
| })} | |||
| </TableRow> | |||
| ); | |||
| }) | |||
| : items.map((item) => { | |||
| return ( | |||
| <TableRow hover tabIndex={-1} key={item.id}> | |||
| } | |||
| role={setCheckboxIds ? "checkbox" : undefined} | |||
| > | |||
| {columns.map((column, idx) => { | |||
| const columnName = column.name; | |||
| @@ -329,7 +324,27 @@ function SearchResults<T extends ResultWithId>({ | |||
| })} | |||
| </TableRow> | |||
| ); | |||
| })} | |||
| }) | |||
| : items.map((item) => { | |||
| return ( | |||
| <TableRow hover tabIndex={-1} key={item.id}> | |||
| {columns.map((column, idx) => { | |||
| const columnName = column.name; | |||
| return ( | |||
| <TabelCells | |||
| key={`${columnName.toString()}-${idx}`} | |||
| column={column} | |||
| columnName={columnName} | |||
| idx={idx} | |||
| item={item} | |||
| checkboxIds={checkboxIds} | |||
| /> | |||
| ); | |||
| })} | |||
| </TableRow> | |||
| ); | |||
| })} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| @@ -35,6 +35,7 @@ | |||
| "Project":"專案", | |||
| "Product":"產品", | |||
| "Material":"材料", | |||
| "mat":"原料", | |||
| "FG":"成品", | |||
| "FG & Material Demand Forecast Detail":"成品及材料需求預測詳情", | |||
| "View item In-out And inventory Ledger":"查看物料出入庫及庫存日誌", | |||
| @@ -7,5 +7,13 @@ | |||
| "Qty": "數量", | |||
| "UoM": "單位", | |||
| "mat": "物料", | |||
| "fg": "成品" | |||
| } | |||
| "fg": "成品", | |||
| "Available Qty": "可用數量 (銷售單位)", | |||
| "Sales UoM": "銷售單位", | |||
| "Available Qty Per Smallest Unit": "可用數量 (基本單位)", | |||
| "Base UoM": "基本單位", | |||
| "Lot No": "批號", | |||
| "Expiry Date": "到期日", | |||
| "No items are selected yet.": "未選擇項目", | |||
| "Item selected": "已選擇項目" | |||
| } | |||