FPSMS-frontend
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 

361 rader
13 KiB

  1. "use client";
  2. import React, { useState, useEffect, useCallback, useRef } 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. FormControl,
  17. Select,
  18. MenuItem,
  19. Stack
  20. } from "@mui/material";
  21. import { useTranslation } from "react-i18next";
  22. import dayjs from "dayjs";
  23. import { fetchOperatorKpi, OperatorKpiResponse, OperatorKpiProcessInfo } from "@/app/api/jo/actions";
  24. import { arrayToDayjs } from "@/app/utils/formatUtil";
  25. const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 分鐘
  26. const OperatorKpiDashboard: React.FC = () => {
  27. const { t } = useTranslation(["common", "jo"]);
  28. const [data, setData] = useState<OperatorKpiResponse[]>([]);
  29. const [loading, setLoading] = useState<boolean>(true);
  30. const [selectedDate, setSelectedDate] = useState(dayjs().format("YYYY-MM-DD"));
  31. const refreshCountRef = useRef<number>(0);
  32. const [now, setNow] = useState(dayjs());
  33. const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | null>(null);
  34. const formatTime = (timeData: any): string => {
  35. if (!timeData) return "-";
  36. if (Array.isArray(timeData)) {
  37. try {
  38. const parsed = arrayToDayjs(timeData, true);
  39. if (parsed.isValid()) {
  40. return parsed.format("HH:mm");
  41. }
  42. } catch (e) {
  43. console.error("Error parsing time array:", e);
  44. }
  45. }
  46. if (typeof timeData === "string") {
  47. const parsed = dayjs(timeData);
  48. if (parsed.isValid()) {
  49. return parsed.format("HH:mm");
  50. }
  51. }
  52. return "-";
  53. };
  54. const formatMinutesToHHmm = (minutes: number): string => {
  55. if (!minutes || minutes <= 0) return "00:00";
  56. const hours = Math.floor(minutes / 60);
  57. const mins = minutes % 60;
  58. return `${hours.toString().padStart(2, "0")}:${mins.toString().padStart(2, "0")}`;
  59. };
  60. const loadData = useCallback(async () => {
  61. setLoading(true);
  62. try {
  63. const result = await fetchOperatorKpi(selectedDate);
  64. setData(result);
  65. setLastDataRefreshTime(dayjs());
  66. refreshCountRef.current += 1;
  67. } catch (error) {
  68. console.error("Error fetching operator KPI:", error);
  69. setData([]);
  70. } finally {
  71. setLoading(false);
  72. }
  73. }, [selectedDate]);
  74. useEffect(() => {
  75. loadData();
  76. const interval = setInterval(() => {
  77. loadData();
  78. }, REFRESH_INTERVAL);
  79. return () => clearInterval(interval);
  80. }, [loadData]);
  81. useEffect(() => {
  82. const timer = setInterval(() => setNow(dayjs()), 60 * 1000);
  83. return () => clearInterval(timer);
  84. }, []);
  85. const renderCurrentProcesses = (processes: OperatorKpiProcessInfo[]) => {
  86. if (!processes || processes.length === 0) {
  87. return (
  88. <Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
  89. -
  90. </Typography>
  91. );
  92. }
  93. // 只顯示目前一個處理中的工序(樣式比照 Excel:欄位名稱縱向排列)
  94. const p = processes[0];
  95. const jobOrder = p.jobOrderCode ? `[${p.jobOrderCode}]` : "-";
  96. const itemInfo = p.itemCode && p.itemName
  97. ? `${p.itemCode} - ${p.itemName}`
  98. : p.itemCode || p.itemName || "-";
  99. // 格式化所需時間(分鐘轉換為 HH:mm)
  100. const formatRequiredTime = (minutes: number | null | undefined): string => {
  101. if (!minutes || minutes <= 0) return "-";
  102. const hours = Math.floor(minutes / 60);
  103. const mins = minutes % 60;
  104. return `${hours.toString().padStart(2, "0")}:${mins.toString().padStart(2, "0")}`;
  105. };
  106. // 計算預計完成時間
  107. const calculateEstimatedCompletionTime = (): string => {
  108. if (!p.startTime || !p.processingTime || p.processingTime <= 0) return "-";
  109. try {
  110. const start = arrayToDayjs(p.startTime, true);
  111. if (!start.isValid()) return "-";
  112. const estimated = start.add(p.processingTime, "minute");
  113. return estimated.format("HH:mm");
  114. } catch (e) {
  115. console.error("Error calculating estimated completion time:", e);
  116. return "-";
  117. }
  118. };
  119. return (
  120. <>
  121. <Typography variant="body2" sx={{ lineHeight: 1.6 }}>
  122. {t("Job Order and Product")}: {jobOrder} {itemInfo}
  123. </Typography>
  124. <Typography variant="body2" sx={{ lineHeight: 1.6 }}>
  125. {t("Process")}: {p.processName || "-"}
  126. </Typography>
  127. <Typography variant="body2" sx={{ lineHeight: 1.6 }}>
  128. {t("Start Time")}: {formatTime(p.startTime)}
  129. </Typography>
  130. <Typography variant="body2" sx={{ lineHeight: 1.6 }}>
  131. {t("Required Time")}: {formatRequiredTime(p.processingTime)}
  132. </Typography>
  133. <Typography variant="body2" sx={{ lineHeight: 1.6 }}>
  134. {t("Estimated Completion Time")}: {calculateEstimatedCompletionTime()}
  135. </Typography>
  136. </>
  137. );
  138. };
  139. return (
  140. <Card sx={{ mb: 2 }}>
  141. <CardContent>
  142. {/* Title */}
  143. <Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
  144. {t("Operator KPI Dashboard")}
  145. </Typography>
  146. {/* Filters */}
  147. <Stack direction="row" spacing={2} sx={{ mb: 3 }}>
  148. <FormControl size="small" sx={{ minWidth: 160 }}>
  149. <Select
  150. value={selectedDate}
  151. onChange={(e) => setSelectedDate(e.target.value)}
  152. >
  153. <MenuItem value={dayjs().format("YYYY-MM-DD")}>{t("Today")}</MenuItem>
  154. <MenuItem value={dayjs().subtract(1, "day").format("YYYY-MM-DD")}>{t("Yesterday")}</MenuItem>
  155. <MenuItem value={dayjs().subtract(2, "day").format("YYYY-MM-DD")}>{t("Two Days Ago")}</MenuItem>
  156. </Select>
  157. </FormControl>
  158. <Box sx={{ flexGrow: 1 }} />
  159. <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}>
  160. <Typography variant="body2" sx={{ color: 'text.secondary' }}>
  161. {t("Now")}: {now.format('HH:mm')}
  162. </Typography>
  163. <Typography variant="body2" sx={{ color: 'text.secondary' }}>
  164. {t("Auto-refresh every 10 minutes")} | {t("Last updated")}: {lastDataRefreshTime ? lastDataRefreshTime.format('HH:mm:ss') : '--:--:--'}
  165. </Typography>
  166. </Stack>
  167. </Stack>
  168. {loading ? (
  169. <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
  170. <CircularProgress />
  171. </Box>
  172. ) : (
  173. <TableContainer sx={{ border: "3px solid #135fed", overflowX: "auto", maxHeight: 440, overflow: 'auto' }}
  174. component={Paper}
  175. >
  176. <Table size="small" sx={{ minWidth: 800 }}>
  177. <TableHead>
  178. <TableRow
  179. sx={{
  180. bgcolor: "#424242",
  181. "& th": {
  182. borderBottom: "none",
  183. py: 1.5,
  184. position: 'sticky', top: 0, zIndex: 1
  185. },
  186. }}
  187. >
  188. <TableCell align="right" sx={{ width: 80 }}>
  189. <Typography
  190. variant="subtitle2"
  191. sx={{
  192. fontWeight: 600,
  193. //color: "#ffffff",
  194. }}
  195. >
  196. {t("No.")}
  197. </Typography>
  198. </TableCell>
  199. <TableCell sx={{ minWidth: 280 }}>
  200. <Typography
  201. variant="subtitle2"
  202. sx={{
  203. fontWeight: 600,
  204. //color: "#ffffff",
  205. }}
  206. >
  207. {t("Operator")}
  208. </Typography>
  209. </TableCell>
  210. <TableCell sx={{ minWidth: 300 }}>
  211. <Typography
  212. variant="subtitle2"
  213. sx={{
  214. fontWeight: 600,
  215. //color: "#ffffff",
  216. }}
  217. >
  218. {t("Job Details")}
  219. </Typography>
  220. </TableCell>
  221. </TableRow>
  222. </TableHead>
  223. <TableBody>
  224. {data.length === 0 ? (
  225. <TableRow>
  226. <TableCell colSpan={4} align="center">
  227. {t("No data available")}
  228. </TableCell>
  229. </TableRow>
  230. ) : (
  231. data.map((row, index) => {
  232. const jobOrderCount = row.totalJobOrderCount || 0;
  233. return (
  234. <TableRow
  235. key={row.operatorId}
  236. sx={{
  237. "&:hover": {
  238. bgcolor: "#f9f9f9",
  239. },
  240. "& td": {
  241. borderBottom: "1px solid #e0e0e0",
  242. py: 2,
  243. verticalAlign: "top",
  244. },
  245. }}
  246. >
  247. <TableCell align="right"
  248. sx={{
  249. width: 80,
  250. fontWeight: 500,
  251. verticalAlign: "top",
  252. }}
  253. >
  254. {index + 1}
  255. </TableCell>
  256. <TableCell
  257. sx={{
  258. minWidth: 280,
  259. padding: 0,
  260. verticalAlign: "top",
  261. height: "100%",
  262. }}
  263. >
  264. <Box
  265. sx={{
  266. p: 1.5,
  267. display: "flex",
  268. flexDirection: "column",
  269. gap: 0.75,
  270. bgcolor: "#f5f5f5",
  271. border: "1px solid #e0e0e0",
  272. borderRadius: 1.5,
  273. boxSizing: "border-box",
  274. height: "180px",
  275. }}
  276. >
  277. <Typography variant="body2" sx={{ lineHeight: 1.6 }}>
  278. {t("Operator Name & No.")}:{" "}
  279. <Box component="span" sx={{ fontWeight: 500 }}>
  280. {row.operatorName || "-"}{" "}
  281. {row.staffNo ? `(${row.staffNo})` : ""}
  282. </Box>
  283. </Typography>
  284. <Typography variant="body2" sx={{ lineHeight: 1.6 }}>
  285. {t("Count of Job Orders")}:{" "}
  286. <Box component="span" sx={{ fontWeight: 500 }}>
  287. {jobOrderCount}
  288. </Box>
  289. </Typography>
  290. <Typography variant="body2" sx={{ lineHeight: 1.6 }}>
  291. {t("Total Processing Time")}:{" "}
  292. <Box component="span" sx={{ fontWeight: 500 }}>
  293. {formatMinutesToHHmm(row.totalProcessingMinutes || 0)}
  294. </Box>
  295. </Typography>
  296. </Box>
  297. </TableCell>
  298. <TableCell
  299. sx={{
  300. minWidth: 300,
  301. padding: 0,
  302. verticalAlign: "top",
  303. height: "100%",
  304. }}
  305. >
  306. <Box
  307. sx={{
  308. p: 1.5,
  309. display: "flex",
  310. flexDirection: "column",
  311. gap: 0.75,
  312. bgcolor: "#f5f5f5",
  313. border: "1px solid #e0e0e0",
  314. borderRadius: 1.5,
  315. boxSizing: "border-box",
  316. height: "180px",
  317. }}
  318. >
  319. {renderCurrentProcesses(row.currentProcesses)}
  320. </Box>
  321. </TableCell>
  322. </TableRow>
  323. );
  324. })
  325. )}
  326. </TableBody>
  327. </Table>
  328. </TableContainer>
  329. )}
  330. </CardContent>
  331. </Card>
  332. );
  333. };
  334. export default OperatorKpiDashboard;