@@ -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 <> | |||
<Stack | |||
direction="row" | |||
justifyContent={"space-between"} | |||
flexWrap={"wrap"} | |||
rowGap={2} | |||
> | |||
<Typography variant="h4" marginInlineEnd={2}> | |||
{t("Inventory")} | |||
</Typography> | |||
</Stack> | |||
<Suspense fallback={<InventorySearch.Loading />}> | |||
<InventorySearch /> | |||
</Suspense> | |||
</>; | |||
} | |||
export default Inventory; |
@@ -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 () => { | |||
<Typography variant="h4" marginInlineEnd={2}> | |||
{t("Detail Scheduling")} | |||
</Typography> | |||
{/* <Button | |||
variant="contained" | |||
startIcon={<Add />} | |||
LinkComponent={Link} | |||
href="product/create" | |||
> | |||
{t("Create product")} | |||
</Button> */} | |||
</Stack> | |||
<Suspense fallback={<ItemsSearch.Loading />}> | |||
<ItemsSearch /> | |||
<Suspense fallback={<DetailSchedule.Loading />}> | |||
<DetailSchedule /> | |||
</Suspense> | |||
</> | |||
); | |||
}; | |||
export default detailScheduling; | |||
export default DetailScheduling; |
@@ -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<InventoryResult[]>(`${BASE_API_URL}/inventory/list`, { | |||
next: { tags: ["inventories"]} | |||
}) | |||
}) |
@@ -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 = () => { | |||
@@ -101,14 +101,14 @@ const ViewByFGDetails: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ apiRef, isEdit }) => { | |||
return row.productionQty | |||
} | |||
}, | |||
{ | |||
field: "estimatedProductionTime", | |||
label: "Estimated Production Time", | |||
type: "read-only", | |||
style: { | |||
textAlign: "right", | |||
} | |||
}, | |||
{ | |||
field: "priority", | |||
label: "Production Priority", | |||
@@ -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<Omit<InventoryResult, | |||
| "id" | |||
| "qty" | |||
| "uomCode" | |||
| "uomUdfudesc" | |||
| "germPerSmallestUnit" | |||
| "qtyPerSmallestUnit" | |||
| "itemSmallestUnit" | |||
| "price" | |||
| "description" | |||
| "category">>; | |||
type SearchParamNames = keyof SearchQuery; | |||
const InventorySearch: React.FC<Props> = ({ | |||
inventories, | |||
}) => { | |||
const { t } = useTranslation("inventories"); | |||
const [filteredInventories, setFilteredInventories] = useState(inventories) | |||
const searchCriteria: Criterion<SearchParamNames>[] = 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<Column<InventoryResult>[]>( | |||
() => [ | |||
{ | |||
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: <CheckCircleOutline fontSize="small"/>, | |||
// unavailable: <DoDisturb fontSize="small"/>, | |||
// }, | |||
// colors: { | |||
// available: "success", | |||
// unavailable: "error", | |||
// } | |||
// }, | |||
], [t] | |||
) | |||
return ( | |||
<> | |||
<SearchBox | |||
criteria={searchCriteria} | |||
onSearch={(query) => { | |||
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} | |||
/> | |||
<SearchResults<InventoryResult> items={filteredInventories} columns={columns} pagingController={{ | |||
pageNum: 0, | |||
pageSize: 0, | |||
totalCount: 0, | |||
}} /> | |||
</> | |||
) | |||
} | |||
export default InventorySearch |
@@ -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 <InventorySearch inventories={inventories}/> | |||
} | |||
InventorySearchWrapper.Loading = GeneralLoading; | |||
export default InventorySearchWrapper |
@@ -0,0 +1 @@ | |||
export { default } from "./InventorySearchWrapper" |
@@ -76,8 +76,8 @@ const NavigationContent: React.FC = () => { | |||
}, | |||
{ | |||
icon: <RequestQuote />, | |||
label: "View item In-out And invertory Ledger", | |||
path: "", | |||
label: "View item In-out And inventory Ledger", | |||
path: "/inventory", | |||
}, | |||
], | |||
}, | |||
@@ -28,7 +28,7 @@ interface BaseCriterion<T extends string> { | |||
label2?: string; | |||
paramName: T; | |||
paramName2?: T; | |||
options?: T[]; | |||
options?: T[] | string[]; | |||
filterObj?: T; | |||
handleSelectionChange?: (selectedOptions: T[]) => void; | |||
} | |||
@@ -39,11 +39,11 @@ interface TextCriterion<T extends string> extends BaseCriterion<T> { | |||
interface SelectCriterion<T extends string> extends BaseCriterion<T> { | |||
type: "select"; | |||
options: T[]; | |||
options: string[]; | |||
} | |||
interface MultiSelectCriterion<T extends string> extends BaseCriterion<T> { | |||
type: "select"; | |||
type: "multi-select"; | |||
options: T[]; | |||
selectedOptions: T[]; | |||
handleSelectionChange: (selectedOptions: T[]) => void; | |||
@@ -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<T extends ResultWithId> { | |||
name: keyof T; | |||
label: string; | |||
align?: TableCellProps["align"]; | |||
headerAlign?: TableCellProps["align"]; | |||
sx?: SxProps<Theme> | undefined; | |||
style?: Partial<HTMLElement["style"]> & { [propName: string]: string }; | |||
type?: ColumnType; | |||
} | |||
interface IconColumn<T extends ResultWithId> extends BaseColumn<T> { | |||
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<T extends ResultWithId> extends BaseColumn<T> { | |||
type: "decimal"; | |||
} | |||
interface IntegerColumn<T extends ResultWithId> extends BaseColumn<T> { | |||
type: "integer"; | |||
} | |||
interface ColumnWithAction<T extends ResultWithId> extends BaseColumn<T> { | |||
onClick: (item: T) => void; | |||
buttonIcon: React.ReactNode; | |||
buttonIcons: { [columnValue in keyof T]: React.ReactNode }; | |||
buttonColor?: IconButtonOwnProps["color"]; | |||
} | |||
export type Column<T extends ResultWithId> = | |||
| BaseColumn<T> | |||
| IconColumn<T> | |||
| DecimalColumn<T> | |||
| ColumnWithAction<T>; | |||
interface Props<T extends ResultWithId> { | |||
@@ -42,7 +72,7 @@ interface Props<T extends ResultWithId> { | |||
totalCount: number | |||
}) | { pageNum: number; pageSize: number; totalCount: number })) => void, | |||
pagingController: { pageNum: number; pageSize: number; totalCount: number }, | |||
isAutoPaging: boolean | |||
isAutoPaging?: boolean | |||
} | |||
function isActionColumn<T extends ResultWithId>( | |||
@@ -51,14 +81,67 @@ function isActionColumn<T extends ResultWithId>( | |||
return Boolean((column as ColumnWithAction<T>).onClick); | |||
} | |||
function isIconColumn<T extends ResultWithId>( | |||
column: Column<T>, | |||
): column is IconColumn<T> { | |||
return column.type === "icon"; | |||
} | |||
function isDecimalColumn<T extends ResultWithId>( | |||
column: Column<T>, | |||
): column is DecimalColumn<T> { | |||
return column.type === "decimal"; | |||
} | |||
function isIntegerColumn<T extends ResultWithId>( | |||
column: Column<T>, | |||
): column is IntegerColumn<T> { | |||
return column.type === "integer"; | |||
} | |||
// Icon Component Functions | |||
function convertObjectKeysToLowercase<T extends object>(obj: T): object | undefined { | |||
return obj ? Object.fromEntries( | |||
Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value]) | |||
) : undefined; | |||
} | |||
function handleIconColors<T extends ResultWithId>( | |||
column: IconColumn<T>, | |||
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<T extends ResultWithId>( | |||
column: IconColumn<T>, | |||
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 ?? <CheckCircleOutlineIcon fontSize="small" />; | |||
}; | |||
function SearchResults<T extends ResultWithId>({ | |||
items, | |||
columns, | |||
noWrapper, | |||
pagingController, | |||
setPagingController, | |||
isAutoPaging = true, | |||
}: Props<T>) { | |||
items, | |||
columns, | |||
noWrapper, | |||
pagingController, | |||
setPagingController, | |||
isAutoPaging = true, | |||
}: Props<T>) { | |||
const [page, setPage] = React.useState(0); | |||
const [rowsPerPage, setRowsPerPage] = React.useState(10); | |||
@@ -90,12 +173,12 @@ function SearchResults<T extends ResultWithId>({ | |||
const table = ( | |||
<> | |||
<TableContainer sx={{maxHeight: 440}}> | |||
<TableContainer sx={{ maxHeight: 440 }}> | |||
<Table stickyHeader> | |||
<TableHead> | |||
<TableRow> | |||
{columns.map((column, idx) => ( | |||
<TableCell key={`${column.name.toString()}${idx}`}> | |||
<TableCell align={column.headerAlign} sx={column.sx} key={`${column.name.toString()}${idx}`}> | |||
{column.label} | |||
</TableCell> | |||
))} | |||
@@ -113,18 +196,7 @@ function SearchResults<T extends ResultWithId>({ | |||
const columnName = column.name; | |||
return ( | |||
<TableCell key={`${columnName.toString()}-${idx}`}> | |||
{isActionColumn(column) ? ( | |||
<IconButton | |||
color={column.buttonColor ?? "primary"} | |||
onClick={() => column.onClick(item)} | |||
> | |||
{column.buttonIcon} | |||
</IconButton> | |||
) : ( | |||
<>{item[columnName] as string}</> | |||
)} | |||
</TableCell> | |||
<TabelCells key={`${columnName.toString()}-${idx}`} column={column} columnName={columnName} idx={idx} item={item}/> | |||
); | |||
})} | |||
</TableRow> | |||
@@ -139,19 +211,8 @@ function SearchResults<T extends ResultWithId>({ | |||
const columnName = column.name; | |||
return ( | |||
<TableCell key={`${columnName.toString()}-${idx}`}> | |||
{isActionColumn(column) ? ( | |||
<IconButton | |||
color={column.buttonColor ?? "primary"} | |||
onClick={() => column.onClick(item)} | |||
> | |||
{column.buttonIcon} | |||
</IconButton> | |||
) : ( | |||
<>{item[columnName] as string}</> | |||
)} | |||
</TableCell> | |||
); | |||
<TabelCells key={`${columnName.toString()}-${idx}`} column={column} columnName={columnName} idx={idx} item={item}/> | |||
); | |||
})} | |||
</TableRow> | |||
); | |||
@@ -172,7 +233,49 @@ function SearchResults<T extends ResultWithId>({ | |||
</> | |||
); | |||
return noWrapper ? table : <Paper sx={{overflow: "hidden"}}>{table}</Paper>; | |||
return noWrapper ? table : <Paper sx={{ overflow: "hidden" }}>{table}</Paper>; | |||
} | |||
// Table cells | |||
interface TableCellsProps<T extends ResultWithId> { | |||
column: Column<T>, | |||
columnName: keyof T, | |||
idx: number, | |||
item: T, | |||
} | |||
function TabelCells<T extends ResultWithId>({ | |||
column, | |||
columnName, | |||
idx, | |||
item | |||
}: TableCellsProps<T>) { | |||
return ( | |||
<TableCell align={column.align} sx={column.sx} key={`${columnName.toString()}-${idx}`}> | |||
{isActionColumn(column) ? ( | |||
<IconButton | |||
color={column.buttonColor ?? "primary"} | |||
onClick={() => column.onClick(item)} | |||
> | |||
{column.buttonIcon} | |||
</IconButton> | |||
) : | |||
isIconColumn(column) ? ( | |||
<Icon | |||
color={handleIconColors(column, item[columnName])} | |||
> | |||
{handleIconIcons(column, item[columnName])} | |||
</Icon> | |||
) : | |||
isDecimalColumn(column) ? ( | |||
<>{decimalFormatter.format(Number(item[columnName]))}</> | |||
) : | |||
isIntegerColumn(column) ? ( | |||
<>{integerFormatter.format(Number(item[columnName]))}</> | |||
) : ( | |||
<>{item[columnName] as string}</> | |||
)} | |||
</TableCell>) | |||
} | |||
export default SearchResults; |