From 144533e804a06abc20017f5c21022115321c84d3 Mon Sep 17 00:00:00 2001 From: "cyril.tsui" Date: Tue, 3 Jun 2025 16:29:05 +0800 Subject: [PATCH] Add Inventory --- src/app/(main)/inventory/page.tsx | 34 ++++ src/app/(main)/scheduling/detail/page.tsx | 21 +- src/app/api/inventory/actions.ts | 0 src/app/api/inventory/index.ts | 30 +++ src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../DetailScheduleDetail/ViewByFGDetails.tsx | 16 +- .../InventorySearch/InventorySearch.tsx | 139 ++++++++++++++ .../InventorySearchWrapper.tsx | 24 +++ src/components/InventorySearch/index.ts | 1 + .../NavigationContent/NavigationContent.tsx | 4 +- src/components/SearchBox/SearchBox.tsx | 6 +- .../SearchResults/SearchResults.tsx | 179 ++++++++++++++---- 12 files changed, 392 insertions(+), 63 deletions(-) create mode 100644 src/app/(main)/inventory/page.tsx create mode 100644 src/app/api/inventory/actions.ts create mode 100644 src/app/api/inventory/index.ts create mode 100644 src/components/InventorySearch/InventorySearch.tsx create mode 100644 src/components/InventorySearch/InventorySearchWrapper.tsx create mode 100644 src/components/InventorySearch/index.ts diff --git a/src/app/(main)/inventory/page.tsx b/src/app/(main)/inventory/page.tsx new file mode 100644 index 0000000..5016aba --- /dev/null +++ b/src/app/(main)/inventory/page.tsx @@ -0,0 +1,34 @@ +import { preloadInventory } from "@/app/api/inventory"; +import InventorySearch from "@/components/InventorySearch"; +import { getServerI18n } from "@/i18n"; +import { Stack, Typography } from "@mui/material"; +import { Metadata } from "next"; +import { Suspense } from "react"; + +export const metadata: Metadata = { + title: "Inventory" +} + +const Inventory: React.FC = async () => { + const { t } = await getServerI18n("inventory") + + preloadInventory() + + return <> + + + {t("Inventory")} + + + }> + + + ; +} + +export default Inventory; \ No newline at end of file diff --git a/src/app/(main)/scheduling/detail/page.tsx b/src/app/(main)/scheduling/detail/page.tsx index d6eced6..3116e8a 100644 --- a/src/app/(main)/scheduling/detail/page.tsx +++ b/src/app/(main)/scheduling/detail/page.tsx @@ -1,19 +1,16 @@ import { TypeEnum } from "@/app/utils/typeEnum"; -import ItemsSearch from "@/components/ItemsSearch"; +import DetailSchedule from "@/components/DetailSchedule"; import { getServerI18n } from "@/i18n"; -import Add from "@mui/icons-material/Add"; -import Button from "@mui/material/Button"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { Metadata } from "next"; -import Link from "next/link"; import { Suspense } from "react"; export const metadata: Metadata = { title: "Detail Scheduling", }; -const detailScheduling: React.FC = async () => { +const DetailScheduling: React.FC = async () => { const project = TypeEnum.PRODUCT const { t } = await getServerI18n(project); // preloadClaims(); @@ -29,20 +26,12 @@ const detailScheduling: React.FC = async () => { {t("Detail Scheduling")} - {/* */} - }> - + }> + ); }; -export default detailScheduling; +export default DetailScheduling; diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/inventory/index.ts b/src/app/api/inventory/index.ts new file mode 100644 index 0000000..5425b24 --- /dev/null +++ b/src/app/api/inventory/index.ts @@ -0,0 +1,30 @@ +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { cache } from "react"; +import "server-only"; + +export interface InventoryResult { + id: number; + code: string; + name: string; + type: string; + qty: number; + uomCode: string; + uomUdfudesc: string; + germPerSmallestUnit: number; + qtyPerSmallestUnit: number; + smallestUnit: string; + price: number; + currencyName: string; + status: string; +} + +export const preloadInventory = () => { + fetchInventories(); +} + +export const fetchInventories = cache(async() => { + return serverFetchJson(`${BASE_API_URL}/inventory/list`, { + next: { tags: ["inventories"]} + }) +}) \ No newline at end of file diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 24c3688..65f399c 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -18,6 +18,7 @@ const pathToLabelMap: { [path: string]: string } = { "/scheduling/rough/edit": "FG & Material Demand Forecast Detail", "/scheduling/detail": "Detail Scheduling", "/scheduling/detail/edit": "FG Production Schedule", + "/inventory": "Inventory", }; const Breadcrumb = () => { diff --git a/src/components/DetailScheduleDetail/ViewByFGDetails.tsx b/src/components/DetailScheduleDetail/ViewByFGDetails.tsx index f706291..e40cbd2 100644 --- a/src/components/DetailScheduleDetail/ViewByFGDetails.tsx +++ b/src/components/DetailScheduleDetail/ViewByFGDetails.tsx @@ -101,14 +101,14 @@ const ViewByFGDetails: React.FC = ({ apiRef, isEdit }) => { () => [ [ { - id: 1, jobNo: "JO20250507001", priority: 85, code: "PP1193", type: "FG", name: "蔥油(1磅) ", inStockQty: 1322, productionQty: 661, + id: 1, jobNo: "JO20250507001", estimatedProductionTime: "1 hr", priority: 85, code: "PP1193", type: "FG", name: "蔥油(1磅) ", inStockQty: 1322, productionQty: 661, lines: [ { id: 1, code: "MH0040", type: "Material", name: "大豆油(1噸/桶)", inStockQty: 100, purchaseQty: 20 }, { id: 2, code: "FA0161", type: "Material", name: "洋蔥粒", inStockQty: 80, purchaseQty: 10 } ] }, { - id: 2, jobNo: "JO20250507002", priority: 80, code: " PP1096", type: "FG", name: "白麵撈", inStockQty: 1040, productionQty: 520, + id: 2, jobNo: "JO20250507002", estimatedProductionTime: "2 hrs", priority: 80, code: " PP1096", type: "FG", name: "白麵撈", inStockQty: 1040, productionQty: 520, lines: [ { id: 1, code: "MH0040", type: "Material", name: "大豆油(1噸/桶)", inStockQty: 1000, purchaseQty: 190.00 }, { id: 1, code: "MH0040", type: "Material", name: "星加坡綠富貴花牌幼白麵粉 (50磅/包)", inStockQty: 1000, purchaseQty: 250.00 }, @@ -116,7 +116,7 @@ const ViewByFGDetails: React.FC = ({ apiRef, isEdit }) => { ] }, { - id: 3, jobNo: "JO20250507003", priority: 35, code: "PP1080", type: "FG", name: "咖哩汁", inStockQty: 2400, productionQty: 1200.0, + id: 3, jobNo: "JO20250507003", estimatedProductionTime: "5 hrs : 15 mins", priority: 35, code: "PP1080", type: "FG", name: "咖哩汁", inStockQty: 2400, productionQty: 1200.0, lines: [ { id: 1, code: "MH0040", type: "Material", name: "大豆油(1噸/桶)", inStockQty: 0, purchaseQty: 108.88 }, { id: 2, code: "GI3236", type: "Material", name: "清水(煮過牛腩)", inStockQty: 317.52, purchaseQty: 635.04 }, @@ -132,7 +132,7 @@ const ViewByFGDetails: React.FC = ({ apiRef, isEdit }) => { ] }, { - id: 4, jobNo: "JO20250507004", priority: 20, code: " PP1188", type: "FG", name: "咖喱膽", inStockQty: 1016.2, productionQty: 508.1, + id: 4, jobNo: "JO20250507004", estimatedProductionTime: "3 hrs", priority: 20, code: " PP1188", type: "FG", name: "咖喱膽", inStockQty: 1016.2, productionQty: 508.1, lines: [ { id: 1, code: "MH0040", type: "Material", name: "大豆油(1噸/桶)", inStockQty: 0, purchaseQty: 217.72 }, { id: 2, code: "FA0161", type: "Material", name: "洋蔥粒", inStockQty: 0, purchaseQty: 18.15 }, @@ -525,6 +525,14 @@ const ViewByFGDetails: React.FC = ({ apiRef, isEdit }) => { return row.productionQty } }, + { + field: "estimatedProductionTime", + label: "Estimated Production Time", + type: "read-only", + style: { + textAlign: "right", + } + }, { field: "priority", label: "Production Priority", diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx new file mode 100644 index 0000000..e0f3fec --- /dev/null +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -0,0 +1,139 @@ +"use client" +import { 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 SearchResults, { Column } from "../SearchResults"; +import { CheckCircleOutline, DoDisturb } from "@mui/icons-material"; + +interface Props { + inventories: InventoryResult[]; +} + +type SearchQuery = Partial>; +type SearchParamNames = keyof SearchQuery; + +const InventorySearch: React.FC = ({ + inventories, +}) => { + const { t } = useTranslation("inventories"); + + const [filteredInventories, setFilteredInventories] = useState(inventories) + + const searchCriteria: Criterion[] = useMemo(() => [ + { label: t("Code"), paramName: "code", type: "text" }, + { label: t("Name"), paramName: "name", type: "text" }, + { label: t("Type"), paramName: "type", type: "select", options: uniq(inventories.map(i => i.type)) }, + { label: t("Status"), paramName: "status", type: "select", options: uniq(inventories.map(i => i.status)) }, + ], [t] + ); + + const onReset = useCallback(() => { + setFilteredInventories(inventories) + }, [inventories]) + + const columns = useMemo[]>( + () => [ + { + name: "code", + label: t("Code"), + }, + { + name: "name", + label: t("Name"), + }, + { + name: "type", + label: t("Type"), + }, + { + name: "qty", + 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] + ) + + return ( + <> + { + console.log(query) + console.log(inventories) + setFilteredInventories( + inventories.filter( + (i) => + i.code.toLowerCase().includes(query.code.toLowerCase()) && + i.name.toLowerCase().includes(query.name.toLowerCase()) && + (query.type == "All" || i.type.toLowerCase().includes(query.type.toLowerCase())) && + (query.status == "All" || i.status.toLowerCase().includes(query.status.toLowerCase())) + ) + ) + }} + onReset={onReset} + /> + items={filteredInventories} columns={columns} pagingController={{ + pageNum: 0, + pageSize: 0, + totalCount: 0, + }} /> + + ) +} + +export default InventorySearch \ No newline at end of file diff --git a/src/components/InventorySearch/InventorySearchWrapper.tsx b/src/components/InventorySearch/InventorySearchWrapper.tsx new file mode 100644 index 0000000..f207c7f --- /dev/null +++ b/src/components/InventorySearch/InventorySearchWrapper.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import GeneralLoading from "../General/GeneralLoading" +import { fetchInventories } from "@/app/api/inventory"; +import InventorySearch from "./InventorySearch"; + +interface SubComponents { + Loading: typeof GeneralLoading; +} + +const InventorySearchWrapper: React.FC & SubComponents = async () => { + + const [ + inventories + ] = await Promise.all([ + fetchInventories() + ]) + + return +} + + +InventorySearchWrapper.Loading = GeneralLoading; + +export default InventorySearchWrapper \ No newline at end of file diff --git a/src/components/InventorySearch/index.ts b/src/components/InventorySearch/index.ts new file mode 100644 index 0000000..3d6573b --- /dev/null +++ b/src/components/InventorySearch/index.ts @@ -0,0 +1 @@ +export { default } from "./InventorySearchWrapper" \ No newline at end of file diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index c91fab8..71100c7 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -76,8 +76,8 @@ const NavigationContent: React.FC = () => { }, { icon: , - label: "View item In-out And invertory Ledger", - path: "", + label: "View item In-out And inventory Ledger", + path: "/inventory", }, ], }, diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index 560c6f9..bbd7c41 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -28,7 +28,7 @@ interface BaseCriterion { label2?: string; paramName: T; paramName2?: T; - options?: T[]; + options?: T[] | string[]; filterObj?: T; handleSelectionChange?: (selectedOptions: T[]) => void; } @@ -39,11 +39,11 @@ interface TextCriterion extends BaseCriterion { interface SelectCriterion extends BaseCriterion { type: "select"; - options: T[]; + options: string[]; } interface MultiSelectCriterion extends BaseCriterion { - type: "select"; + type: "multi-select"; options: T[]; selectedOptions: T[]; handleSelectionChange: (selectedOptions: T[]) => void; diff --git a/src/components/SearchResults/SearchResults.tsx b/src/components/SearchResults/SearchResults.tsx index 75cdc78..6a5a863 100644 --- a/src/components/SearchResults/SearchResults.tsx +++ b/src/components/SearchResults/SearchResults.tsx @@ -4,32 +4,62 @@ import React from "react"; import Paper from "@mui/material/Paper"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; -import TableCell from "@mui/material/TableCell"; +import TableCell, { TableCellProps } from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TablePagination, { TablePaginationProps, } from "@mui/material/TablePagination"; import TableRow from "@mui/material/TableRow"; -import IconButton, {IconButtonOwnProps} from "@mui/material/IconButton"; +import IconButton, { IconButtonOwnProps } from "@mui/material/IconButton"; +import { ButtonOwnProps, Icon, IconOwnProps, SxProps, Theme } from "@mui/material"; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; export interface ResultWithId { id: string | number; } +type ColumnType = "icon" | "decimal" | "integer"; + interface BaseColumn { name: keyof T; label: string; + align?: TableCellProps["align"]; + headerAlign?: TableCellProps["align"]; + sx?: SxProps | undefined; + style?: Partial & { [propName: string]: string }; + type?: ColumnType; +} + +interface IconColumn extends BaseColumn { + name: keyof T; + type: "icon"; + icon?: React.ReactNode; + icons?: { [columnValue in keyof T]: React.ReactNode }; + color?: IconOwnProps["color"]; + colors?: { [columnValue in keyof T]: IconOwnProps["color"] }; +} + +interface DecimalColumn extends BaseColumn { + type: "decimal"; +} + +interface IntegerColumn extends BaseColumn { + type: "integer"; } interface ColumnWithAction extends BaseColumn { onClick: (item: T) => void; buttonIcon: React.ReactNode; + buttonIcons: { [columnValue in keyof T]: React.ReactNode }; buttonColor?: IconButtonOwnProps["color"]; } export type Column = | BaseColumn + | IconColumn + | DecimalColumn | ColumnWithAction; interface Props { @@ -42,7 +72,7 @@ interface Props { totalCount: number }) | { pageNum: number; pageSize: number; totalCount: number })) => void, pagingController: { pageNum: number; pageSize: number; totalCount: number }, - isAutoPaging: boolean + isAutoPaging?: boolean } function isActionColumn( @@ -51,14 +81,67 @@ function isActionColumn( return Boolean((column as ColumnWithAction).onClick); } +function isIconColumn( + column: Column, +): column is IconColumn { + return column.type === "icon"; +} + +function isDecimalColumn( + column: Column, +): column is DecimalColumn { + return column.type === "decimal"; +} + +function isIntegerColumn( + column: Column, +): column is IntegerColumn { + return column.type === "integer"; +} + +// Icon Component Functions +function convertObjectKeysToLowercase(obj: T): object | undefined { + return obj ? Object.fromEntries( + Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value]) + ) : undefined; +} + +function handleIconColors( + column: IconColumn, + value: T[keyof T], +): IconOwnProps["color"] { + const colors = convertObjectKeysToLowercase(column.colors ?? {}); + const valueKey = String(value).toLowerCase() as keyof typeof colors; + + if (colors && valueKey in colors) { + return colors[valueKey]; + } + + return column.color ?? "primary"; +}; + +function handleIconIcons( + column: IconColumn, + value: T[keyof T], +): React.ReactNode { + const icons = convertObjectKeysToLowercase(column.icons ?? {}); + const valueKey = String(value).toLowerCase() as keyof typeof icons; + + if (icons && valueKey in icons) { + return icons[valueKey]; + } + + return column.icon ?? ; +}; + function SearchResults({ - items, - columns, - noWrapper, - pagingController, - setPagingController, - isAutoPaging = true, - }: Props) { + items, + columns, + noWrapper, + pagingController, + setPagingController, + isAutoPaging = true, +}: Props) { const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); @@ -90,12 +173,12 @@ function SearchResults({ const table = ( <> - + {columns.map((column, idx) => ( - + {column.label} ))} @@ -113,18 +196,7 @@ function SearchResults({ const columnName = column.name; return ( - - {isActionColumn(column) ? ( - column.onClick(item)} - > - {column.buttonIcon} - - ) : ( - <>{item[columnName] as string} - )} - + ); })} @@ -139,19 +211,8 @@ function SearchResults({ const columnName = column.name; return ( - - {isActionColumn(column) ? ( - column.onClick(item)} - > - {column.buttonIcon} - - ) : ( - <>{item[columnName] as string} - )} - - ); + + ); })} ); @@ -172,7 +233,49 @@ function SearchResults({ ); - return noWrapper ? table : {table}; + return noWrapper ? table : {table}; +} + +// Table cells +interface TableCellsProps { + column: Column, + columnName: keyof T, + idx: number, + item: T, +} + +function TabelCells({ + column, + columnName, + idx, + item +}: TableCellsProps) { + return ( + + {isActionColumn(column) ? ( + column.onClick(item)} + > + {column.buttonIcon} + + ) : + isIconColumn(column) ? ( + + {handleIconIcons(column, item[columnName])} + + ) : + isDecimalColumn(column) ? ( + <>{decimalFormatter.format(Number(item[columnName]))} + ) : + isIntegerColumn(column) ? ( + <>{integerFormatter.format(Number(item[columnName]))} + ) : ( + <>{item[columnName] as string} + )} + ) } export default SearchResults;