From 3675c90342fcdb6c485aaf6b6a0dcb93da2924d0 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Sun, 15 Mar 2026 01:40:35 +0800 Subject: [PATCH] price inqury --- src/app/(main)/settings/itemPrice/page.tsx | 27 ++ src/app/api/inventory/index.ts | 2 + src/app/api/settings/item/index.ts | 4 + src/app/utils/formatUtil.ts | 9 + src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../ItemPriceSearch/ItemPriceSearch.tsx | 335 ++++++++++++++++++ .../NavigationContent/NavigationContent.tsx | 5 + src/components/SearchBox/SearchBox.tsx | 6 +- src/i18n/en/common.json | 16 +- src/i18n/en/inventory.json | 3 +- src/i18n/zh/common.json | 24 +- src/i18n/zh/inventory.json | 4 +- 12 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 src/app/(main)/settings/itemPrice/page.tsx create mode 100644 src/components/ItemPriceSearch/ItemPriceSearch.tsx 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={ + <> + + + + } + /> + + + {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({ ); })} - + + {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": "最新市場價格" }