FPSMS-frontend
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 

364 строки
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. Stack
  17. } from "@mui/material";
  18. import { useTranslation } from "react-i18next";
  19. import dayjs from "dayjs";
  20. import type { Dayjs } from "dayjs";
  21. import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
  22. import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
  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>(dayjs());
  31. const refreshCountRef = useRef<number>(0);
  32. const [now, setNow] = useState(dayjs());
  33. const [lastDataRefreshTime, setLastDataRefreshTime] = useState<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.format("YYYY-MM-DD"));
  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. <LocalizationProvider dateAdapter={AdapterDayjs}>
  149. <DatePicker
  150. label={t("Date")}
  151. value={selectedDate}
  152. onChange={(newValue) => {
  153. if (newValue) setSelectedDate(newValue);
  154. }}
  155. format="YYYY-MM-DD"
  156. slotProps={{
  157. textField: { size: "small", sx: { minWidth: 160 } },
  158. }}
  159. />
  160. </LocalizationProvider>
  161. <Box sx={{ flexGrow: 1 }} />
  162. <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}>
  163. <Typography variant="body2" sx={{ color: 'text.secondary' }} suppressHydrationWarning>
  164. {t("Now")}: {now.format('HH:mm')}
  165. </Typography>
  166. <Typography variant="body2" sx={{ color: 'text.secondary' }}>
  167. {t("Auto-refresh every 10 minutes")} | {t("Last updated")}: {lastDataRefreshTime ? lastDataRefreshTime.format('HH:mm:ss') : '--:--:--'}
  168. </Typography>
  169. </Stack>
  170. </Stack>
  171. {loading ? (
  172. <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
  173. <CircularProgress />
  174. </Box>
  175. ) : (
  176. <TableContainer sx={{ border: "3px solid #135fed", overflowX: "auto", maxHeight: 440, overflow: 'auto' }}
  177. component={Paper}
  178. >
  179. <Table size="small" sx={{ minWidth: 800 }}>
  180. <TableHead>
  181. <TableRow
  182. sx={{
  183. bgcolor: "#424242",
  184. "& th": {
  185. borderBottom: "none",
  186. py: 1.5,
  187. position: 'sticky', top: 0, zIndex: 1
  188. },
  189. }}
  190. >
  191. <TableCell align="right" sx={{ width: 80 }}>
  192. <Typography
  193. variant="subtitle2"
  194. sx={{
  195. fontWeight: 600,
  196. //color: "#ffffff",
  197. }}
  198. >
  199. {t("No.")}
  200. </Typography>
  201. </TableCell>
  202. <TableCell sx={{ minWidth: 280 }}>
  203. <Typography
  204. variant="subtitle2"
  205. sx={{
  206. fontWeight: 600,
  207. //color: "#ffffff",
  208. }}
  209. >
  210. {t("Operator")}
  211. </Typography>
  212. </TableCell>
  213. <TableCell sx={{ minWidth: 300 }}>
  214. <Typography
  215. variant="subtitle2"
  216. sx={{
  217. fontWeight: 600,
  218. //color: "#ffffff",
  219. }}
  220. >
  221. {t("Job Details")}
  222. </Typography>
  223. </TableCell>
  224. </TableRow>
  225. </TableHead>
  226. <TableBody>
  227. {data.length === 0 ? (
  228. <TableRow>
  229. <TableCell colSpan={4} align="center">
  230. {t("No data available")}
  231. </TableCell>
  232. </TableRow>
  233. ) : (
  234. data.map((row, index) => {
  235. const jobOrderCount = row.totalJobOrderCount || 0;
  236. return (
  237. <TableRow
  238. key={row.operatorId}
  239. sx={{
  240. "&:hover": {
  241. bgcolor: "#f9f9f9",
  242. },
  243. "& td": {
  244. borderBottom: "1px solid #e0e0e0",
  245. py: 2,
  246. verticalAlign: "top",
  247. },
  248. }}
  249. >
  250. <TableCell align="right"
  251. sx={{
  252. width: 80,
  253. fontWeight: 500,
  254. verticalAlign: "top",
  255. }}
  256. >
  257. {index + 1}
  258. </TableCell>
  259. <TableCell
  260. sx={{
  261. minWidth: 280,
  262. padding: 0,
  263. verticalAlign: "top",
  264. height: "100%",
  265. }}
  266. >
  267. <Box
  268. sx={{
  269. p: 1.5,
  270. display: "flex",
  271. flexDirection: "column",
  272. gap: 0.75,
  273. bgcolor: "#f5f5f5",
  274. border: "1px solid #e0e0e0",
  275. borderRadius: 1.5,
  276. boxSizing: "border-box",
  277. height: "180px",
  278. }}
  279. >
  280. <Typography variant="body2" sx={{ lineHeight: 1.6 }}>
  281. {t("Operator Name & No.")}:{" "}
  282. <Box component="span" sx={{ fontWeight: 500 }}>
  283. {row.operatorName || "-"}{" "}
  284. {row.staffNo ? `(${row.staffNo})` : ""}
  285. </Box>
  286. </Typography>
  287. <Typography variant="body2" sx={{ lineHeight: 1.6 }}>
  288. {t("Count of Job Orders")}:{" "}
  289. <Box component="span" sx={{ fontWeight: 500 }}>
  290. {jobOrderCount}
  291. </Box>
  292. </Typography>
  293. <Typography variant="body2" sx={{ lineHeight: 1.6 }}>
  294. {t("Total Processing Time")}:{" "}
  295. <Box component="span" sx={{ fontWeight: 500 }}>
  296. {formatMinutesToHHmm(row.totalProcessingMinutes || 0)}
  297. </Box>
  298. </Typography>
  299. </Box>
  300. </TableCell>
  301. <TableCell
  302. sx={{
  303. minWidth: 300,
  304. padding: 0,
  305. verticalAlign: "top",
  306. height: "100%",
  307. }}
  308. >
  309. <Box
  310. sx={{
  311. p: 1.5,
  312. display: "flex",
  313. flexDirection: "column",
  314. gap: 0.75,
  315. bgcolor: "#f5f5f5",
  316. border: "1px solid #e0e0e0",
  317. borderRadius: 1.5,
  318. boxSizing: "border-box",
  319. height: "180px",
  320. }}
  321. >
  322. {renderCurrentProcesses(row.currentProcesses)}
  323. </Box>
  324. </TableCell>
  325. </TableRow>
  326. );
  327. })
  328. )}
  329. </TableBody>
  330. </Table>
  331. </TableContainer>
  332. )}
  333. </CardContent>
  334. </Card>
  335. );
  336. };
  337. export default OperatorKpiDashboard;