From 1a329b179775599dd1dcac7ce4e2eeec2bccf4f0 Mon Sep 17 00:00:00 2001 From: "Tommy\\2Fi-Staff" Date: Tue, 13 Jan 2026 11:07:31 +0800 Subject: [PATCH 1/3] Supporting Function FG Pick Status Dashboard --- src/app/api/do/actions.tsx | 24 ++ src/app/api/do/client.ts | 16 + .../DashboardPage/DashboardPage.tsx | 8 + .../truckSchedule/TruckScheduleDashboard.tsx | 397 ++++++++++++++++++ .../DashboardPage/truckSchedule/index.ts | 3 + src/i18n/en/dashboard.json | 77 +++- src/i18n/zh/dashboard.json | 17 +- 7 files changed, 540 insertions(+), 2 deletions(-) create mode 100644 src/app/api/do/client.ts create mode 100644 src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx create mode 100644 src/components/DashboardPage/truckSchedule/index.ts diff --git a/src/app/api/do/actions.tsx b/src/app/api/do/actions.tsx index 5007a80..ff20f0a 100644 --- a/src/app/api/do/actions.tsx +++ b/src/app/api/do/actions.tsx @@ -131,6 +131,21 @@ export interface getTicketReleaseTable { handlerName: string | null; numberOfFGItems: number; } + +export interface TruckScheduleDashboardItem { + storeId: string | null; + truckId: number | null; + truckLanceCode: string | null; + truckDepartureTime: string | number[] | null; + numberOfShopsToServe: number; + numberOfPickTickets: number; + totalItemsToPick: number; + numberOfTicketsReleased: number; + firstTicketStartTime: string | number[] | null; + numberOfTicketsCompleted: number; + lastTicketEndTime: string | number[] | null; + pickTimeTakenMinutes: number | null; +} export interface SearchDeliveryOrderInfoRequest { code: string; shopName: string; @@ -181,6 +196,15 @@ export const fetchTicketReleaseTable = cache(async (startDate: string, endDate: } ); }); + +export const fetchTruckScheduleDashboard = cache(async () => { + return await serverFetchJson( + `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`, + { + method: "GET", + } + ); +}); export const startBatchReleaseAsyncSingle = cache(async (data: { doId: number; userId: number }) => { const { doId, userId } = data; return await serverFetchJson<{ id: number|null; code: string; entity?: any }>( diff --git a/src/app/api/do/client.ts b/src/app/api/do/client.ts new file mode 100644 index 0000000..8adddde --- /dev/null +++ b/src/app/api/do/client.ts @@ -0,0 +1,16 @@ +"use client"; + +import { + fetchTruckScheduleDashboard, + type TruckScheduleDashboardItem +} from "./actions"; + +export const fetchTruckScheduleDashboardClient = async (): Promise => { + return await fetchTruckScheduleDashboard(); +}; + +export type { TruckScheduleDashboardItem }; + +export default fetchTruckScheduleDashboardClient; + + diff --git a/src/components/DashboardPage/DashboardPage.tsx b/src/components/DashboardPage/DashboardPage.tsx index f2d0dad..d417a8a 100644 --- a/src/components/DashboardPage/DashboardPage.tsx +++ b/src/components/DashboardPage/DashboardPage.tsx @@ -17,6 +17,7 @@ import CollapsibleCard from "../CollapsibleCard"; // import SupervisorQcApproval, { IQCItems } from "./QC/SupervisorQcApproval"; import { EscalationResult } from "@/app/api/escalation"; import EscalationLogTable from "./escalation/EscalationLogTable"; +import { TruckScheduleDashboard } from "./truckSchedule"; type Props = { // iqc: IQCItems[] | undefined escalationLogs: EscalationResult[] @@ -42,6 +43,13 @@ const DashboardPage: React.FC = ({ return ( + + + + + + + { + const { t } = useTranslation("dashboard"); + const [selectedStore, setSelectedStore] = useState(""); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + // Initialize as null to avoid SSR/client hydration mismatch + const [currentTime, setCurrentTime] = useState(null); + const [isClient, setIsClient] = useState(false); + const completedTrackerRef = useRef>(new Map()); + const refreshCountRef = useRef(0); + + // Set client flag and time on mount + useEffect(() => { + setIsClient(true); + setCurrentTime(dayjs()); + }, []); + + // Format time from array or string to HH:mm + const formatTime = (timeData: string | number[] | null): string => { + if (!timeData) return '-'; + + if (Array.isArray(timeData)) { + if (timeData.length >= 2) { + const hour = timeData[0] || 0; + const minute = timeData[1] || 0; + return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; + } + return '-'; + } + + if (typeof timeData === 'string') { + const parts = timeData.split(':'); + if (parts.length >= 2) { + const hour = parseInt(parts[0], 10); + const minute = parseInt(parts[1], 10); + return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; + } + } + + return '-'; + }; + + // Format datetime from array or string + const formatDateTime = (dateTimeData: string | number[] | null): string => { + if (!dateTimeData) return '-'; + + if (Array.isArray(dateTimeData)) { + return arrayToDayjs(dateTimeData, true).format('HH:mm'); + } + + const parsed = dayjs(dateTimeData); + if (parsed.isValid()) { + return parsed.format('HH:mm'); + } + + return '-'; + }; + + // Calculate time remaining for truck departure + const calculateTimeRemaining = useCallback((departureTime: string | number[] | null): string => { + if (!departureTime || !currentTime) return '-'; + + const now = currentTime; + let departureHour: number; + let departureMinute: number; + + if (Array.isArray(departureTime)) { + if (departureTime.length < 2) return '-'; + departureHour = departureTime[0] || 0; + departureMinute = departureTime[1] || 0; + } else if (typeof departureTime === 'string') { + const parts = departureTime.split(':'); + if (parts.length < 2) return '-'; + departureHour = parseInt(parts[0], 10); + departureMinute = parseInt(parts[1], 10); + } else { + return '-'; + } + + // Create departure datetime for today + const departure = now.clone().hour(departureHour).minute(departureMinute).second(0); + const diffMinutes = departure.diff(now, 'minute'); + + if (diffMinutes < 0) { + // Past departure time + const absDiff = Math.abs(diffMinutes); + const hours = Math.floor(absDiff / 60); + const minutes = absDiff % 60; + return `-${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + } else { + const hours = Math.floor(diffMinutes / 60); + const minutes = diffMinutes % 60; + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + } + }, [currentTime]); + + // Generate unique key for tracking completed items + const getItemKey = (item: TruckScheduleDashboardItem): string => { + return `${item.storeId}-${item.truckLanceCode}-${item.truckDepartureTime}`; + }; + + // Load data from API + const loadData = useCallback(async () => { + try { + const result = await fetchTruckScheduleDashboardClient(); + + // Update completed tracker + refreshCountRef.current += 1; + const currentRefresh = refreshCountRef.current; + + result.forEach(item => { + const key = getItemKey(item); + // If all tickets are completed, track it + if (item.numberOfPickTickets > 0 && item.numberOfTicketsCompleted >= item.numberOfPickTickets) { + const existing = completedTrackerRef.current.get(key); + if (!existing) { + completedTrackerRef.current.set(key, { key, refreshCount: currentRefresh }); + } + } else { + // Remove from tracker if no longer completed + completedTrackerRef.current.delete(key); + } + }); + + // Filter out items that have been completed for 2+ refresh cycles + const filteredResult = result.filter(item => { + const key = getItemKey(item); + const tracker = completedTrackerRef.current.get(key); + if (tracker) { + // Hide if completed for 2 or more refresh cycles + if (currentRefresh - tracker.refreshCount >= 2) { + return false; + } + } + return true; + }); + + setData(filteredResult); + } catch (error) { + console.error('Error fetching truck schedule dashboard:', error); + } finally { + setLoading(false); + } + }, []); + + // Initial load and auto-refresh every 5 minutes + useEffect(() => { + loadData(); + + const refreshInterval = setInterval(() => { + loadData(); + }, 5 * 60 * 1000); // 5 minutes + + return () => clearInterval(refreshInterval); + }, [loadData]); + + // Update current time every 1 minute for time remaining calculation + useEffect(() => { + if (!isClient) return; + + const timeInterval = setInterval(() => { + setCurrentTime(dayjs()); + }, 60 * 1000); // 1 minute + + return () => clearInterval(timeInterval); + }, [isClient]); + + // Filter data by selected store + const filteredData = useMemo(() => { + if (!selectedStore) return data; + return data.filter(item => item.storeId === selectedStore); + }, [data, selectedStore]); + + // Get chip color based on time remaining + const getTimeChipColor = (departureTime: string | number[] | null): "success" | "warning" | "error" | "default" => { + if (!departureTime || !currentTime) return "default"; + + const now = currentTime; + let departureHour: number; + let departureMinute: number; + + if (Array.isArray(departureTime)) { + if (departureTime.length < 2) return "default"; + departureHour = departureTime[0] || 0; + departureMinute = departureTime[1] || 0; + } else if (typeof departureTime === 'string') { + const parts = departureTime.split(':'); + if (parts.length < 2) return "default"; + departureHour = parseInt(parts[0], 10); + departureMinute = parseInt(parts[1], 10); + } else { + return "default"; + } + + const departure = now.clone().hour(departureHour).minute(departureMinute).second(0); + const diffMinutes = departure.diff(now, 'minute'); + + if (diffMinutes < 0) return "error"; // Past due + if (diffMinutes <= 30) return "warning"; // Within 30 minutes + return "success"; // More than 30 minutes + }; + + return ( + + + {/* Title */} + + {t("Truck Schedule Dashboard")} + + + {/* Filter */} + + + + {t("Store ID")} + + + + + + {t("Auto-refresh every 5 minutes")} | {t("Last updated")}: {isClient && currentTime ? currentTime.format('HH:mm:ss') : '--:--:--'} + + + + {/* Table */} + + {loading ? ( + + + + ) : ( + + + + + {t("Store ID")} + {t("Truck Schedule")} + {t("Time Remaining")} + {t("No. of Shops")} + {t("Total Items")} + {t("Tickets Released")} + {t("First Ticket Start")} + {t("Tickets Completed")} + {t("Last Ticket End")} + {t("Pick Time (min)")} + + + + {filteredData.length === 0 ? ( + + + + {t("No truck schedules available for today")} + + + + ) : ( + filteredData.map((row, index) => { + const timeRemaining = calculateTimeRemaining(row.truckDepartureTime); + const chipColor = getTimeChipColor(row.truckDepartureTime); + + return ( + 0 && row.numberOfTicketsCompleted >= row.numberOfPickTickets + ? 'success.light' + : 'inherit' + }} + > + + + + + + + {row.truckLanceCode || '-'} + + + ETD: {formatTime(row.truckDepartureTime)} + + + + + + + + + {row.numberOfShopsToServe} [{row.numberOfPickTickets}] + + + + + {row.totalItemsToPick} + + + + 0 ? 'info' : 'default'} + /> + + + {formatDateTime(row.firstTicketStartTime)} + + + 0 ? 'success' : 'default'} + /> + + + {formatDateTime(row.lastTicketEndTime)} + + + + {row.pickTimeTakenMinutes !== null ? row.pickTimeTakenMinutes : '-'} + + + + ); + }) + )} + +
+
+ )} +
+
+
+ ); +}; + +export default TruckScheduleDashboard; diff --git a/src/components/DashboardPage/truckSchedule/index.ts b/src/components/DashboardPage/truckSchedule/index.ts new file mode 100644 index 0000000..b3609a6 --- /dev/null +++ b/src/components/DashboardPage/truckSchedule/index.ts @@ -0,0 +1,3 @@ +export { default as TruckScheduleDashboard } from './TruckScheduleDashboard'; + + diff --git a/src/i18n/en/dashboard.json b/src/i18n/en/dashboard.json index 9e26dfe..a6d2c2b 100644 --- a/src/i18n/en/dashboard.json +++ b/src/i18n/en/dashboard.json @@ -1 +1,76 @@ -{} \ No newline at end of file +{ + "Dashboard": "Dashboard", + "Order status": "Order status", + "pending": "pending", + "receiving": "receiving", + "total": "total", + "Warehouse temperature record": "Warehouse temperature record", + "Warehouse type": "Warehouse type", + "Last 6 hours": "Last 6 hours", + "Add some entries!": "Add some entries!", + "Last 24 hours": "Last 24 hours", + "Cold storage": "Cold storage", + "Normal temperature storage": "Normal temperature storage", + "Temperature status": "Temperature status", + "Humidity status": "Humidity status", + "Warehouse status": "Warehouse status", + "Progress chart": "Progress chart", + "Purchase Order Code": "Purchase Order Code", + "Item Name": "Item Name", + "Escalation Level": "Escalation Level", + "Reason": "Reason", + "escalated date": "escalated date", + "Order completion": "Order completion", + "Store Management": "Store Management", + "Consumable": "Consumable", + "Shipment": "Shipment", + "Extracted order": "Extracted order", + "Pending order": "Pending order", + "Temperature": "Temperature", + "Humidity": "Humidity", + "Pending storage": "Pending storage", + "Total storage": "Total storage", + "Application completion": "Application completion", + "Processed application": "Processed application", + "Pending application": "Pending application", + "pending inspection material": "pending inspection material", + "rejected": "rejected", + "accepted": "accepted", + "escalated": "escalated", + "inspected material": "inspected material", + "total material": "total material", + "stock in escalation list": "stock in escalation list", + "Responsible for handling colleagues": "Responsible for handling colleagues", + "Completed QC Total": "Completed QC Total", + "QC Fail Count": "QC Fail Count", + "DN Date": "DN Date", + "Received Qty": "Received Qty", + "Po Code": "Po Code", + "My Escalation List": "My Escalation List", + "Escalation List": "Escalation List", + "Purchase UoM": "Purchase UoM", + "QC Completed Count": "QC Completed Count", + "QC Fail-Total Count": "QC Fail-Total Count", + "escalationStatus": "escalationStatus", + "escalated datetime": "escalated datetime", + "escalateFrom": "escalateFrom", + "No": "No", + "Responsible Escalation List": "Responsible Escalation List", + "show completed logs": "show completed logs", + "Rows per page": "Rows per page", + "Truck Schedule Dashboard": "Truck Schedule Dashboard", + "Store ID": "Store ID", + "All Stores": "All Stores", + "Auto-refresh every 5 minutes": "Auto-refresh every 5 minutes", + "Last updated": "Last updated", + "Truck Schedule": "Truck Schedule", + "Time Remaining": "Time Remaining", + "No. of Shops": "No. of Shops", + "Total Items": "Total Items", + "Tickets Released": "Tickets Released", + "First Ticket Start": "First Ticket Start", + "Tickets Completed": "Tickets Completed", + "Last Ticket End": "Last Ticket End", + "Pick Time (min)": "Pick Time (min)", + "No truck schedules available for today": "No truck schedules available for today" +} diff --git a/src/i18n/zh/dashboard.json b/src/i18n/zh/dashboard.json index 3f32c02..9464706 100644 --- a/src/i18n/zh/dashboard.json +++ b/src/i18n/zh/dashboard.json @@ -57,5 +57,20 @@ "No": "無", "Responsible Escalation List": "負責的上報列表", "show completed logs": "顯示已完成上報", - "Rows per page": "每頁行數" + "Rows per page": "每頁行數", + "Truck Schedule Dashboard": "車輛調度儀表板", + "Store ID": "樓層", + "All Stores": "所有樓層", + "Auto-refresh every 5 minutes": "每5分鐘自動刷新", + "Last updated": "最後更新", + "Truck Schedule": "車輛班次", + "Time Remaining": "剩餘時間", + "No. of Shops": "門店數量", + "Total Items": "總貨品數", + "Tickets Released": "已發放成品出倉單", + "First Ticket Start": "首單開始時間", + "Tickets Completed": "已完成成品出倉單", + "Last Ticket End": "末單結束時間", + "Pick Time (min)": "揀貨時間(分鐘)", + "No truck schedules available for today": "今日無車輛調度計劃" } From 8c7aa31cdf1f13f5212a5afabeed8e08874b2936 Mon Sep 17 00:00:00 2001 From: "Tommy\\2Fi-Staff" Date: Tue, 13 Jan 2026 11:59:48 +0800 Subject: [PATCH 2/3] TrucklaneDetail add shop branches --- src/components/CreateItem/CreateItem.tsx | 3 +-- src/components/Shop/TruckLaneDetail.tsx | 24 ++++++++++++++++++++++-- src/i18n/en/common.json | 3 +++ src/i18n/zh/common.json | 1 + 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/components/CreateItem/CreateItem.tsx b/src/components/CreateItem/CreateItem.tsx index 69b8e9e..f4fd8e2 100644 --- a/src/components/CreateItem/CreateItem.tsx +++ b/src/components/CreateItem/CreateItem.tsx @@ -159,9 +159,8 @@ const CreateItem: React.FC = ({ console.log(qcCheck); // return // do api - console.log("asdad"); const responseI = await saveItem(data); - console.log("asdad"); + const responseQ = await saveItemQcChecks(qcCheck); if (responseI && responseQ) { if (!Boolean(responseI.id)) { diff --git a/src/components/Shop/TruckLaneDetail.tsx b/src/components/Shop/TruckLaneDetail.tsx index 21b5536..e8eef0f 100644 --- a/src/components/Shop/TruckLaneDetail.tsx +++ b/src/components/Shop/TruckLaneDetail.tsx @@ -69,6 +69,7 @@ const TruckLaneDetail: React.FC = () => { const [uniqueRemarks, setUniqueRemarks] = useState([]); const [uniqueShopCodes, setUniqueShopCodes] = useState([]); const [uniqueShopNames, setUniqueShopNames] = useState([]); + const [shopNameByCodeMap, setShopNameByCodeMap] = useState>(new Map()); const [addShopDialogOpen, setAddShopDialogOpen] = useState(false); const [newShop, setNewShop] = useState({ shopName: "", @@ -86,11 +87,12 @@ const TruckLaneDetail: React.FC = () => { useEffect(() => { const fetchAutocompleteData = async () => { try { - const [shopData, remarks, codes, names] = await Promise.all([ + const [shopData, remarks, codes, names, allShopsFromShopTable] = await Promise.all([ findAllUniqueShopNamesAndCodesFromTrucksClient() as Promise>, findAllUniqueRemarksFromTrucksClient() as Promise, findAllUniqueShopCodesFromTrucksClient() as Promise, findAllUniqueShopNamesFromTrucksClient() as Promise, + fetchAllShopsClient() as Promise, ]); // Convert to Shop format (id will be 0 since we don't have shop IDs from truck table) @@ -105,6 +107,15 @@ const TruckLaneDetail: React.FC = () => { setUniqueRemarks(remarks || []); setUniqueShopCodes(codes || []); setUniqueShopNames(names || []); + + // Create lookup map: shopCode -> shopName from shop table + const shopNameMap = new Map(); + (allShopsFromShopTable || []).forEach((shop) => { + if (shop.code) { + shopNameMap.set(String(shop.code).trim().toLowerCase(), String(shop.name || "").trim()); + } + }); + setShopNameByCodeMap(shopNameMap); } catch (err) { console.error("Failed to load autocomplete data:", err); } @@ -700,6 +711,7 @@ const TruckLaneDetail: React.FC = () => { {t("Shop Name")} + {t("Shop Branch")} {t("Shop Code")} {t("Remark")} {t("Loading Sequence")} @@ -709,7 +721,7 @@ const TruckLaneDetail: React.FC = () => { {shopsData.length === 0 ? ( - + {t("No shops found using this truck lane")} @@ -719,6 +731,14 @@ const TruckLaneDetail: React.FC = () => { shopsData.map((shop, index) => ( + {/* Shop Name from shop table (read-only, looked up by shop code) */} + {(() => { + const shopCode = String(shop.code || "").trim().toLowerCase(); + return shopNameByCodeMap.get(shopCode) || "-"; + })()} + + + {/* Shop Branch from truck table (editable) */} {editingRowIndex === index ? ( Date: Tue, 13 Jan 2026 15:01:41 +0800 Subject: [PATCH 3/3] 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 && ( +