Przeglądaj źródła

price inqury

reset-do-picking-order
kelvin.yau 1 tydzień temu
rodzic
commit
3675c90342
12 zmienionych plików z 431 dodań i 5 usunięć
  1. +27
    -0
      src/app/(main)/settings/itemPrice/page.tsx
  2. +2
    -0
      src/app/api/inventory/index.ts
  3. +4
    -0
      src/app/api/settings/item/index.ts
  4. +9
    -0
      src/app/utils/formatUtil.ts
  5. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  6. +335
    -0
      src/components/ItemPriceSearch/ItemPriceSearch.tsx
  7. +5
    -0
      src/components/NavigationContent/NavigationContent.tsx
  8. +5
    -1
      src/components/SearchBox/SearchBox.tsx
  9. +15
    -1
      src/i18n/en/common.json
  10. +2
    -1
      src/i18n/en/inventory.json
  11. +23
    -1
      src/i18n/zh/common.json
  12. +3
    -1
      src/i18n/zh/inventory.json

+ 27
- 0
src/app/(main)/settings/itemPrice/page.tsx Wyświetl plik

@@ -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 (
<>
<PageTitleBar title={t("Price Inquiry", { ns: "common" })} className="mb-4" />

<I18nProvider namespaces={["common", "inventory"]}>
<Suspense fallback={<ItemPriceSearch.Loading />}>
<ItemPriceSearch />
</Suspense>
</I18nProvider>
</>
);
};

export default ItemPriceSetting;

+ 2
- 0
src/app/api/inventory/index.ts Wyświetl plik

@@ -24,6 +24,8 @@ export interface InventoryResult {
price: number;
currencyName: string;
status: string;
latestMarketUnitPrice?: number;
latestMupUpdatedDate?: string;
}

export interface InventoryLotLineResult {


+ 4
- 0
src/app/api/settings/item/index.ts Wyświetl plik

@@ -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 = {


+ 9
- 0
src/app/utils/formatUtil.ts Wyświetl plik

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


+ 1
- 0
src/components/Breadcrumb/Breadcrumb.tsx Wyświetl plik

@@ -39,6 +39,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/stockIssue": "Stock Issue",
"/report": "Report",
"/bagPrint": "打袋機",
"/settings/itemPrice": "Price Inquiry",
};

const Breadcrumb = () => {


+ 335
- 0
src/components/ItemPriceSearch/ItemPriceSearch.tsx Wyświetl plik

@@ -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<ItemsResult | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isUploading, setIsUploading] = useState(false);

const criteria: Criterion<SearchParamNames>[] = 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<ItemsResultResponse>(
`${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<SearchParamNames, string>) => {
const query: SearchQuery = {
code: inputs.code ?? "",
name: inputs.name ?? "",
};
fetchExactItem(query);
},
[fetchExactItem],
);

const handleReset = useCallback(() => {
setItem(null);
}, []);

const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<>
<SearchBox<SearchParamNames>
criteria={criteria}
onSearch={handleSearch}
onReset={handleReset}
extraActions={
<>
<Button
variant="outlined"
startIcon={isDownloading ? <CircularProgress size={18} color="inherit" /> : <Download />}
onClick={handleDownloadTemplate}
disabled={isDownloading || isUploading}
sx={{ borderColor: "#e2e8f0", color: "#334155" }}
>
{isDownloading ? t("Downloading...", { ns: "common" }) : t("Download Template", { ns: "common" })}
</Button>
<Button
variant="outlined"
component="label"
startIcon={isUploading ? <CircularProgress size={18} color="inherit" /> : <Upload />}
disabled={isDownloading || isUploading}
sx={{ borderColor: "#e2e8f0", color: "#334155" }}
>
{isUploading ? t("Uploading...", { ns: "common" }) : t("Upload", { ns: "common" })}
<input
ref={fileInputRef}
type="file"
hidden
accept=".xlsx,.xls"
onChange={handleUploadChange}
/>
</Button>
</>
}
/>

<Box sx={{ mt: 2 }}>
{isSearching && (
<Typography variant="body2" color="text.secondary">
{t("Searching")}...
</Typography>
)}

{!isSearching && !item && (
<Typography variant="body2" color="text.secondary">
{t("No item selected")}
</Typography>
)}

{!isSearching && item && (
<Grid container spacing={2} sx={{ alignItems: "stretch" }}>
{/* Box 1: Item info */}
<Grid item xs={12} sm={6} md={4} sx={{ display: "flex" }}>
<Card
variant="outlined"
sx={{
borderRadius: 2,
flex: 1,
display: "flex",
flexDirection: "column",
}}
>
<CardContent sx={{ py: 2, flex: 1, "&:last-child": { pb: 2 } }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1 }}>
{item.code}
</Typography>
<Typography
variant="body1"
color="text.secondary"
sx={{
mb: 0.5,
height: "2.5em",
lineHeight: 1.25,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{item.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{[
item.type ? t(item.type.toUpperCase(), { ns: "common" }) : null,
item.purchaseUnit ?? null,
]
.filter(Boolean)
.join(" · ") || "—"}
</Typography>
</CardContent>
</Card>
</Grid>

{/* Box 2: Avg unit price (from items table) */}
<Grid item xs={12} sm={6} md={4} sx={{ display: "flex" }}>
<Card variant="outlined" sx={{ borderRadius: 2, flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<CardContent sx={{ py: 2, flex: 1, minHeight: 0, "&:last-child": { pb: 2 }, display: "flex", flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, flexShrink: 0 }}>
{t("Average unit price", { ns: "inventory" })}
</Typography>
<Typography variant="h5" sx={{ width: "100%", flex: 1, display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", fontWeight: 500, color: "black", minHeight: 0 }}>
{avgPrice != null && avgPrice !== 0
? `HKD ${priceFormatter.format(avgPrice)}`
: t("No Purchase Order After 2026-01-01", { ns: "common" })}
</Typography>
<Box sx={{ mt: "auto", height: 32, display: "flex", alignItems: "center" }} aria-hidden />
</CardContent>
</Card>
</Grid>

{/* Box 3: Latest market unit price & update date (from items table) */}
<Grid item xs={12} sm={6} md={4} sx={{ display: "flex" }}>
<Card variant="outlined" sx={{ borderRadius: 2, flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<CardContent sx={{ py: 2, flex: 1, minHeight: 0, "&:last-child": { pb: 2 }, display: "flex", flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, flexShrink: 0 }}>
{t("Latest market unit price", { ns: "inventory" })}
</Typography>
<Typography variant="h5" sx={{ width: "100%", textAlign: "center", fontWeight: 500, color: "black", flex: 1, minHeight: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
{item.latestMarketUnitPrice != null && Number(item.latestMarketUnitPrice) !== 0
? `HKD ${priceFormatter.format(Number(item.latestMarketUnitPrice))}`
: t("No Import Record", { ns: "common" })}
</Typography>
<Box sx={{ mt: "auto", height: 32, display: "flex", alignItems: "center" }}>
<Typography variant="body2" color="text.secondary">
{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
? "—"
: ""}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
)}
</Box>
</>
);
};

ItemPriceSearch.Loading = function Loading() {
return <div>Loading...</div>;
};

export default ItemPriceSearch;

+ 5
- 0
src/components/NavigationContent/NavigationContent.tsx Wyświetl plik

@@ -212,6 +212,11 @@ const NavigationContent: React.FC = () => {
label: "Equipment",
path: "/settings/equipment",
},
{
icon: <Assessment />,
label: "Price Inquiry",
path: "/settings/itemPrice",
},
{
icon: <Warehouse />,
label: "Warehouse",


+ 5
- 1
src/components/SearchBox/SearchBox.tsx Wyświetl plik

@@ -120,12 +120,15 @@ interface Props<T extends string> {
// onSearch: (inputs: Record<T | (Criterion<T>["type"] extends "dateRange" ? `${T}To` : never), string>) => void;
onSearch: (inputs: Record<T | `${T}To`, string>) => void;
onReset?: () => void;
/** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */
extraActions?: React.ReactNode;
}

function SearchBox<T extends string>({
criteria,
onSearch,
onReset,
extraActions,
}: Props<T>) {
const { t } = useTranslation("common");
const defaultAll: AutocompleteOptions = {
@@ -566,7 +569,7 @@ function SearchBox<T extends string>({
);
})}
</Grid>
<CardActions sx={{ justifyContent: "flex-start", gap: 1, pt: 2 }}>
<CardActions sx={{ justifyContent: "flex-start", gap: 1, pt: 2, flexWrap: "wrap" }}>
<Button
variant="outlined"
startIcon={<RestartAlt />}
@@ -583,6 +586,7 @@ function SearchBox<T extends string>({
>
{t("Search")}
</Button>
{extraActions}
</CardActions>
</CardContent>
</Card>


+ 15
- 1
src/i18n/en/common.json Wyświetl plik

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

+ 2
- 1
src/i18n/en/inventory.json Wyświetl plik

@@ -1,3 +1,4 @@
{
"Average unit price": "Average unit price",
"Latest market unit price": "Latest market unit price"
}

+ 23
- 1
src/i18n/zh/common.json Wyświetl plik

@@ -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": "最新市場價格"
}

+ 3
- 1
src/i18n/zh/inventory.json Wyświetl plik

@@ -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": "最新市場價格"

}

Ładowanie…
Anuluj
Zapisz