From d0a0736f0d8de9fa9432d8b2cc857c7bee9a559f Mon Sep 17 00:00:00 2001 From: "B.E.N.S.O.N" Date: Tue, 13 Jan 2026 15:01:41 +0800 Subject: [PATCH] Supporting Function: Warehouse QR Code Printing --- src/app/(main)/settings/qrCodeHandle/page.tsx | 10 +- src/app/api/warehouse/client.ts | 33 + .../WarehouseHandle/WarehouseHandle.tsx | 20 - .../qrCodeHandles/qrCodeHandleTabs.tsx | 15 +- .../qrCodeHandleWarehouseSearch.tsx | 675 ++++++++++++++++++ .../qrCodeHandleWarehouseSearchWrapper.tsx | 21 + 6 files changed, 752 insertions(+), 22 deletions(-) create mode 100644 src/app/api/warehouse/client.ts create mode 100644 src/components/qrCodeHandles/qrCodeHandleWarehouseSearch.tsx create mode 100644 src/components/qrCodeHandles/qrCodeHandleWarehouseSearchWrapper.tsx diff --git a/src/app/(main)/settings/qrCodeHandle/page.tsx b/src/app/(main)/settings/qrCodeHandle/page.tsx index e0a84c7..d363561 100644 --- a/src/app/(main)/settings/qrCodeHandle/page.tsx +++ b/src/app/(main)/settings/qrCodeHandle/page.tsx @@ -4,6 +4,7 @@ import Typography from "@mui/material/Typography"; import { getServerI18n } from "@/i18n"; import QrCodeHandleSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleSearchWrapper"; import QrCodeHandleEquipmentSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleEquipmentSearchWrapper"; +import QrCodeHandleWarehouseSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleWarehouseSearchWrapper"; import QrCodeHandleTabs from "@/components/qrCodeHandles/qrCodeHandleTabs"; import { I18nProvider } from "@/i18n"; import Box from "@mui/material/Box"; @@ -19,7 +20,7 @@ const QrCodeHandlePage: React.FC = async () => { {t("QR Code Handle")} - + }> @@ -35,6 +36,13 @@ const QrCodeHandlePage: React.FC = async () => { } + warehouseTabContent={ + }> + + + + + } /> diff --git a/src/app/api/warehouse/client.ts b/src/app/api/warehouse/client.ts new file mode 100644 index 0000000..454d48a --- /dev/null +++ b/src/app/api/warehouse/client.ts @@ -0,0 +1,33 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { WarehouseResult } from "./index"; + +export const exportWarehouseQrCode = async (warehouseIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { + + const token = localStorage.getItem("accessToken"); + + const response = await fetch(`${NEXT_PUBLIC_API_URL}/warehouse/export-qrcode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), + }, + body: JSON.stringify({ warehouseIds }), + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error("Unauthorized: Please log in again"); + } + throw new Error(`Failed to export QR code: ${response.status} ${response.statusText}`); + } + + const filename = response.headers.get("Content-Disposition")?.split("filename=")[1]?.replace(/"/g, "") || "warehouse_qrcode.pdf"; + + const blob = await response.blob(); + const arrayBuffer = await blob.arrayBuffer(); + const blobValue = new Uint8Array(arrayBuffer); + + return { blobValue, filename }; +}; diff --git a/src/components/WarehouseHandle/WarehouseHandle.tsx b/src/components/WarehouseHandle/WarehouseHandle.tsx index 453de68..97e471b 100644 --- a/src/components/WarehouseHandle/WarehouseHandle.tsx +++ b/src/components/WarehouseHandle/WarehouseHandle.tsx @@ -53,8 +53,6 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { successDialog(t("Delete Success"), t); } catch (error) { console.error("Failed to delete warehouse:", error); - // Don't redirect on error, just show error message - // The error will be logged but user stays on the page } }, t); }, [t, router]); @@ -76,18 +74,14 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { try { let results: WarehouseResult[] = warehouses; - // Build search pattern from the four fields: store_idF-warehouse-area-slot - // Only search by code field - match the code that follows this pattern const storeId = searchInputs.store_id?.trim() || ""; const warehouse = searchInputs.warehouse?.trim() || ""; const area = searchInputs.area?.trim() || ""; const slot = searchInputs.slot?.trim() || ""; const stockTakeSection = searchInputs.stockTakeSection?.trim() || ""; - // If any field has a value, filter by code pattern and stockTakeSection if (storeId || warehouse || area || slot || stockTakeSection) { results = warehouses.filter((warehouseItem) => { - // Filter by stockTakeSection if provided if (stockTakeSection) { const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); if (!itemStockTakeSection.includes(stockTakeSection.toLowerCase())) { @@ -95,7 +89,6 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { } } - // Filter by code pattern if any code-related field is provided if (storeId || warehouse || area || slot) { if (!warehouseItem.code) { return false; @@ -103,8 +96,6 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { const codeValue = String(warehouseItem.code).toLowerCase(); - // Check if code matches the pattern: store_id-warehouse-area-slot - // Match each part if provided const codeParts = codeValue.split("-"); if (codeParts.length >= 4) { @@ -121,7 +112,6 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { return storeIdMatch && warehouseMatch && areaMatch && slotMatch; } - // Fallback: if code doesn't follow the pattern, check if it contains any of the search terms const storeIdMatch = !storeId || codeValue.includes(storeId.toLowerCase()); const warehouseMatch = !warehouse || codeValue.includes(warehouse.toLowerCase()); const areaMatch = !area || codeValue.includes(area.toLowerCase()); @@ -130,11 +120,9 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { return storeIdMatch && warehouseMatch && areaMatch && slotMatch; } - // If only stockTakeSection is provided, return true (already filtered above) return true; }); } else { - // If no search terms, show all warehouses results = warehouses; } @@ -142,7 +130,6 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); } catch (error) { console.error("Error searching warehouses:", error); - // Fallback: filter by code pattern and stockTakeSection const storeId = searchInputs.store_id?.trim().toLowerCase() || ""; const warehouse = searchInputs.warehouse?.trim().toLowerCase() || ""; const area = searchInputs.area?.trim().toLowerCase() || ""; @@ -151,7 +138,6 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { setFilteredWarehouse( warehouses.filter((warehouseItem) => { - // Filter by stockTakeSection if provided if (stockTakeSection) { const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); if (!itemStockTakeSection.includes(stockTakeSection)) { @@ -159,7 +145,6 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { } } - // Filter by code if any code-related field is provided if (storeId || warehouse || area || slot) { if (!warehouseItem.code) { return false; @@ -267,7 +252,6 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { justifyContent: "flex-start", }} > - {/* 樓層 field with F inside on the right */} = ({ warehouses }) => { - - {/* 倉庫 field */} = ({ warehouses }) => { - - {/* 區域 field */} = ({ warehouses }) => { - - {/* 儲位 field */} = ({ warehouses }) => { size="small" sx={{ width: "150px", minWidth: "120px" }} /> - {/* 盤點區域 field */} = ({ userTabContent, equipmentTabContent, + warehouseTabContent, }) => { const { t } = useTranslation("common"); const { t: tUser } = useTranslation("user"); + const { t: tWarehouse } = useTranslation("warehouse"); const searchParams = useSearchParams(); const router = useRouter(); const getInitialTab = () => { const tab = searchParams.get("tab"); if (tab === "equipment") return 1; + if (tab === "warehouse") return 2; if (tab === "user") return 0; return 0; }; @@ -54,6 +58,8 @@ const QrCodeHandleTabs: React.FC = ({ const tab = searchParams.get("tab"); if (tab === "equipment") { setCurrentTab(1); + } else if (tab === "warehouse") { + setCurrentTab(2); } else if (tab === "user") { setCurrentTab(0); } @@ -61,7 +67,9 @@ const QrCodeHandleTabs: React.FC = ({ const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { setCurrentTab(newValue); - const tabName = newValue === 1 ? "equipment" : "user"; + let tabName = "user"; + if (newValue === 1) tabName = "equipment"; + else if (newValue === 2) tabName = "warehouse"; const params = new URLSearchParams(searchParams.toString()); params.set("tab", tabName); router.push(`?${params.toString()}`, { scroll: false }); @@ -73,6 +81,7 @@ const QrCodeHandleTabs: React.FC = ({ + @@ -83,6 +92,10 @@ const QrCodeHandleTabs: React.FC = ({ {equipmentTabContent} + + + {warehouseTabContent} + ); }; diff --git a/src/components/qrCodeHandles/qrCodeHandleWarehouseSearch.tsx b/src/components/qrCodeHandles/qrCodeHandleWarehouseSearch.tsx new file mode 100644 index 0000000..7bed7ec --- /dev/null +++ b/src/components/qrCodeHandles/qrCodeHandleWarehouseSearch.tsx @@ -0,0 +1,675 @@ +"use client"; + +import { useCallback, useMemo, useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import SearchResults, { Column } from "../SearchResults"; +import { successDialog } from "../Swal/CustomAlerts"; +import useUploadContext from "../UploadProvider/useUploadContext"; +import { downloadFile } from "@/app/utils/commonUtil"; +import { WarehouseResult } from "@/app/api/warehouse"; +import { exportWarehouseQrCode } from "@/app/api/warehouse/client"; +import { + Checkbox, + Box, + Button, + TextField, + Stack, + Autocomplete, + Modal, + Card, + CardContent, + CardActions, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Typography, + InputAdornment +} from "@mui/material"; +import DownloadIcon from "@mui/icons-material/Download"; +import PrintIcon from "@mui/icons-material/Print"; +import CloseIcon from "@mui/icons-material/Close"; +import RestartAlt from "@mui/icons-material/RestartAlt"; +import Search from "@mui/icons-material/Search"; +import { PrinterCombo } from "@/app/api/settings/printer"; + +interface Props { + warehouses: WarehouseResult[]; + printerCombo: PrinterCombo[]; +} + +const QrCodeHandleWarehouseSearch: React.FC = ({ warehouses, printerCombo }) => { + const { t } = useTranslation(["warehouse", "common"]); + const [filteredWarehouses, setFilteredWarehouses] = useState(warehouses); + const { setIsUploading } = useUploadContext(); + const [pagingController, setPagingController] = useState({ + pageNum: 1, + pageSize: 10, + }); + + const [checkboxIds, setCheckboxIds] = useState([]); + const [selectAll, setSelectAll] = useState(false); + const [printQty, setPrintQty] = useState(1); + const [isSearching, setIsSearching] = useState(false); + + const [previewOpen, setPreviewOpen] = useState(false); + const [previewUrl, setPreviewUrl] = useState(null); + + const [selectedWarehousesModalOpen, setSelectedWarehousesModalOpen] = useState(false); + + const [searchInputs, setSearchInputs] = useState({ + store_id: "", + warehouse: "", + area: "", + slot: "", + }); + + const filteredPrinters = useMemo(() => { + return printerCombo.filter((printer) => { + return printer.type === "A4"; + }); + }, [printerCombo]); + + const [selectedPrinter, setSelectedPrinter] = useState( + filteredPrinters.length > 0 ? filteredPrinters[0] : undefined + ); + + useEffect(() => { + if (!selectedPrinter || !filteredPrinters.find(p => p.id === selectedPrinter.id)) { + setSelectedPrinter(filteredPrinters.length > 0 ? filteredPrinters[0] : undefined); + } + }, [filteredPrinters, selectedPrinter]); + + const handleReset = useCallback(() => { + setSearchInputs({ + store_id: "", + warehouse: "", + area: "", + slot: "", + }); + setFilteredWarehouses(warehouses); + setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); + }, [warehouses, pagingController.pageSize]); + + const handleSearch = useCallback(() => { + setIsSearching(true); + try { + let results: WarehouseResult[] = warehouses; + + const storeId = searchInputs.store_id?.trim() || ""; + const warehouse = searchInputs.warehouse?.trim() || ""; + const area = searchInputs.area?.trim() || ""; + const slot = searchInputs.slot?.trim() || ""; + + if (storeId || warehouse || area || slot) { + results = warehouses.filter((warehouseItem) => { + if (storeId || warehouse || area || slot) { + if (!warehouseItem.code) { + return false; + } + + const codeValue = String(warehouseItem.code).toLowerCase(); + + const codeParts = codeValue.split("-"); + + if (codeParts.length >= 4) { + const codeStoreId = codeParts[0] || ""; + const codeWarehouse = codeParts[1] || ""; + const codeArea = codeParts[2] || ""; + const codeSlot = codeParts[3] || ""; + + const storeIdMatch = !storeId || codeStoreId.includes(storeId.toLowerCase()); + const warehouseMatch = !warehouse || codeWarehouse.includes(warehouse.toLowerCase()); + const areaMatch = !area || codeArea.includes(area.toLowerCase()); + const slotMatch = !slot || codeSlot.includes(slot.toLowerCase()); + + return storeIdMatch && warehouseMatch && areaMatch && slotMatch; + } + + const storeIdMatch = !storeId || codeValue.includes(storeId.toLowerCase()); + const warehouseMatch = !warehouse || codeValue.includes(warehouse.toLowerCase()); + const areaMatch = !area || codeValue.includes(area.toLowerCase()); + const slotMatch = !slot || codeValue.includes(slot.toLowerCase()); + + return storeIdMatch && warehouseMatch && areaMatch && slotMatch; + } + + return true; + }); + } else { + results = warehouses; + } + + setFilteredWarehouses(results); + setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); + } catch (error) { + console.error("Error searching warehouses:", error); + const storeId = searchInputs.store_id?.trim().toLowerCase() || ""; + const warehouse = searchInputs.warehouse?.trim().toLowerCase() || ""; + const area = searchInputs.area?.trim().toLowerCase() || ""; + const slot = searchInputs.slot?.trim().toLowerCase() || ""; + + setFilteredWarehouses( + warehouses.filter((warehouseItem) => { + if (storeId || warehouse || area || slot) { + if (!warehouseItem.code) { + return false; + } + + const codeValue = String(warehouseItem.code).toLowerCase(); + const codeParts = codeValue.split("-"); + + if (codeParts.length >= 4) { + const storeIdMatch = !storeId || codeParts[0].includes(storeId); + const warehouseMatch = !warehouse || codeParts[1].includes(warehouse); + const areaMatch = !area || codeParts[2].includes(area); + const slotMatch = !slot || codeParts[3].includes(slot); + return storeIdMatch && warehouseMatch && areaMatch && slotMatch; + } + + return (!storeId || codeValue.includes(storeId)) && + (!warehouse || codeValue.includes(warehouse)) && + (!area || codeValue.includes(area)) && + (!slot || codeValue.includes(slot)); + } + + return true; + }) + ); + } finally { + setIsSearching(false); + } + }, [searchInputs, warehouses, pagingController.pageSize]); + + const handleSelectWarehouse = useCallback((warehouseId: number, checked: boolean) => { + if (checked) { + setCheckboxIds(prev => [...prev, warehouseId]); + } else { + setCheckboxIds(prev => prev.filter(id => id !== warehouseId)); + setSelectAll(false); + } + }, []); + + const handleSelectAll = useCallback((checked: boolean) => { + if (checked) { + setCheckboxIds(filteredWarehouses.map(warehouse => warehouse.id)); + setSelectAll(true); + } else { + setCheckboxIds([]); + setSelectAll(false); + } + }, [filteredWarehouses]); + + const showPdfPreview = useCallback(async (warehouseIds: number[]) => { + if (warehouseIds.length === 0) { + return; + } + try { + setIsUploading(true); + const response = await exportWarehouseQrCode(warehouseIds); + + const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + + setPreviewUrl(`${url}#toolbar=0`); + setPreviewOpen(true); + } catch (error) { + console.error("Error exporting QR code:", error); + } finally { + setIsUploading(false); + } + }, [setIsUploading]); + + const handleClosePreview = useCallback(() => { + setPreviewOpen(false); + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + setPreviewUrl(null); + } + }, [previewUrl]); + + const handleDownloadQrCode = useCallback(async (warehouseIds: number[]) => { + if (warehouseIds.length === 0) { + return; + } + try { + setIsUploading(true); + const response = await exportWarehouseQrCode(warehouseIds); + downloadFile(response.blobValue, response.filename); + setSelectedWarehousesModalOpen(false); + successDialog("二維碼已下載", t); + } catch (error) { + console.error("Error exporting QR code:", error); + } finally { + setIsUploading(false); + } + }, [setIsUploading, t]); + + const handlePrint = useCallback(async () => { + if (checkboxIds.length === 0) { + return; + } + try { + setIsUploading(true); + const response = await exportWarehouseQrCode(checkboxIds); + + const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + + const printWindow = window.open(url, '_blank'); + if (printWindow) { + printWindow.onload = () => { + for (let i = 0; i < printQty; i++) { + setTimeout(() => { + printWindow.print(); + }, i * 500); + } + }; + } + + setTimeout(() => { + URL.revokeObjectURL(url); + }, 1000); + setSelectedWarehousesModalOpen(false); + successDialog("二維碼已列印", t); + } catch (error) { + console.error("Error printing QR code:", error); + } finally { + setIsUploading(false); + } + }, [checkboxIds, printQty, setIsUploading, t]); + + const handleViewSelectedQrCodes = useCallback(() => { + if (checkboxIds.length === 0) { + return; + } + setSelectedWarehousesModalOpen(true); + }, [checkboxIds]); + + const selectedWarehouses = useMemo(() => { + return warehouses.filter(warehouse => checkboxIds.includes(warehouse.id)); + }, [warehouses, checkboxIds]); + + const handleCloseSelectedWarehousesModal = useCallback(() => { + setSelectedWarehousesModalOpen(false); + }, []); + + const columns = useMemo[]>( + () => [ + { + name: "id", + label: "", + sx: { width: "50px", minWidth: "50px" }, + renderCell: (params) => ( + handleSelectWarehouse(params.id, e.target.checked)} + onClick={(e) => e.stopPropagation()} + /> + ), + }, + { + name: "code", + label: t("code"), + align: "left", + headerAlign: "left", + sx: { width: "200px", minWidth: "200px" }, + }, + { + name: "store_id", + label: t("store_id"), + align: "left", + headerAlign: "left", + sx: { width: "150px", minWidth: "150px" }, + }, + { + name: "warehouse", + label: t("warehouse"), + align: "left", + headerAlign: "left", + sx: { width: "150px", minWidth: "150px" }, + }, + { + name: "area", + label: t("area"), + align: "left", + headerAlign: "left", + sx: { width: "150px", minWidth: "150px" }, + }, + { + name: "slot", + label: t("slot"), + align: "left", + headerAlign: "left", + sx: { width: "150px", minWidth: "150px" }, + }, + ], + [t, checkboxIds, handleSelectWarehouse], + ); + + return ( + <> + + + {t("Search Criteria")} + + + setSearchInputs((prev) => ({ ...prev, store_id: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + InputProps={{ + endAdornment: ( + F + ), + }} + /> + + - + + + setSearchInputs((prev) => ({ ...prev, warehouse: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + /> + + - + + + setSearchInputs((prev) => ({ ...prev, area: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + /> + + - + + + setSearchInputs((prev) => ({ ...prev, slot: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + /> + + + + + + + + + items={filteredWarehouses} + columns={columns} + pagingController={pagingController} + setPagingController={setPagingController} + totalCount={filteredWarehouses.length} + isAutoPaging={true} + /> + + + + + + + + + + 已選擇倉庫 ({selectedWarehouses.length}) + + + + + + + + + + + + + {t("code")} + + + {t("store_id")} + + + {t("warehouse")} + + + {t("area")} + + + {t("slot")} + + + + + {selectedWarehouses.length === 0 ? ( + + + 沒有選擇的倉庫 + + + ) : ( + selectedWarehouses.map((warehouse) => ( + + {warehouse.code || '-'} + {warehouse.store_id || '-'} + {warehouse.warehouse || '-'} + {warehouse.area || '-'} + {warehouse.slot || '-'} + + )) + )} + +
+
+
+ + + + + options={filteredPrinters} + value={selectedPrinter ?? null} + onChange={(event, value) => { + setSelectedPrinter(value ?? undefined); + }} + getOptionLabel={(option) => option.name || option.label || option.code || String(option.id)} + isOptionEqualToValue={(option, value) => option.id === value.id} + renderInput={(params) => ( + + )} + /> + { + const value = parseInt(e.target.value) || 1; + setPrintQty(Math.max(1, value)); + }} + inputProps={{ min: 1 }} + sx={{ width: 120 }} + /> + + + + +
+
+ + + + + + + + + + + {previewUrl && ( +