FPSMS-frontend
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 

477 lignes
18 KiB

  1. "use client";
  2. import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
  3. import {
  4. Box,
  5. Typography,
  6. FormControl,
  7. InputLabel,
  8. Select,
  9. MenuItem,
  10. Card,
  11. CardContent,
  12. Stack,
  13. Table,
  14. TableBody,
  15. TableCell,
  16. TableContainer,
  17. TableHead,
  18. TableRow,
  19. Paper,
  20. CircularProgress,
  21. Chip
  22. } from '@mui/material';
  23. import { useTranslation } from 'react-i18next';
  24. import dayjs from 'dayjs';
  25. import { fetchTruckScheduleDashboardClient, type TruckScheduleDashboardItem } from '@/app/api/do/client';
  26. import { formatDepartureTime, arrayToDayjs } from '@/app/utils/formatUtil';
  27. // Track completed items for hiding after 2 refresh cycles
  28. interface CompletedTracker {
  29. key: string;
  30. refreshCount: number;
  31. }
  32. // Data stored per date for instant switching
  33. interface DateData {
  34. today: TruckScheduleDashboardItem[];
  35. tomorrow: TruckScheduleDashboardItem[];
  36. dayAfterTomorrow: TruckScheduleDashboardItem[];
  37. }
  38. const TruckScheduleDashboard: React.FC = () => {
  39. const { t } = useTranslation("dashboard");
  40. const [selectedStore, setSelectedStore] = useState<string>("");
  41. const [selectedDate, setSelectedDate] = useState<string>("today");
  42. // Store data for all three dates for instant switching
  43. const [allData, setAllData] = useState<DateData>({ today: [], tomorrow: [], dayAfterTomorrow: [] });
  44. const [loading, setLoading] = useState<boolean>(true);
  45. // Initialize as null to avoid SSR/client hydration mismatch
  46. const [currentTime, setCurrentTime] = useState<dayjs.Dayjs | null>(null);
  47. const [isClient, setIsClient] = useState<boolean>(false);
  48. // Track completed items per date
  49. const completedTrackerRef = useRef<Map<string, Map<string, CompletedTracker>>>(new Map([
  50. ['today', new Map()],
  51. ['tomorrow', new Map()],
  52. ['dayAfterTomorrow', new Map()]
  53. ]));
  54. const refreshCountRef = useRef<Map<string, number>>(new Map([
  55. ['today', 0],
  56. ['tomorrow', 0],
  57. ['dayAfterTomorrow', 0]
  58. ]));
  59. // Get date label for display (e.g., "2026-01-17")
  60. const getDateLabel = (offset: number): string => {
  61. return dayjs().add(offset, 'day').format('YYYY-MM-DD');
  62. };
  63. // Get day offset based on date option
  64. const getDateOffset = (dateOption: string): number => {
  65. if (dateOption === "today") return 0;
  66. if (dateOption === "tomorrow") return 1;
  67. if (dateOption === "dayAfterTomorrow") return 2;
  68. return 0;
  69. };
  70. // Convert date option to YYYY-MM-DD format for API
  71. const getDateParam = (dateOption: string): string => {
  72. const offset = getDateOffset(dateOption);
  73. return dayjs().add(offset, 'day').format('YYYY-MM-DD');
  74. };
  75. // Set client flag and time on mount
  76. useEffect(() => {
  77. setIsClient(true);
  78. setCurrentTime(dayjs());
  79. }, []);
  80. // Format time from array or string to HH:mm
  81. const formatTime = (timeData: string | number[] | null): string => {
  82. if (!timeData) return '-';
  83. if (Array.isArray(timeData)) {
  84. if (timeData.length >= 2) {
  85. const hour = timeData[0] || 0;
  86. const minute = timeData[1] || 0;
  87. return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
  88. }
  89. return '-';
  90. }
  91. if (typeof timeData === 'string') {
  92. const parts = timeData.split(':');
  93. if (parts.length >= 2) {
  94. const hour = parseInt(parts[0], 10);
  95. const minute = parseInt(parts[1], 10);
  96. return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
  97. }
  98. }
  99. return '-';
  100. };
  101. // Format datetime from array or string
  102. const formatDateTime = (dateTimeData: string | number[] | null): string => {
  103. if (!dateTimeData) return '-';
  104. if (Array.isArray(dateTimeData)) {
  105. return arrayToDayjs(dateTimeData, true).format('HH:mm');
  106. }
  107. const parsed = dayjs(dateTimeData);
  108. if (parsed.isValid()) {
  109. return parsed.format('HH:mm');
  110. }
  111. return '-';
  112. };
  113. // Calculate time remaining for truck departure
  114. const calculateTimeRemaining = useCallback((departureTime: string | number[] | null, dateOption: string): string => {
  115. if (!departureTime || !currentTime) return '-';
  116. const now = currentTime;
  117. let departureHour: number;
  118. let departureMinute: number;
  119. if (Array.isArray(departureTime)) {
  120. if (departureTime.length < 2) return '-';
  121. departureHour = departureTime[0] || 0;
  122. departureMinute = departureTime[1] || 0;
  123. } else if (typeof departureTime === 'string') {
  124. const parts = departureTime.split(':');
  125. if (parts.length < 2) return '-';
  126. departureHour = parseInt(parts[0], 10);
  127. departureMinute = parseInt(parts[1], 10);
  128. } else {
  129. return '-';
  130. }
  131. // Create departure datetime for the selected date (today, tomorrow, or day after tomorrow)
  132. const dateOffset = getDateOffset(dateOption);
  133. const departure = now.clone().add(dateOffset, 'day').hour(departureHour).minute(departureMinute).second(0);
  134. const diffMinutes = departure.diff(now, 'minute');
  135. if (diffMinutes < 0) {
  136. // Past departure time
  137. const absDiff = Math.abs(diffMinutes);
  138. const hours = Math.floor(absDiff / 60);
  139. const minutes = absDiff % 60;
  140. return `-${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
  141. } else {
  142. const hours = Math.floor(diffMinutes / 60);
  143. const minutes = diffMinutes % 60;
  144. return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
  145. }
  146. }, [currentTime]);
  147. // Generate unique key for tracking completed items
  148. const getItemKey = (item: TruckScheduleDashboardItem): string => {
  149. return `${item.storeId}-${item.truckLanceCode}-${item.truckDepartureTime}`;
  150. };
  151. // Process data for a specific date option with completed tracker logic
  152. const processDataForDate = (result: TruckScheduleDashboardItem[], dateOption: string): TruckScheduleDashboardItem[] => {
  153. const tracker = completedTrackerRef.current.get(dateOption) || new Map();
  154. const currentRefresh = (refreshCountRef.current.get(dateOption) || 0) + 1;
  155. refreshCountRef.current.set(dateOption, currentRefresh);
  156. result.forEach(item => {
  157. const key = getItemKey(item);
  158. // If all tickets are completed, track it
  159. if (item.numberOfPickTickets > 0 && item.numberOfTicketsCompleted >= item.numberOfPickTickets) {
  160. const existing = tracker.get(key);
  161. if (!existing) {
  162. tracker.set(key, { key, refreshCount: currentRefresh });
  163. }
  164. } else {
  165. // Remove from tracker if no longer completed
  166. tracker.delete(key);
  167. }
  168. });
  169. completedTrackerRef.current.set(dateOption, tracker);
  170. // Filter out items that have been completed for 2+ refresh cycles
  171. return result.filter(item => {
  172. const key = getItemKey(item);
  173. const itemTracker = tracker.get(key);
  174. if (itemTracker) {
  175. // Hide if completed for 2 or more refresh cycles
  176. if (currentRefresh - itemTracker.refreshCount >= 2) {
  177. return false;
  178. }
  179. }
  180. return true;
  181. });
  182. };
  183. // Load data for all three dates in parallel for instant switching
  184. const loadData = useCallback(async (isInitialLoad: boolean = false) => {
  185. // Only show loading spinner on initial load, not during refresh
  186. if (isInitialLoad) {
  187. setLoading(true);
  188. }
  189. try {
  190. const dateOptions = ['today', 'tomorrow', 'dayAfterTomorrow'] as const;
  191. const dateParams = dateOptions.map(opt => getDateParam(opt));
  192. // Fetch all three dates in parallel
  193. const [todayResult, tomorrowResult, dayAfterResult] = await Promise.all([
  194. fetchTruckScheduleDashboardClient(dateParams[0]),
  195. fetchTruckScheduleDashboardClient(dateParams[1]),
  196. fetchTruckScheduleDashboardClient(dateParams[2])
  197. ]);
  198. // Process each date's data with completed tracker logic
  199. setAllData({
  200. today: processDataForDate(todayResult, 'today'),
  201. tomorrow: processDataForDate(tomorrowResult, 'tomorrow'),
  202. dayAfterTomorrow: processDataForDate(dayAfterResult, 'dayAfterTomorrow')
  203. });
  204. } catch (error) {
  205. console.error('Error fetching truck schedule dashboard:', error);
  206. } finally {
  207. if (isInitialLoad) {
  208. setLoading(false);
  209. }
  210. }
  211. }, []);
  212. // Initial load and auto-refresh every 5 minutes
  213. useEffect(() => {
  214. loadData(true); // Initial load - show spinner
  215. const refreshInterval = setInterval(() => {
  216. loadData(false); // Refresh - don't show spinner, keep existing data visible
  217. }, 5 * 60 * 1000); // 5 minutes
  218. return () => clearInterval(refreshInterval);
  219. }, [loadData]);
  220. // Update current time every 1 minute for time remaining calculation
  221. useEffect(() => {
  222. if (!isClient) return;
  223. const timeInterval = setInterval(() => {
  224. setCurrentTime(dayjs());
  225. }, 60 * 1000); // 1 minute
  226. return () => clearInterval(timeInterval);
  227. }, [isClient]);
  228. // Get data for selected date, then filter by store - both filters are instant
  229. const filteredData = useMemo(() => {
  230. // First get the data for the selected date
  231. const dateData = allData[selectedDate as keyof DateData] || [];
  232. // Then filter by store if selected
  233. if (!selectedStore) return dateData;
  234. return dateData.filter(item => item.storeId === selectedStore);
  235. }, [allData, selectedDate, selectedStore]);
  236. // Get chip color based on time remaining
  237. const getTimeChipColor = (departureTime: string | number[] | null, dateOption: string): "success" | "warning" | "error" | "default" => {
  238. if (!departureTime || !currentTime) return "default";
  239. const now = currentTime;
  240. let departureHour: number;
  241. let departureMinute: number;
  242. if (Array.isArray(departureTime)) {
  243. if (departureTime.length < 2) return "default";
  244. departureHour = departureTime[0] || 0;
  245. departureMinute = departureTime[1] || 0;
  246. } else if (typeof departureTime === 'string') {
  247. const parts = departureTime.split(':');
  248. if (parts.length < 2) return "default";
  249. departureHour = parseInt(parts[0], 10);
  250. departureMinute = parseInt(parts[1], 10);
  251. } else {
  252. return "default";
  253. }
  254. // Create departure datetime for the selected date (today, tomorrow, or day after tomorrow)
  255. const dateOffset = getDateOffset(dateOption);
  256. const departure = now.clone().add(dateOffset, 'day').hour(departureHour).minute(departureMinute).second(0);
  257. const diffMinutes = departure.diff(now, 'minute');
  258. if (diffMinutes < 0) return "error"; // Past due
  259. if (diffMinutes <= 30) return "warning"; // Within 30 minutes
  260. return "success"; // More than 30 minutes
  261. };
  262. return (
  263. <Card sx={{ mb: 2 }}>
  264. <CardContent>
  265. {/* Filter */}
  266. <Stack direction="row" spacing={2} sx={{ mb: 3 }}>
  267. <FormControl sx={{ minWidth: 150 }} size="small">
  268. <InputLabel id="store-select-label" shrink={true}>
  269. {t("Store ID")}
  270. </InputLabel>
  271. <Select
  272. labelId="store-select-label"
  273. id="store-select"
  274. value={selectedStore}
  275. label={t("Store ID")}
  276. onChange={(e) => setSelectedStore(e.target.value)}
  277. displayEmpty
  278. >
  279. <MenuItem value="">{t("All Stores")}</MenuItem>
  280. <MenuItem value="2/F">2/F</MenuItem>
  281. <MenuItem value="4/F">4/F</MenuItem>
  282. </Select>
  283. </FormControl>
  284. <FormControl sx={{ minWidth: 200 }} size="small">
  285. <InputLabel id="date-select-label" shrink={true}>
  286. {t("Select Date")}
  287. </InputLabel>
  288. <Select
  289. labelId="date-select-label"
  290. id="date-select"
  291. value={selectedDate}
  292. label={t("Select Date")}
  293. onChange={(e) => setSelectedDate(e.target.value)}
  294. >
  295. <MenuItem value="today">{t("Today")} ({getDateLabel(0)})</MenuItem>
  296. <MenuItem value="tomorrow">{t("Tomorrow")} ({getDateLabel(1)})</MenuItem>
  297. <MenuItem value="dayAfterTomorrow">{t("Day After Tomorrow")} ({getDateLabel(2)})</MenuItem>
  298. </Select>
  299. </FormControl>
  300. <Typography variant="body2" sx={{ alignSelf: 'center', color: 'text.secondary' }}>
  301. {t("Auto-refresh every 5 minutes")} | {t("Last updated")}: {isClient && currentTime ? currentTime.format('HH:mm:ss') : '--:--:--'}
  302. </Typography>
  303. </Stack>
  304. {/* Table */}
  305. <Box sx={{ mt: 2 }}>
  306. {loading ? (
  307. <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
  308. <CircularProgress />
  309. </Box>
  310. ) : (
  311. <TableContainer component={Paper}>
  312. <Table size="small" sx={{ minWidth: 1200 }}>
  313. <TableHead>
  314. <TableRow sx={{ backgroundColor: 'grey.100' }}>
  315. <TableCell sx={{ fontWeight: 600 }}>{t("Store ID")}</TableCell>
  316. <TableCell sx={{ fontWeight: 600 }}>{t("Truck Schedule")}</TableCell>
  317. <TableCell sx={{ fontWeight: 600 }}>{t("Time Remaining")}</TableCell>
  318. <TableCell sx={{ fontWeight: 600 }} align="center">{t("No. of Shops")}</TableCell>
  319. <TableCell sx={{ fontWeight: 600 }} align="center">{t("Total Items")}</TableCell>
  320. <TableCell sx={{ fontWeight: 600 }} align="center">{t("Tickets Released")}</TableCell>
  321. <TableCell sx={{ fontWeight: 600 }}>{t("First Ticket Start")}</TableCell>
  322. <TableCell sx={{ fontWeight: 600 }} align="center">{t("Tickets Completed")}</TableCell>
  323. <TableCell sx={{ fontWeight: 600 }}>{t("Last Ticket End")}</TableCell>
  324. <TableCell sx={{ fontWeight: 600 }} align="center">{t("Pick Time (min)")}</TableCell>
  325. </TableRow>
  326. </TableHead>
  327. <TableBody>
  328. {filteredData.length === 0 ? (
  329. <TableRow>
  330. <TableCell colSpan={10} align="center">
  331. <Typography variant="body2" color="text.secondary">
  332. {t("No truck schedules available")} ({getDateParam(selectedDate)})
  333. </Typography>
  334. </TableCell>
  335. </TableRow>
  336. ) : (
  337. filteredData.map((row, index) => {
  338. const timeRemaining = calculateTimeRemaining(row.truckDepartureTime, selectedDate);
  339. const chipColor = getTimeChipColor(row.truckDepartureTime, selectedDate);
  340. return (
  341. <TableRow
  342. key={`${row.storeId}-${row.truckLanceCode}-${index}`}
  343. sx={{
  344. '&:hover': { backgroundColor: 'grey.50' },
  345. backgroundColor: row.numberOfPickTickets > 0 && row.numberOfTicketsCompleted >= row.numberOfPickTickets
  346. ? 'success.light'
  347. : 'inherit'
  348. }}
  349. >
  350. <TableCell>
  351. <Chip
  352. label={row.storeId || '-'}
  353. size="small"
  354. color={row.storeId === '2/F' ? 'primary' : 'secondary'}
  355. />
  356. </TableCell>
  357. <TableCell>
  358. <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
  359. <Typography variant="body2" sx={{ fontWeight: 500 }}>
  360. {row.truckLanceCode || '-'}
  361. </Typography>
  362. <Typography variant="caption" color="text.secondary">
  363. ETD: {formatTime(row.truckDepartureTime)}
  364. </Typography>
  365. </Box>
  366. </TableCell>
  367. <TableCell>
  368. <Chip
  369. label={timeRemaining}
  370. size="small"
  371. color={chipColor}
  372. sx={{ fontWeight: 600 }}
  373. />
  374. </TableCell>
  375. <TableCell align="center">
  376. <Typography variant="body2">
  377. {row.numberOfShopsToServe} [{row.numberOfPickTickets}]
  378. </Typography>
  379. </TableCell>
  380. <TableCell align="center">
  381. <Typography variant="body2" sx={{ fontWeight: 500 }}>
  382. {row.totalItemsToPick}
  383. </Typography>
  384. </TableCell>
  385. <TableCell align="center">
  386. <Chip
  387. label={row.numberOfTicketsReleased}
  388. size="small"
  389. color={row.numberOfTicketsReleased > 0 ? 'info' : 'default'}
  390. />
  391. </TableCell>
  392. <TableCell>
  393. {formatDateTime(row.firstTicketStartTime)}
  394. </TableCell>
  395. <TableCell align="center">
  396. <Chip
  397. label={row.numberOfTicketsCompleted}
  398. size="small"
  399. color={row.numberOfTicketsCompleted > 0 ? 'success' : 'default'}
  400. />
  401. </TableCell>
  402. <TableCell>
  403. {formatDateTime(row.lastTicketEndTime)}
  404. </TableCell>
  405. <TableCell align="center">
  406. <Typography
  407. variant="body2"
  408. sx={{
  409. fontWeight: 500,
  410. color: row.pickTimeTakenMinutes !== null ? 'text.primary' : 'text.secondary'
  411. }}
  412. >
  413. {row.pickTimeTakenMinutes !== null ? row.pickTimeTakenMinutes : '-'}
  414. </Typography>
  415. </TableCell>
  416. </TableRow>
  417. );
  418. })
  419. )}
  420. </TableBody>
  421. </Table>
  422. </TableContainer>
  423. )}
  424. </Box>
  425. </CardContent>
  426. </Card>
  427. );
  428. };
  429. export default TruckScheduleDashboard;