|
- "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;
- }
-
- // Data stored per date for instant switching
- interface DateData {
- today: TruckScheduleDashboardItem[];
- tomorrow: TruckScheduleDashboardItem[];
- dayAfterTomorrow: TruckScheduleDashboardItem[];
- }
-
- const TruckScheduleDashboard: React.FC = () => {
- const { t } = useTranslation("dashboard");
- const [selectedStore, setSelectedStore] = useState<string>("");
- const [selectedDate, setSelectedDate] = useState<string>("today");
- // Store data for all three dates for instant switching
- const [allData, setAllData] = useState<DateData>({ today: [], tomorrow: [], dayAfterTomorrow: [] });
- 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);
- // Track completed items per date
- const completedTrackerRef = useRef<Map<string, Map<string, CompletedTracker>>>(new Map([
- ['today', new Map()],
- ['tomorrow', new Map()],
- ['dayAfterTomorrow', new Map()]
- ]));
- const refreshCountRef = useRef<Map<string, number>>(new Map([
- ['today', 0],
- ['tomorrow', 0],
- ['dayAfterTomorrow', 0]
- ]));
-
- // Get date label for display (e.g., "2026-01-17")
- const getDateLabel = (offset: number): string => {
- return dayjs().add(offset, 'day').format('YYYY-MM-DD');
- };
-
- // Get day offset based on date option
- const getDateOffset = (dateOption: string): number => {
- if (dateOption === "today") return 0;
- if (dateOption === "tomorrow") return 1;
- if (dateOption === "dayAfterTomorrow") return 2;
- return 0;
- };
-
- // Convert date option to YYYY-MM-DD format for API
- const getDateParam = (dateOption: string): string => {
- const offset = getDateOffset(dateOption);
- return dayjs().add(offset, 'day').format('YYYY-MM-DD');
- };
-
- // 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, dateOption: string): 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 the selected date (today, tomorrow, or day after tomorrow)
- const dateOffset = getDateOffset(dateOption);
- const departure = now.clone().add(dateOffset, 'day').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}`;
- };
-
- // Process data for a specific date option with completed tracker logic
- const processDataForDate = (result: TruckScheduleDashboardItem[], dateOption: string): TruckScheduleDashboardItem[] => {
- const tracker = completedTrackerRef.current.get(dateOption) || new Map();
- const currentRefresh = (refreshCountRef.current.get(dateOption) || 0) + 1;
- refreshCountRef.current.set(dateOption, currentRefresh);
-
- result.forEach(item => {
- const key = getItemKey(item);
- // If all tickets are completed, track it
- if (item.numberOfPickTickets > 0 && item.numberOfTicketsCompleted >= item.numberOfPickTickets) {
- const existing = tracker.get(key);
- if (!existing) {
- tracker.set(key, { key, refreshCount: currentRefresh });
- }
- } else {
- // Remove from tracker if no longer completed
- tracker.delete(key);
- }
- });
-
- completedTrackerRef.current.set(dateOption, tracker);
-
- // Filter out items that have been completed for 2+ refresh cycles
- return result.filter(item => {
- const key = getItemKey(item);
- const itemTracker = tracker.get(key);
- if (itemTracker) {
- // Hide if completed for 2 or more refresh cycles
- if (currentRefresh - itemTracker.refreshCount >= 2) {
- return false;
- }
- }
- return true;
- });
- };
-
- // Load data for all three dates in parallel for instant switching
- const loadData = useCallback(async (isInitialLoad: boolean = false) => {
- // Only show loading spinner on initial load, not during refresh
- if (isInitialLoad) {
- setLoading(true);
- }
- try {
- const dateOptions = ['today', 'tomorrow', 'dayAfterTomorrow'] as const;
- const dateParams = dateOptions.map(opt => getDateParam(opt));
-
- // Fetch all three dates in parallel
- const [todayResult, tomorrowResult, dayAfterResult] = await Promise.all([
- fetchTruckScheduleDashboardClient(dateParams[0]),
- fetchTruckScheduleDashboardClient(dateParams[1]),
- fetchTruckScheduleDashboardClient(dateParams[2])
- ]);
-
- // Process each date's data with completed tracker logic
- setAllData({
- today: processDataForDate(todayResult, 'today'),
- tomorrow: processDataForDate(tomorrowResult, 'tomorrow'),
- dayAfterTomorrow: processDataForDate(dayAfterResult, 'dayAfterTomorrow')
- });
- } catch (error) {
- console.error('Error fetching truck schedule dashboard:', error);
- } finally {
- if (isInitialLoad) {
- setLoading(false);
- }
- }
- }, []);
-
- // Initial load and auto-refresh every 5 minutes
- useEffect(() => {
- loadData(true); // Initial load - show spinner
-
- const refreshInterval = setInterval(() => {
- loadData(false); // Refresh - don't show spinner, keep existing data visible
- }, 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]);
-
- // Get data for selected date, then filter by store - both filters are instant
- const filteredData = useMemo(() => {
- // First get the data for the selected date
- const dateData = allData[selectedDate as keyof DateData] || [];
- // Then filter by store if selected
- if (!selectedStore) return dateData;
- return dateData.filter(item => item.storeId === selectedStore);
- }, [allData, selectedDate, selectedStore]);
-
- // Get chip color based on time remaining
- const getTimeChipColor = (departureTime: string | number[] | null, dateOption: string): "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";
- }
-
- // Create departure datetime for the selected date (today, tomorrow, or day after tomorrow)
- const dateOffset = getDateOffset(dateOption);
- const departure = now.clone().add(dateOffset, 'day').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>
- {/* 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>
-
- <FormControl sx={{ minWidth: 200 }} size="small">
- <InputLabel id="date-select-label" shrink={true}>
- {t("Select Date")}
- </InputLabel>
- <Select
- labelId="date-select-label"
- id="date-select"
- value={selectedDate}
- label={t("Select Date")}
- onChange={(e) => setSelectedDate(e.target.value)}
- >
- <MenuItem value="today">{t("Today")} ({getDateLabel(0)})</MenuItem>
- <MenuItem value="tomorrow">{t("Tomorrow")} ({getDateLabel(1)})</MenuItem>
- <MenuItem value="dayAfterTomorrow">{t("Day After Tomorrow")} ({getDateLabel(2)})</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")} ({getDateParam(selectedDate)})
- </Typography>
- </TableCell>
- </TableRow>
- ) : (
- filteredData.map((row, index) => {
- const timeRemaining = calculateTimeRemaining(row.truckDepartureTime, selectedDate);
- const chipColor = getTimeChipColor(row.truckDepartureTime, selectedDate);
-
- 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;
|