FPSMS-frontend
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 

425 řádky
15 KiB

  1. "use client";
  2. import React, { useState, useEffect, useCallback } from "react";
  3. import {
  4. Box,
  5. Card,
  6. CardContent,
  7. CircularProgress,
  8. Table,
  9. TableBody,
  10. TableCell,
  11. TableContainer,
  12. TableHead,
  13. TableRow,
  14. Paper,
  15. Typography,
  16. Tabs,
  17. Tab,
  18. Chip,
  19. Stack
  20. } from "@mui/material";
  21. import { useTranslation } from "react-i18next";
  22. import dayjs from "dayjs";
  23. import {
  24. fetchEquipmentStatus,
  25. EquipmentStatusByTypeResponse,
  26. EquipmentStatusPerDetail,
  27. } from "@/app/api/jo/actions";
  28. import { arrayToDayjs } from "@/app/utils/formatUtil";
  29. const REFRESH_INTERVAL = 60 * 1000; // 1 分鐘
  30. const STATUS_COLORS: Record<string, "success" | "default" | "warning" | "error"> = {
  31. Processing: "success",
  32. Idle: "default",
  33. Repair: "warning",
  34. };
  35. const formatDateTime = (value: any): string => {
  36. if (!value) return "-";
  37. if (Array.isArray(value)) {
  38. try {
  39. const parsed = arrayToDayjs(value, true);
  40. if (parsed.isValid()) {
  41. return parsed.format("YYYY-MM-DD HH:mm");
  42. }
  43. } catch (e) {
  44. console.error("Error parsing datetime array:", e);
  45. }
  46. }
  47. if (typeof value === "string") {
  48. const parsed = dayjs(value);
  49. if (parsed.isValid()) {
  50. return parsed.format("YYYY-MM-DD HH:mm");
  51. }
  52. }
  53. return "-";
  54. };
  55. const formatTime = (value: any): string => {
  56. if (!value) return "-";
  57. if (Array.isArray(value)) {
  58. try {
  59. const parsed = arrayToDayjs(value, true);
  60. if (parsed.isValid()) {
  61. return parsed.format("HH:mm");
  62. }
  63. } catch (e) {
  64. console.error("Error parsing time array:", e);
  65. }
  66. }
  67. if (typeof value === "string") {
  68. const parsed = dayjs(value);
  69. if (parsed.isValid()) {
  70. return parsed.format("HH:mm");
  71. }
  72. }
  73. return "-";
  74. };
  75. // 计算预计完成时间
  76. const calculateEstimatedCompletionTime = (
  77. startTime: any,
  78. processingTime: number | null | undefined
  79. ): string => {
  80. if (!startTime || !processingTime || processingTime <= 0) return "-";
  81. try {
  82. const start = arrayToDayjs(startTime, true);
  83. if (!start.isValid()) return "-";
  84. const estimated = start.add(processingTime, "minute");
  85. return estimated.format("YYYY-MM-DD HH:mm");
  86. } catch (e) {
  87. console.error("Error calculating estimated completion time:", e);
  88. return "-";
  89. }
  90. };
  91. // 计算剩余时间(分钟)
  92. const calculateRemainingTime = (
  93. startTime: any,
  94. processingTime: number | null | undefined
  95. ): string => {
  96. if (!startTime || !processingTime || processingTime <= 0) return "-";
  97. try {
  98. const start = arrayToDayjs(startTime, true);
  99. if (!start.isValid()) return "-";
  100. const now = dayjs();
  101. const estimated = start.add(processingTime, "minute");
  102. const remainingMinutes = estimated.diff(now, "minute");
  103. if (remainingMinutes < 0) {
  104. return `-${Math.abs(remainingMinutes)}`;
  105. }
  106. return remainingMinutes.toString();
  107. } catch (e) {
  108. console.error("Error calculating remaining time:", e);
  109. return "-";
  110. }
  111. };
  112. const EquipmentStatusDashboard: React.FC = () => {
  113. const { t } = useTranslation(["common", "jo"]);
  114. const [data, setData] = useState<EquipmentStatusByTypeResponse[]>([]);
  115. const [loading, setLoading] = useState<boolean>(true);
  116. const [tabIndex, setTabIndex] = useState<number>(0);
  117. const [now, setNow] = useState(dayjs());
  118. const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | null>(null);
  119. const loadData = useCallback(async () => {
  120. setLoading(true);
  121. try {
  122. const result = await fetchEquipmentStatus();
  123. setData(result || []);
  124. setLastDataRefreshTime(dayjs());
  125. } catch (error) {
  126. console.error("Error fetching equipment status:", error);
  127. setData([]);
  128. } finally {
  129. setLoading(false);
  130. }
  131. }, []);
  132. useEffect(() => {
  133. loadData();
  134. const interval = setInterval(() => {
  135. loadData();
  136. }, REFRESH_INTERVAL);
  137. return () => clearInterval(interval);
  138. }, [loadData]);
  139. // 添加定时更新剩余时间
  140. useEffect(() => {
  141. const timer = setInterval(() => {
  142. // 触发重新渲染以更新剩余时间
  143. setData((prev) => [...prev]);
  144. }, 60000); // 每分钟更新一次
  145. return () => clearInterval(timer);
  146. }, []);
  147. useEffect(() => {
  148. const timer = setInterval(() => setNow(dayjs()), 60 * 1000);
  149. return () => clearInterval(timer);
  150. }, []);
  151. const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
  152. setTabIndex(newValue);
  153. };
  154. const displayTypes =
  155. tabIndex === 0
  156. ? data
  157. : data.filter((_, index) => index === tabIndex - 1);
  158. return (
  159. <Box>
  160. <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
  161. <Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
  162. {t("Production Equipment Status Dashboard")}
  163. </Typography>
  164. </Box>
  165. <Box sx={{ display: 'flex', alignItems: 'center', width: '100%', mb: 2, gap: 2 }}>
  166. <Box sx={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
  167. <Tabs
  168. value={tabIndex}
  169. onChange={handleTabChange}
  170. variant="scrollable"
  171. scrollButtons="auto"
  172. >
  173. <Tab label={t("All")} />
  174. {data.map((type, index) => (
  175. <Tab
  176. key={type.equipmentTypeId}
  177. label={type.equipmentTypeName || `${t("Equipment Type")} ${index + 1}`}
  178. />
  179. ))}
  180. </Tabs>
  181. </Box>
  182. <Stack direction="row" spacing={2} sx={{ flexShrink: 0, alignSelf: 'center' }}>
  183. <Typography variant="body2" sx={{ color: 'text.secondary' }}>
  184. {t("Now")}: {now.format('HH:mm')}
  185. </Typography>
  186. <Typography variant="body2" sx={{ color: 'text.secondary' }}>
  187. {t("Auto-refresh every 1 minute")} | {t("Last updated")}: {lastDataRefreshTime ? lastDataRefreshTime.format('HH:mm:ss') : '--:--:--'}
  188. </Typography>
  189. </Stack>
  190. </Box>
  191. {loading ? (
  192. <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
  193. <CircularProgress />
  194. </Box>
  195. ) : displayTypes.length === 0 ? (
  196. <Box sx={{ textAlign: "center", p: 3 }}>
  197. {t("No data available")}
  198. </Box>
  199. ) : (
  200. <Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
  201. {displayTypes.map((type) => {
  202. const details = type.details || [];
  203. if (details.length === 0) return null;
  204. return (
  205. <Card
  206. key={type.equipmentTypeId}
  207. sx={{
  208. border: "3px solid #135fed",
  209. overflowX: "auto",
  210. }}
  211. >
  212. <CardContent>
  213. <Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
  214. {type.equipmentTypeName || "-"}
  215. </Typography>
  216. <TableContainer component={Paper} sx={{ maxHeight: 440, overflow: 'auto' }}>
  217. <Table size="small" sx={{ tableLayout: 'fixed', width: '100%' }}>
  218. <TableHead>
  219. <TableRow sx={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'background.paper' }}>
  220. <TableCell sx={{ width: '15%', minWidth: 150 }}>
  221. <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
  222. {t("Equipment Name and Code")}
  223. </Typography>
  224. </TableCell>
  225. {details.map((d) => (
  226. <TableCell
  227. key={d.equipmentDetailId}
  228. sx={{
  229. width: `${85 / details.length}%`,
  230. textAlign: 'left'
  231. }}
  232. >
  233. <Box sx={{ display: "flex", flexDirection: "column" }}>
  234. <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
  235. {d.equipmentDetailName || "-"}
  236. </Typography>
  237. <Typography variant="caption" color="text.secondary">
  238. {d.equipmentDetailCode || "-"}
  239. </Typography>
  240. </Box>
  241. </TableCell>
  242. ))}
  243. </TableRow>
  244. </TableHead>
  245. <TableBody>
  246. {/* 工序 Row */}
  247. <TableRow>
  248. <TableCell sx={{ width: '15%', minWidth: 150 }}>
  249. <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
  250. {t("Process")}
  251. </Typography>
  252. </TableCell>
  253. {details.map((d) => (
  254. <TableCell
  255. key={d.equipmentDetailId}
  256. sx={{
  257. width: `${85 / details.length}%`,
  258. textAlign: 'left'
  259. }}
  260. >
  261. {d.status === "Processing" ? d.currentProcess?.processName || "-" : "-"}
  262. </TableCell>
  263. ))}
  264. </TableRow>
  265. {/* 狀態 Row - 修改:Processing 时只显示 job order code */}
  266. <TableRow>
  267. <TableCell sx={{ width: '15%', minWidth: 150 }}>
  268. <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
  269. {t("Status")}
  270. </Typography>
  271. </TableCell>
  272. {details.map((d) => {
  273. const chipColor = STATUS_COLORS[d.status] || "default";
  274. const cp = d.currentProcess;
  275. // Processing 时只显示 job order code,不显示 Chip
  276. if (d.status === "Processing" && cp?.jobOrderCode) {
  277. return (
  278. <TableCell
  279. key={d.equipmentDetailId}
  280. sx={{
  281. width: `${85 / details.length}%`,
  282. textAlign: 'left'
  283. }}
  284. >
  285. <Typography variant="body2" sx={{ fontWeight: 500 }}>
  286. {cp.jobOrderCode}
  287. </Typography>
  288. </TableCell>
  289. );
  290. }
  291. // 其他状态显示 Chip
  292. return (
  293. <TableCell
  294. key={d.equipmentDetailId}
  295. sx={{
  296. width: `${85 / details.length}%`,
  297. textAlign: 'left'
  298. }}
  299. >
  300. <Chip label={t(`${d.status}`)} color={chipColor} size="small" />
  301. </TableCell>
  302. );
  303. })}
  304. </TableRow>
  305. {/* 開始時間 Row */}
  306. <TableRow>
  307. <TableCell sx={{ width: '15%', minWidth: 150 }}>
  308. <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
  309. {t("Start Time")}
  310. </Typography>
  311. </TableCell>
  312. {details.map((d) => (
  313. <TableCell
  314. key={d.equipmentDetailId}
  315. sx={{
  316. width: `${85 / details.length}%`,
  317. textAlign: 'left'
  318. }}
  319. >
  320. {d.status === "Processing"
  321. ? formatDateTime(d.currentProcess?.startTime)
  322. : "-"}
  323. </TableCell>
  324. ))}
  325. </TableRow>
  326. {/* 預計完成時間 Row */}
  327. <TableRow>
  328. <TableCell sx={{ width: '15%', minWidth: 150 }}>
  329. <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
  330. {t("預計完成時間")}
  331. </Typography>
  332. </TableCell>
  333. {details.map((d) => (
  334. <TableCell
  335. key={d.equipmentDetailId}
  336. sx={{
  337. width: `${85 / details.length}%`,
  338. textAlign: 'left'
  339. }}
  340. >
  341. {d.status === "Processing"
  342. ? calculateEstimatedCompletionTime(
  343. d.currentProcess?.startTime,
  344. d.currentProcess?.processingTime
  345. )
  346. : "-"}
  347. </TableCell>
  348. ))}
  349. </TableRow>
  350. {/* 剩餘時間 Row */}
  351. <TableRow>
  352. <TableCell sx={{ width: '15%', minWidth: 150 }}>
  353. <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
  354. {t("Remaining Time (min)")}
  355. </Typography>
  356. </TableCell>
  357. {details.map((d) => (
  358. <TableCell
  359. key={d.equipmentDetailId}
  360. sx={{
  361. width: `${85 / details.length}%`,
  362. textAlign: 'left'
  363. }}
  364. >
  365. {d.status === "Processing"
  366. ? calculateRemainingTime(
  367. d.currentProcess?.startTime,
  368. d.currentProcess?.processingTime
  369. )
  370. : "-"}
  371. </TableCell>
  372. ))}
  373. </TableRow>
  374. </TableBody>
  375. </Table>
  376. </TableContainer>
  377. </CardContent>
  378. </Card>
  379. );
  380. })}
  381. </Box>
  382. )}
  383. </Box>
  384. );
  385. };
  386. export default EquipmentStatusDashboard;