|
|
|
@@ -0,0 +1,397 @@ |
|
|
|
"use client"; |
|
|
|
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; |
|
|
|
import { |
|
|
|
Box, |
|
|
|
Typography, |
|
|
|
FormControl, |
|
|
|
InputLabel, |
|
|
|
Select, |
|
|
|
MenuItem, |
|
|
|
Card, |
|
|
|
CardContent, |
|
|
|
Stack, |
|
|
|
Table, |
|
|
|
TableBody, |
|
|
|
TableCell, |
|
|
|
TableContainer, |
|
|
|
TableHead, |
|
|
|
TableRow, |
|
|
|
Paper, |
|
|
|
CircularProgress, |
|
|
|
Chip |
|
|
|
} from '@mui/material'; |
|
|
|
import { useTranslation } from 'react-i18next'; |
|
|
|
import dayjs from 'dayjs'; |
|
|
|
import { fetchTruckScheduleDashboardClient, type TruckScheduleDashboardItem } from '@/app/api/do/client'; |
|
|
|
import { formatDepartureTime, arrayToDayjs } from '@/app/utils/formatUtil'; |
|
|
|
|
|
|
|
// Track completed items for hiding after 2 refresh cycles |
|
|
|
interface CompletedTracker { |
|
|
|
key: string; |
|
|
|
refreshCount: number; |
|
|
|
} |
|
|
|
|
|
|
|
const TruckScheduleDashboard: React.FC = () => { |
|
|
|
const { t } = useTranslation("dashboard"); |
|
|
|
const [selectedStore, setSelectedStore] = useState<string>(""); |
|
|
|
const [data, setData] = useState<TruckScheduleDashboardItem[]>([]); |
|
|
|
const [loading, setLoading] = useState<boolean>(true); |
|
|
|
// Initialize as null to avoid SSR/client hydration mismatch |
|
|
|
const [currentTime, setCurrentTime] = useState<dayjs.Dayjs | null>(null); |
|
|
|
const [isClient, setIsClient] = useState<boolean>(false); |
|
|
|
const completedTrackerRef = useRef<Map<string, CompletedTracker>>(new Map()); |
|
|
|
const refreshCountRef = useRef<number>(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 ( |
|
|
|
<Card sx={{ mb: 2 }}> |
|
|
|
<CardContent> |
|
|
|
{/* Title */} |
|
|
|
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}> |
|
|
|
{t("Truck Schedule Dashboard")} |
|
|
|
</Typography> |
|
|
|
|
|
|
|
{/* Filter */} |
|
|
|
<Stack direction="row" spacing={2} sx={{ mb: 3 }}> |
|
|
|
<FormControl sx={{ minWidth: 150 }} size="small"> |
|
|
|
<InputLabel id="store-select-label" shrink={true}> |
|
|
|
{t("Store ID")} |
|
|
|
</InputLabel> |
|
|
|
<Select |
|
|
|
labelId="store-select-label" |
|
|
|
id="store-select" |
|
|
|
value={selectedStore} |
|
|
|
label={t("Store ID")} |
|
|
|
onChange={(e) => setSelectedStore(e.target.value)} |
|
|
|
displayEmpty |
|
|
|
> |
|
|
|
<MenuItem value="">{t("All Stores")}</MenuItem> |
|
|
|
<MenuItem value="2/F">2/F</MenuItem> |
|
|
|
<MenuItem value="4/F">4/F</MenuItem> |
|
|
|
</Select> |
|
|
|
</FormControl> |
|
|
|
|
|
|
|
<Typography variant="body2" sx={{ alignSelf: 'center', color: 'text.secondary' }}> |
|
|
|
{t("Auto-refresh every 5 minutes")} | {t("Last updated")}: {isClient && currentTime ? currentTime.format('HH:mm:ss') : '--:--:--'} |
|
|
|
</Typography> |
|
|
|
</Stack> |
|
|
|
|
|
|
|
{/* Table */} |
|
|
|
<Box sx={{ mt: 2 }}> |
|
|
|
{loading ? ( |
|
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}> |
|
|
|
<CircularProgress /> |
|
|
|
</Box> |
|
|
|
) : ( |
|
|
|
<TableContainer component={Paper}> |
|
|
|
<Table size="small" sx={{ minWidth: 1200 }}> |
|
|
|
<TableHead> |
|
|
|
<TableRow sx={{ backgroundColor: 'grey.100' }}> |
|
|
|
<TableCell sx={{ fontWeight: 600 }}>{t("Store ID")}</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 600 }}>{t("Truck Schedule")}</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 600 }}>{t("Time Remaining")}</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 600 }} align="center">{t("No. of Shops")}</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 600 }} align="center">{t("Total Items")}</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 600 }} align="center">{t("Tickets Released")}</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 600 }}>{t("First Ticket Start")}</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 600 }} align="center">{t("Tickets Completed")}</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 600 }}>{t("Last Ticket End")}</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 600 }} align="center">{t("Pick Time (min)")}</TableCell> |
|
|
|
</TableRow> |
|
|
|
</TableHead> |
|
|
|
<TableBody> |
|
|
|
{filteredData.length === 0 ? ( |
|
|
|
<TableRow> |
|
|
|
<TableCell colSpan={10} align="center"> |
|
|
|
<Typography variant="body2" color="text.secondary"> |
|
|
|
{t("No truck schedules available for today")} |
|
|
|
</Typography> |
|
|
|
</TableCell> |
|
|
|
</TableRow> |
|
|
|
) : ( |
|
|
|
filteredData.map((row, index) => { |
|
|
|
const timeRemaining = calculateTimeRemaining(row.truckDepartureTime); |
|
|
|
const chipColor = getTimeChipColor(row.truckDepartureTime); |
|
|
|
|
|
|
|
return ( |
|
|
|
<TableRow |
|
|
|
key={`${row.storeId}-${row.truckLanceCode}-${index}`} |
|
|
|
sx={{ |
|
|
|
'&:hover': { backgroundColor: 'grey.50' }, |
|
|
|
backgroundColor: row.numberOfPickTickets > 0 && row.numberOfTicketsCompleted >= row.numberOfPickTickets |
|
|
|
? 'success.light' |
|
|
|
: 'inherit' |
|
|
|
}} |
|
|
|
> |
|
|
|
<TableCell> |
|
|
|
<Chip |
|
|
|
label={row.storeId || '-'} |
|
|
|
size="small" |
|
|
|
color={row.storeId === '2/F' ? 'primary' : 'secondary'} |
|
|
|
/> |
|
|
|
</TableCell> |
|
|
|
<TableCell> |
|
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> |
|
|
|
<Typography variant="body2" sx={{ fontWeight: 500 }}> |
|
|
|
{row.truckLanceCode || '-'} |
|
|
|
</Typography> |
|
|
|
<Typography variant="caption" color="text.secondary"> |
|
|
|
ETD: {formatTime(row.truckDepartureTime)} |
|
|
|
</Typography> |
|
|
|
</Box> |
|
|
|
</TableCell> |
|
|
|
<TableCell> |
|
|
|
<Chip |
|
|
|
label={timeRemaining} |
|
|
|
size="small" |
|
|
|
color={chipColor} |
|
|
|
sx={{ fontWeight: 600 }} |
|
|
|
/> |
|
|
|
</TableCell> |
|
|
|
<TableCell align="center"> |
|
|
|
<Typography variant="body2"> |
|
|
|
{row.numberOfShopsToServe} [{row.numberOfPickTickets}] |
|
|
|
</Typography> |
|
|
|
</TableCell> |
|
|
|
<TableCell align="center"> |
|
|
|
<Typography variant="body2" sx={{ fontWeight: 500 }}> |
|
|
|
{row.totalItemsToPick} |
|
|
|
</Typography> |
|
|
|
</TableCell> |
|
|
|
<TableCell align="center"> |
|
|
|
<Chip |
|
|
|
label={row.numberOfTicketsReleased} |
|
|
|
size="small" |
|
|
|
color={row.numberOfTicketsReleased > 0 ? 'info' : 'default'} |
|
|
|
/> |
|
|
|
</TableCell> |
|
|
|
<TableCell> |
|
|
|
{formatDateTime(row.firstTicketStartTime)} |
|
|
|
</TableCell> |
|
|
|
<TableCell align="center"> |
|
|
|
<Chip |
|
|
|
label={row.numberOfTicketsCompleted} |
|
|
|
size="small" |
|
|
|
color={row.numberOfTicketsCompleted > 0 ? 'success' : 'default'} |
|
|
|
/> |
|
|
|
</TableCell> |
|
|
|
<TableCell> |
|
|
|
{formatDateTime(row.lastTicketEndTime)} |
|
|
|
</TableCell> |
|
|
|
<TableCell align="center"> |
|
|
|
<Typography |
|
|
|
variant="body2" |
|
|
|
sx={{ |
|
|
|
fontWeight: 500, |
|
|
|
color: row.pickTimeTakenMinutes !== null ? 'text.primary' : 'text.secondary' |
|
|
|
}} |
|
|
|
> |
|
|
|
{row.pickTimeTakenMinutes !== null ? row.pickTimeTakenMinutes : '-'} |
|
|
|
</Typography> |
|
|
|
</TableCell> |
|
|
|
</TableRow> |
|
|
|
); |
|
|
|
}) |
|
|
|
)} |
|
|
|
</TableBody> |
|
|
|
</Table> |
|
|
|
</TableContainer> |
|
|
|
)} |
|
|
|
</Box> |
|
|
|
</CardContent> |
|
|
|
</Card> |
|
|
|
); |
|
|
|
}; |
|
|
|
|
|
|
|
export default TruckScheduleDashboard; |