From 1a329b179775599dd1dcac7ce4e2eeec2bccf4f0 Mon Sep 17 00:00:00 2001 From: "Tommy\\2Fi-Staff" Date: Tue, 13 Jan 2026 11:07:31 +0800 Subject: [PATCH] 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": "今日無車輛調度計劃" }