diff --git a/src/app/(main)/settings/itemPrice/page.tsx b/src/app/(main)/settings/itemPrice/page.tsx
new file mode 100644
index 0000000..d6d3cbc
--- /dev/null
+++ b/src/app/(main)/settings/itemPrice/page.tsx
@@ -0,0 +1,27 @@
+import { Metadata } from "next";
+import { Suspense } from "react";
+import { I18nProvider, getServerI18n } from "@/i18n";
+import ItemPriceSearch from "@/components/ItemPriceSearch/ItemPriceSearch";
+import PageTitleBar from "@/components/PageTitleBar";
+
+export const metadata: Metadata = {
+ title: "Price Inquiry",
+};
+
+const ItemPriceSetting: React.FC = async () => {
+ const { t } = await getServerI18n("inventory", "common");
+
+ return (
+ <>
+
+
+
+ }>
+
+
+
+ >
+ );
+};
+
+export default ItemPriceSetting;
\ No newline at end of file
diff --git a/src/app/api/inventory/index.ts b/src/app/api/inventory/index.ts
index f29d0af..869bed2 100644
--- a/src/app/api/inventory/index.ts
+++ b/src/app/api/inventory/index.ts
@@ -24,6 +24,8 @@ export interface InventoryResult {
price: number;
currencyName: string;
status: string;
+ latestMarketUnitPrice?: number;
+ latestMupUpdatedDate?: string;
}
export interface InventoryLotLineResult {
diff --git a/src/app/api/settings/item/index.ts b/src/app/api/settings/item/index.ts
index cdb7cce..e85933e 100644
--- a/src/app/api/settings/item/index.ts
+++ b/src/app/api/settings/item/index.ts
@@ -62,6 +62,10 @@ export type ItemsResult = {
isEgg?: boolean | undefined;
isFee?: boolean | undefined;
isBag?: boolean | undefined;
+ averageUnitPrice?: number | string;
+ latestMarketUnitPrice?: number;
+ latestMupUpdatedDate?: string;
+ purchaseUnit?: string;
};
export type Result = {
diff --git a/src/app/utils/formatUtil.ts b/src/app/utils/formatUtil.ts
index 60ecdc6..d3ca843 100644
--- a/src/app/utils/formatUtil.ts
+++ b/src/app/utils/formatUtil.ts
@@ -26,12 +26,21 @@ export const decimalFormatter = new Intl.NumberFormat("en-HK", {
maximumFractionDigits: 5,
});
+/** Use for prices (e.g. market unit price): 2 decimal places only */
+export const priceFormatter = new Intl.NumberFormat("en-HK", {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+});
+
export const integerFormatter = new Intl.NumberFormat("en-HK", {});
export const INPUT_DATE_FORMAT = "YYYY-MM-DD";
export const OUTPUT_DATE_FORMAT = "YYYY-MM-DD";
+/** Date and time for display, e.g. "YYYY-MM-DD HH:mm" */
+export const OUTPUT_DATETIME_FORMAT = "YYYY-MM-DD HH:mm";
+
export const INPUT_TIME_FORMAT = "HH:mm:ss";
export const OUTPUT_TIME_FORMAT = "HH:mm:ss";
diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx
index a462fef..87a74d2 100644
--- a/src/components/Breadcrumb/Breadcrumb.tsx
+++ b/src/components/Breadcrumb/Breadcrumb.tsx
@@ -39,6 +39,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/stockIssue": "Stock Issue",
"/report": "Report",
"/bagPrint": "打袋機",
+ "/settings/itemPrice": "Price Inquiry",
};
const Breadcrumb = () => {
diff --git a/src/components/ItemPriceSearch/ItemPriceSearch.tsx b/src/components/ItemPriceSearch/ItemPriceSearch.tsx
new file mode 100644
index 0000000..63c5b22
--- /dev/null
+++ b/src/components/ItemPriceSearch/ItemPriceSearch.tsx
@@ -0,0 +1,335 @@
+"use client";
+
+import { useCallback, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import SearchBox, { Criterion } from "@/components/SearchBox";
+import { ItemsResult, ItemsResultResponse } from "@/app/api/settings/item";
+import { Box, Button, Card, CardContent, CircularProgress, Grid, Typography } from "@mui/material";
+import Download from "@mui/icons-material/Download";
+import Upload from "@mui/icons-material/Upload";
+import { priceFormatter, OUTPUT_DATETIME_FORMAT } from "@/app/utils/formatUtil";
+import dayjs, { Dayjs } from "dayjs";
+import axiosInstance from "@/app/(main)/axios/axiosInstance";
+import { NEXT_PUBLIC_API_URL } from "@/config/api";
+
+type SearchQuery = {
+ code: string;
+ name: string;
+};
+
+type ItemPriceSearchComponent = React.FC & {
+ Loading: React.FC;
+};
+
+type SearchParamNames = keyof SearchQuery;
+
+const ItemPriceSearch: ItemPriceSearchComponent = () => {
+ const { t } = useTranslation(["inventory", "common"]);
+
+ const [item, setItem] = useState(null);
+ const [isSearching, setIsSearching] = useState(false);
+ const [isDownloading, setIsDownloading] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+
+ const criteria: Criterion[] = useMemo(
+ () => [
+ { label: t("Code"), paramName: "code", type: "text" },
+ { label: t("Name"), paramName: "name", type: "text" },
+ ],
+ [t],
+ );
+
+ const fetchExactItem = useCallback(async (query: SearchQuery) => {
+ const trimmedCode = query.code.trim();
+ const trimmedName = query.name.trim();
+ if (!trimmedCode && !trimmedName) {
+ setItem(null);
+ return;
+ }
+
+ setIsSearching(true);
+ try {
+ const params = {
+ code: trimmedCode,
+ name: trimmedName,
+ pageNum: 1,
+ pageSize: 20,
+ };
+
+ const res = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/items/getRecordByPage`,
+ { params },
+ );
+
+ if (!res?.data?.records || res.data.records.length === 0) {
+ setItem(null);
+ return;
+ }
+
+ const records = res.data.records as ItemsResult[];
+
+ const exactMatch = records.find((r) => {
+ const codeMatch = !trimmedCode || (r.code && String(r.code).toLowerCase() === trimmedCode.toLowerCase());
+ const nameMatch = !trimmedName || (r.name && String(r.name).toLowerCase() === trimmedName.toLowerCase());
+ return codeMatch && nameMatch;
+ });
+
+ setItem(exactMatch ?? null);
+ } catch {
+ setItem(null);
+ } finally {
+ setIsSearching(false);
+ }
+ }, []);
+
+ const handleSearch = useCallback(
+ (inputs: Record) => {
+ const query: SearchQuery = {
+ code: inputs.code ?? "",
+ name: inputs.name ?? "",
+ };
+ fetchExactItem(query);
+ },
+ [fetchExactItem],
+ );
+
+ const handleReset = useCallback(() => {
+ setItem(null);
+ }, []);
+
+ const fileInputRef = useRef(null);
+
+ const handleDownloadTemplate = useCallback(async () => {
+ setIsDownloading(true);
+ try {
+ const res = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/items/marketUnitPrice/template`,
+ { responseType: "blob" },
+ );
+ const url = URL.createObjectURL(res.data as Blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = "market_unit_price_template.xlsx";
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch {
+ alert(t("Download failed", { ns: "common" }));
+ } finally {
+ setIsDownloading(false);
+ }
+ }, [t]);
+
+ const handleUploadClick = useCallback(() => {
+ fileInputRef.current?.click();
+ }, []);
+
+ const handleUploadChange = useCallback(
+ async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setIsUploading(true);
+ const formData = new FormData();
+ formData.append("file", file);
+ try {
+ const res = await axiosInstance.post<{
+ success: boolean;
+ updated?: number;
+ errors?: string[];
+ }>(
+ `${NEXT_PUBLIC_API_URL}/items/marketUnitPrice/import`,
+ formData,
+ { headers: { "Content-Type": "multipart/form-data" } },
+ );
+ const data = res.data;
+ if (!data.success) {
+ const errMsg =
+ data.errors?.length > 0
+ ? `${t("Upload failed", { ns: "common" })}\n\n${data.errors.join("\n")}`
+ : t("Upload failed", { ns: "common" });
+ alert(errMsg);
+ return;
+ }
+ const updated = data.updated ?? 0;
+ if (data.errors?.length) {
+ const successLabel = t("Upload successful", { ns: "common" });
+ const countLabel = t("item(s) updated", { ns: "common" });
+ const rowErrorsLabel = t("Upload row errors", { ns: "common" });
+ const msg = `${successLabel} ${updated} ${countLabel}\n\n${rowErrorsLabel}\n${data.errors.join("\n")}`;
+ alert(msg);
+ } else {
+ alert(t("Upload successful", { ns: "common" }));
+ }
+ if (item) fetchExactItem({ code: item.code ?? "", name: item.name ?? "" });
+ } catch {
+ alert(t("Upload failed", { ns: "common" }));
+ } finally {
+ setIsUploading(false);
+ e.target.value = "";
+ }
+ },
+ [item, fetchExactItem, t],
+ );
+
+ const avgPrice = useMemo(() => {
+ if (item?.averageUnitPrice == null || item.averageUnitPrice === "") return null;
+ const n = Number(item.averageUnitPrice);
+ return Number.isFinite(n) ? n : null;
+ }, [item?.averageUnitPrice]);
+
+ return (
+ <>
+
+ criteria={criteria}
+ onSearch={handleSearch}
+ onReset={handleReset}
+ extraActions={
+ <>
+ : }
+ onClick={handleDownloadTemplate}
+ disabled={isDownloading || isUploading}
+ sx={{ borderColor: "#e2e8f0", color: "#334155" }}
+ >
+ {isDownloading ? t("Downloading...", { ns: "common" }) : t("Download Template", { ns: "common" })}
+
+ : }
+ disabled={isDownloading || isUploading}
+ sx={{ borderColor: "#e2e8f0", color: "#334155" }}
+ >
+ {isUploading ? t("Uploading...", { ns: "common" }) : t("Upload", { ns: "common" })}
+
+
+ >
+ }
+ />
+
+
+ {isSearching && (
+
+ {t("Searching")}...
+
+ )}
+
+ {!isSearching && !item && (
+
+ {t("No item selected")}
+
+ )}
+
+ {!isSearching && item && (
+
+ {/* Box 1: Item info */}
+
+
+
+
+ {item.code}
+
+
+ {item.name}
+
+
+ {[
+ item.type ? t(item.type.toUpperCase(), { ns: "common" }) : null,
+ item.purchaseUnit ?? null,
+ ]
+ .filter(Boolean)
+ .join(" · ") || "—"}
+
+
+
+
+
+ {/* Box 2: Avg unit price (from items table) */}
+
+
+
+
+ {t("Average unit price", { ns: "inventory" })}
+
+
+ {avgPrice != null && avgPrice !== 0
+ ? `HKD ${priceFormatter.format(avgPrice)}`
+ : t("No Purchase Order After 2026-01-01", { ns: "common" })}
+
+
+
+
+
+
+ {/* Box 3: Latest market unit price & update date (from items table) */}
+
+
+
+
+ {t("Latest market unit price", { ns: "inventory" })}
+
+
+ {item.latestMarketUnitPrice != null && Number(item.latestMarketUnitPrice) !== 0
+ ? `HKD ${priceFormatter.format(Number(item.latestMarketUnitPrice))}`
+ : t("No Import Record", { ns: "common" })}
+
+
+
+ {item.latestMupUpdatedDate != null && item.latestMupUpdatedDate !== ""
+ ? (() => {
+ const raw = item.latestMupUpdatedDate;
+ let d: Dayjs | null = null;
+ if (Array.isArray(raw) && raw.length >= 5) {
+ const [y, m, day, h, min] = raw as number[];
+ d = dayjs(new Date(y, (m ?? 1) - 1, day ?? 1, h ?? 0, min ?? 0));
+ } else if (typeof raw === "string") {
+ d = dayjs(raw);
+ }
+ return d?.isValid() ? d.format(OUTPUT_DATETIME_FORMAT) : String(raw);
+ })()
+ : item.latestMarketUnitPrice != null && Number(item.latestMarketUnitPrice) !== 0
+ ? "—"
+ : ""}
+
+
+
+
+
+
+ )}
+
+ >
+ );
+};
+
+ItemPriceSearch.Loading = function Loading() {
+ return Loading...
;
+};
+
+export default ItemPriceSearch;
diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx
index 50054d7..ce60a95 100644
--- a/src/components/NavigationContent/NavigationContent.tsx
+++ b/src/components/NavigationContent/NavigationContent.tsx
@@ -212,6 +212,11 @@ const NavigationContent: React.FC = () => {
label: "Equipment",
path: "/settings/equipment",
},
+ {
+ icon: ,
+ label: "Price Inquiry",
+ path: "/settings/itemPrice",
+ },
{
icon: ,
label: "Warehouse",
diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx
index dc80507..fd59feb 100644
--- a/src/components/SearchBox/SearchBox.tsx
+++ b/src/components/SearchBox/SearchBox.tsx
@@ -120,12 +120,15 @@ interface Props {
// onSearch: (inputs: Record["type"] extends "dateRange" ? `${T}To` : never), string>) => void;
onSearch: (inputs: Record) => void;
onReset?: () => void;
+ /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */
+ extraActions?: React.ReactNode;
}
function SearchBox({
criteria,
onSearch,
onReset,
+ extraActions,
}: Props) {
const { t } = useTranslation("common");
const defaultAll: AutocompleteOptions = {
@@ -566,7 +569,7 @@ function SearchBox({
);
})}
-
+
}
@@ -583,6 +586,7 @@ function SearchBox({
>
{t("Search")}
+ {extraActions}
diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json
index 625748a..21eb043 100644
--- a/src/i18n/en/common.json
+++ b/src/i18n/en/common.json
@@ -34,5 +34,19 @@
"Search or select remark": "Search or select remark",
"Edit shop details": "Edit shop details",
"Add Shop to Truck Lane": "Add Shop to Truck Lane",
- "Truck lane code already exists. Please use a different code.": "Truck lane code already exists. Please use a different code."
+ "Truck lane code already exists. Please use a different code.": "Truck lane code already exists. Please use a different code.",
+ "No Purchase Order After 2026-01-01": "No Purchase Order After 2026-01-01",
+ "No Import Record": "No Import Record",
+ "Download Template": "Download Template",
+ "Upload": "Upload",
+ "Downloading...": "Downloading...",
+ "Uploading...": "Uploading...",
+ "Upload successful": "Upload successful",
+ "Upload failed": "Upload failed",
+ "Download failed": "Download failed",
+ "Upload completed with count": "{{count}} item(s) updated.",
+ "Upload row errors": "The following rows had issues:",
+ "item(s) updated": "item(s) updated.",
+ "Average unit price": "Average unit price",
+ "Latest market unit price": "Latest market unit price"
}
\ No newline at end of file
diff --git a/src/i18n/en/inventory.json b/src/i18n/en/inventory.json
index 544b7b4..8816a4d 100644
--- a/src/i18n/en/inventory.json
+++ b/src/i18n/en/inventory.json
@@ -1,3 +1,4 @@
{
-
+ "Average unit price": "Average unit price",
+ "Latest market unit price": "Latest market unit price"
}
\ No newline at end of file
diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json
index a1a33ae..e3b4402 100644
--- a/src/i18n/zh/common.json
+++ b/src/i18n/zh/common.json
@@ -542,5 +542,27 @@
"Auto-refresh every 10 minutes": "每10分鐘自動刷新",
"Auto-refresh every 15 minutes": "每15分鐘自動刷新",
"Auto-refresh every 1 minute": "每1分鐘自動刷新",
- "Brand": "品牌"
+ "Brand": "品牌",
+ "Price Inquiry": "價格查詢",
+ "No Purchase Order After 2026-01-01": "在2026-01-01後沒有採購記錄",
+ "No Import Record": "沒有導入記錄",
+
+ "wip": "半成品",
+ "cmb": "消耗品",
+ "nm": "雜項及非消耗品",
+ "MAT": "材料",
+ "CMB": "消耗品",
+ "NM": "雜項及非消耗品",
+ "Download Template": "下載範本",
+ "Upload": "上傳",
+ "Downloading...": "正在下載...",
+ "Uploading...": "正在上傳...",
+ "Upload successful": "上傳成功",
+ "Upload failed": "上傳失敗",
+ "Download failed": "下載失敗",
+ "Upload completed with count": "已更新 {{count}} 個項目。",
+ "Upload row errors": "以下行有問題:",
+ "item(s) updated": "個項目已更新。",
+ "Average unit price": "平均單位價格",
+ "Latest market unit price": "最新市場價格"
}
\ No newline at end of file
diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json
index fe450a4..8550241 100644
--- a/src/i18n/zh/inventory.json
+++ b/src/i18n/zh/inventory.json
@@ -258,6 +258,8 @@
"No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。",
"Reason for removal": "移除原因",
"Confirm remove": "確認移除",
- "Adjusted Qty": "調整後倉存"
+ "Adjusted Qty": "調整後倉存",
+ "Average unit price": "平均單位價格",
+ "Latest market unit price": "最新市場價格"
}