FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

336 line
13 KiB

  1. "use client";
  2. import { useCallback, useMemo, useRef, useState } from "react";
  3. import { useTranslation } from "react-i18next";
  4. import SearchBox, { Criterion } from "@/components/SearchBox";
  5. import { ItemsResult, ItemsResultResponse } from "@/app/api/settings/item";
  6. import { Box, Button, Card, CardContent, CircularProgress, Grid, Typography } from "@mui/material";
  7. import Download from "@mui/icons-material/Download";
  8. import Upload from "@mui/icons-material/Upload";
  9. import { priceFormatter, OUTPUT_DATETIME_FORMAT } from "@/app/utils/formatUtil";
  10. import dayjs, { Dayjs } from "dayjs";
  11. import axiosInstance from "@/app/(main)/axios/axiosInstance";
  12. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  13. type SearchQuery = {
  14. code: string;
  15. name: string;
  16. };
  17. type ItemPriceSearchComponent = React.FC & {
  18. Loading: React.FC;
  19. };
  20. type SearchParamNames = keyof SearchQuery;
  21. const ItemPriceSearch: ItemPriceSearchComponent = () => {
  22. const { t } = useTranslation(["inventory", "common"]);
  23. const [item, setItem] = useState<ItemsResult | null>(null);
  24. const [isSearching, setIsSearching] = useState(false);
  25. const [isDownloading, setIsDownloading] = useState(false);
  26. const [isUploading, setIsUploading] = useState(false);
  27. const criteria: Criterion<SearchParamNames>[] = useMemo(
  28. () => [
  29. { label: t("Code"), paramName: "code", type: "text" },
  30. { label: t("Name"), paramName: "name", type: "text" },
  31. ],
  32. [t],
  33. );
  34. const fetchExactItem = useCallback(async (query: SearchQuery) => {
  35. const trimmedCode = query.code.trim();
  36. const trimmedName = query.name.trim();
  37. if (!trimmedCode && !trimmedName) {
  38. setItem(null);
  39. return;
  40. }
  41. setIsSearching(true);
  42. try {
  43. const params = {
  44. code: trimmedCode,
  45. name: trimmedName,
  46. pageNum: 1,
  47. pageSize: 20,
  48. };
  49. const res = await axiosInstance.get<ItemsResultResponse>(
  50. `${NEXT_PUBLIC_API_URL}/items/getRecordByPage`,
  51. { params },
  52. );
  53. if (!res?.data?.records || res.data.records.length === 0) {
  54. setItem(null);
  55. return;
  56. }
  57. const records = res.data.records as ItemsResult[];
  58. const exactMatch = records.find((r) => {
  59. const codeMatch = !trimmedCode || (r.code && String(r.code).toLowerCase() === trimmedCode.toLowerCase());
  60. const nameMatch = !trimmedName || (r.name && String(r.name).toLowerCase() === trimmedName.toLowerCase());
  61. return codeMatch && nameMatch;
  62. });
  63. setItem(exactMatch ?? null);
  64. } catch {
  65. setItem(null);
  66. } finally {
  67. setIsSearching(false);
  68. }
  69. }, []);
  70. const handleSearch = useCallback(
  71. (inputs: Record<SearchParamNames, string>) => {
  72. const query: SearchQuery = {
  73. code: inputs.code ?? "",
  74. name: inputs.name ?? "",
  75. };
  76. fetchExactItem(query);
  77. },
  78. [fetchExactItem],
  79. );
  80. const handleReset = useCallback(() => {
  81. setItem(null);
  82. }, []);
  83. const fileInputRef = useRef<HTMLInputElement>(null);
  84. const handleDownloadTemplate = useCallback(async () => {
  85. setIsDownloading(true);
  86. try {
  87. const res = await axiosInstance.get(
  88. `${NEXT_PUBLIC_API_URL}/items/marketUnitPrice/template`,
  89. { responseType: "blob" },
  90. );
  91. const url = URL.createObjectURL(res.data as Blob);
  92. const a = document.createElement("a");
  93. a.href = url;
  94. a.download = "market_unit_price_template.xlsx";
  95. a.click();
  96. URL.revokeObjectURL(url);
  97. } catch {
  98. alert(t("Download failed", { ns: "common" }));
  99. } finally {
  100. setIsDownloading(false);
  101. }
  102. }, [t]);
  103. const handleUploadClick = useCallback(() => {
  104. fileInputRef.current?.click();
  105. }, []);
  106. const handleUploadChange = useCallback(
  107. async (e: React.ChangeEvent<HTMLInputElement>) => {
  108. const file = e.target.files?.[0];
  109. if (!file) return;
  110. setIsUploading(true);
  111. const formData = new FormData();
  112. formData.append("file", file);
  113. try {
  114. const res = await axiosInstance.post<{
  115. success: boolean;
  116. updated?: number;
  117. errors?: string[];
  118. }>(
  119. `${NEXT_PUBLIC_API_URL}/items/marketUnitPrice/import`,
  120. formData,
  121. { headers: { "Content-Type": "multipart/form-data" } },
  122. );
  123. const data = res.data;
  124. if (!data.success) {
  125. const errMsg =
  126. data.errors?.length > 0
  127. ? `${t("Upload failed", { ns: "common" })}\n\n${data.errors.join("\n")}`
  128. : t("Upload failed", { ns: "common" });
  129. alert(errMsg);
  130. return;
  131. }
  132. const updated = data.updated ?? 0;
  133. if (data.errors?.length) {
  134. const successLabel = t("Upload successful", { ns: "common" });
  135. const countLabel = t("item(s) updated", { ns: "common" });
  136. const rowErrorsLabel = t("Upload row errors", { ns: "common" });
  137. const msg = `${successLabel} ${updated} ${countLabel}\n\n${rowErrorsLabel}\n${data.errors.join("\n")}`;
  138. alert(msg);
  139. } else {
  140. alert(t("Upload successful", { ns: "common" }));
  141. }
  142. if (item) fetchExactItem({ code: item.code ?? "", name: item.name ?? "" });
  143. } catch {
  144. alert(t("Upload failed", { ns: "common" }));
  145. } finally {
  146. setIsUploading(false);
  147. e.target.value = "";
  148. }
  149. },
  150. [item, fetchExactItem, t],
  151. );
  152. const avgPrice = useMemo(() => {
  153. if (item?.averageUnitPrice == null || item.averageUnitPrice === "") return null;
  154. const n = Number(item.averageUnitPrice);
  155. return Number.isFinite(n) ? n : null;
  156. }, [item?.averageUnitPrice]);
  157. return (
  158. <>
  159. <SearchBox<SearchParamNames>
  160. criteria={criteria}
  161. onSearch={handleSearch}
  162. onReset={handleReset}
  163. extraActions={
  164. <>
  165. <Button
  166. variant="outlined"
  167. startIcon={isDownloading ? <CircularProgress size={18} color="inherit" /> : <Download />}
  168. onClick={handleDownloadTemplate}
  169. disabled={isDownloading || isUploading}
  170. sx={{ borderColor: "#e2e8f0", color: "#334155" }}
  171. >
  172. {isDownloading ? t("Downloading...", { ns: "common" }) : t("Download Template", { ns: "common" })}
  173. </Button>
  174. <Button
  175. variant="outlined"
  176. component="label"
  177. startIcon={isUploading ? <CircularProgress size={18} color="inherit" /> : <Upload />}
  178. disabled={isDownloading || isUploading}
  179. sx={{ borderColor: "#e2e8f0", color: "#334155" }}
  180. >
  181. {isUploading ? t("Uploading...", { ns: "common" }) : t("Upload", { ns: "common" })}
  182. <input
  183. ref={fileInputRef}
  184. type="file"
  185. hidden
  186. accept=".xlsx,.xls"
  187. onChange={handleUploadChange}
  188. />
  189. </Button>
  190. </>
  191. }
  192. />
  193. <Box sx={{ mt: 2 }}>
  194. {isSearching && (
  195. <Typography variant="body2" color="text.secondary">
  196. {t("Searching")}...
  197. </Typography>
  198. )}
  199. {!isSearching && !item && (
  200. <Typography variant="body2" color="text.secondary">
  201. {t("No item selected")}
  202. </Typography>
  203. )}
  204. {!isSearching && item && (
  205. <Grid container spacing={2} sx={{ alignItems: "stretch" }}>
  206. {/* Box 1: Item info */}
  207. <Grid item xs={12} sm={6} md={4} sx={{ display: "flex" }}>
  208. <Card
  209. variant="outlined"
  210. sx={{
  211. borderRadius: 2,
  212. flex: 1,
  213. display: "flex",
  214. flexDirection: "column",
  215. }}
  216. >
  217. <CardContent sx={{ py: 2, flex: 1, "&:last-child": { pb: 2 } }}>
  218. <Typography variant="h6" sx={{ fontWeight: 600, mb: 1 }}>
  219. {item.code}
  220. </Typography>
  221. <Typography
  222. variant="body1"
  223. color="text.secondary"
  224. sx={{
  225. mb: 0.5,
  226. height: "2.5em",
  227. lineHeight: 1.25,
  228. display: "-webkit-box",
  229. WebkitLineClamp: 2,
  230. WebkitBoxOrient: "vertical",
  231. overflow: "hidden",
  232. }}
  233. >
  234. {item.name}
  235. </Typography>
  236. <Typography variant="body2" color="text.secondary">
  237. {[
  238. item.type ? t(item.type.toUpperCase(), { ns: "common" }) : null,
  239. item.purchaseUnit ?? null,
  240. ]
  241. .filter(Boolean)
  242. .join(" · ") || "—"}
  243. </Typography>
  244. </CardContent>
  245. </Card>
  246. </Grid>
  247. {/* Box 2: Avg unit price (from items table) */}
  248. <Grid item xs={12} sm={6} md={4} sx={{ display: "flex" }}>
  249. <Card variant="outlined" sx={{ borderRadius: 2, flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
  250. <CardContent sx={{ py: 2, flex: 1, minHeight: 0, "&:last-child": { pb: 2 }, display: "flex", flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }}>
  251. <Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, flexShrink: 0 }}>
  252. {t("Average unit price", { ns: "inventory" })}
  253. </Typography>
  254. <Typography variant="h5" sx={{ width: "100%", flex: 1, display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", fontWeight: 500, color: "black", minHeight: 0 }}>
  255. {avgPrice != null && avgPrice !== 0
  256. ? `HKD ${priceFormatter.format(avgPrice)}`
  257. : t("No Purchase Order After 2026-01-01", { ns: "common" })}
  258. </Typography>
  259. <Box sx={{ mt: "auto", height: 32, display: "flex", alignItems: "center" }} aria-hidden />
  260. </CardContent>
  261. </Card>
  262. </Grid>
  263. {/* Box 3: Latest market unit price & update date (from items table) */}
  264. <Grid item xs={12} sm={6} md={4} sx={{ display: "flex" }}>
  265. <Card variant="outlined" sx={{ borderRadius: 2, flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
  266. <CardContent sx={{ py: 2, flex: 1, minHeight: 0, "&:last-child": { pb: 2 }, display: "flex", flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }}>
  267. <Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, flexShrink: 0 }}>
  268. {t("Latest market unit price", { ns: "inventory" })}
  269. </Typography>
  270. <Typography variant="h5" sx={{ width: "100%", textAlign: "center", fontWeight: 500, color: "black", flex: 1, minHeight: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
  271. {item.latestMarketUnitPrice != null && Number(item.latestMarketUnitPrice) !== 0
  272. ? `HKD ${priceFormatter.format(Number(item.latestMarketUnitPrice))}`
  273. : t("No Import Record", { ns: "common" })}
  274. </Typography>
  275. <Box sx={{ mt: "auto", height: 32, display: "flex", alignItems: "center" }}>
  276. <Typography variant="body2" color="text.secondary">
  277. {item.latestMupUpdatedDate != null && item.latestMupUpdatedDate !== ""
  278. ? (() => {
  279. const raw = item.latestMupUpdatedDate;
  280. let d: Dayjs | null = null;
  281. if (Array.isArray(raw) && raw.length >= 5) {
  282. const [y, m, day, h, min] = raw as number[];
  283. d = dayjs(new Date(y, (m ?? 1) - 1, day ?? 1, h ?? 0, min ?? 0));
  284. } else if (typeof raw === "string") {
  285. d = dayjs(raw);
  286. }
  287. return d?.isValid() ? d.format(OUTPUT_DATETIME_FORMAT) : String(raw);
  288. })()
  289. : item.latestMarketUnitPrice != null && Number(item.latestMarketUnitPrice) !== 0
  290. ? "—"
  291. : ""}
  292. </Typography>
  293. </Box>
  294. </CardContent>
  295. </Card>
  296. </Grid>
  297. </Grid>
  298. )}
  299. </Box>
  300. </>
  301. );
  302. };
  303. ItemPriceSearch.Loading = function Loading() {
  304. return <div>Loading...</div>;
  305. };
  306. export default ItemPriceSearch;