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