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