瀏覽代碼

Add Inventory

create_edit_user
cyril.tsui 2 月之前
父節點
當前提交
144533e804
共有 12 個文件被更改,包括 392 次插入63 次删除
  1. +34
    -0
      src/app/(main)/inventory/page.tsx
  2. +5
    -16
      src/app/(main)/scheduling/detail/page.tsx
  3. +0
    -0
      src/app/api/inventory/actions.ts
  4. +30
    -0
      src/app/api/inventory/index.ts
  5. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  6. +12
    -4
      src/components/DetailScheduleDetail/ViewByFGDetails.tsx
  7. +139
    -0
      src/components/InventorySearch/InventorySearch.tsx
  8. +24
    -0
      src/components/InventorySearch/InventorySearchWrapper.tsx
  9. +1
    -0
      src/components/InventorySearch/index.ts
  10. +2
    -2
      src/components/NavigationContent/NavigationContent.tsx
  11. +3
    -3
      src/components/SearchBox/SearchBox.tsx
  12. +141
    -38
      src/components/SearchResults/SearchResults.tsx

+ 34
- 0
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 <>
<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;

+ 5
- 16
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 () => {
<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
src/app/api/inventory/actions.ts 查看文件


+ 30
- 0
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<InventoryResult[]>(`${BASE_API_URL}/inventory/list`, {
next: { tags: ["inventories"]}
})
})

+ 1
- 0
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 = () => {


+ 12
- 4
src/components/DetailScheduleDetail/ViewByFGDetails.tsx 查看文件

@@ -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",


+ 139
- 0
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<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

+ 24
- 0
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 <InventorySearch inventories={inventories}/>
}


InventorySearchWrapper.Loading = GeneralLoading;

export default InventorySearchWrapper

+ 1
- 0
src/components/InventorySearch/index.ts 查看文件

@@ -0,0 +1 @@
export { default } from "./InventorySearchWrapper"

+ 2
- 2
src/components/NavigationContent/NavigationContent.tsx 查看文件

@@ -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",
},
],
},


+ 3
- 3
src/components/SearchBox/SearchBox.tsx 查看文件

@@ -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;


+ 141
- 38
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<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;

Loading…
取消
儲存