diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index b08ac05..f7eb995 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -1480,6 +1480,8 @@ export interface ProcessStatusInfo { processName?: string | null; equipmentName?: string | null; equipmentDetailName?: string | null; + /** 經手人姓名(對應 product process line.handler) */ + handlerName?: string | null; startTime?: string | null; endTime?: string | null; isRequired: boolean; @@ -1498,18 +1500,23 @@ export interface JobProcessStatusResponse { processes: ProcessStatusInfo[]; } -export const fetchJobProcessStatus = cache(async (date?: string) => { - const params = new URLSearchParams(); - if (date) params.set("date", date); // yyyy-MM-dd +export const fetchJobProcessStatus = cache( + async (date?: string, productProcessStatus?: string | null) => { + const params = new URLSearchParams(); + if (date) params.set("date", date); // yyyy-MM-dd + if (productProcessStatus && productProcessStatus.length > 0) { + params.set("productProcessStatus", productProcessStatus); + } - const qs = params.toString(); - const url = `${BASE_API_URL}/product-process/Demo/JobProcessStatus${qs ? `?${qs}` : ""}`; + const qs = params.toString(); + const url = `${BASE_API_URL}/product-process/Demo/JobProcessStatus${qs ? `?${qs}` : ""}`; - return serverFetchJson(url, { - method: "GET", - next: { tags: ["jobProcessStatus"] }, - }); -}); + return serverFetchJson(url, { + method: "GET", + next: { tags: ["jobProcessStatus"] }, + }); + }, +); // ===== Operator KPI Dashboard ===== diff --git a/src/components/ProductionProcess/JobProcessStatus.tsx b/src/components/ProductionProcess/JobProcessStatus.tsx index b411d03..3e40611 100644 --- a/src/components/ProductionProcess/JobProcessStatus.tsx +++ b/src/components/ProductionProcess/JobProcessStatus.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Box, @@ -15,174 +15,393 @@ import { TableRow, Paper, CircularProgress, - Stack -} from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import dayjs from 'dayjs'; -import type { Dayjs } from 'dayjs'; -import { fetchJobProcessStatus, JobProcessStatusResponse } from '@/app/api/jo/actions'; -import { arrayToDayjs } from '@/app/utils/formatUtil'; + Stack, + Button, + ToggleButton, + ToggleButtonGroup, + FormControl, + InputLabel, + Select, + MenuItem, +} from "@mui/material"; + +import { useTranslation } from "react-i18next"; + +import dayjs from "dayjs"; + +import type { Dayjs } from "dayjs"; + +import { + fetchJobProcessStatus, + JobProcessStatusResponse, +} from "@/app/api/jo/actions"; + +import { arrayToDayjs } from "@/app/utils/formatUtil"; + import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; + import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; + const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes +/** 每頁顯示工序數(4~6);固定 5 以平衡可讀性與寬度 */ + +const PROCESSES_PER_PAGE = 5; + +/** 與後端 ProductProcessStatus.value 一致(用於篩選查詢) */ +const PRODUCT_PROCESS_STATUS_FILTER_VALUES = [ + "pending", + "in_progress", + "stopped", + "completed", + "cancelled", +] as const; + +/** 工單狀態儀表板專用文案(與全域 pending/in_progress 等區隔) */ +const JOB_DASHBOARD_PP_STATUS_I18N: Record< + (typeof PRODUCT_PROCESS_STATUS_FILTER_VALUES)[number], + string +> = { + pending: "Job dashboard PP status: pending", + in_progress: "Job dashboard PP status: in_progress", + stopped: "Job dashboard PP status: stopped", + completed: "Job dashboard PP status: completed", + cancelled: "Job dashboard PP status: cancelled", +}; + +/** 工序格僅顯示一類資訊(單行) */ +type ProcessCellDetailMode = "time" | "processName" | "equipment" | "handler"; + +function parseTimeToDayjs(timeData: unknown): Dayjs | null { + if (timeData == null) return null; + + if (Array.isArray(timeData)) { + try { + const parsed = arrayToDayjs(timeData, true); + + return parsed.isValid() ? parsed : null; + } catch { + return null; + } + } + + if (typeof timeData === "string") { + const parsed = dayjs(timeData); + + return parsed.isValid() ? parsed : null; + } + + return null; +} + const JobProcessStatus: React.FC = () => { const { t } = useTranslation(["common", "jo"]); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); - const refreshCountRef = useRef(0); + + /** 每秒更新一次:剩餘時間、進行中耗時(與現有時鐘共用,成本可忽略) */ + const [currentTime, setCurrentTime] = useState(dayjs()); + const [selectedDate, setSelectedDate] = useState(dayjs()); - const [lastDataRefreshTime, setLastDataRefreshTime] = useState(null); - // Update current time every second for countdown + /** 後端 ProductProcessStatus,空字串 = 不篩選 */ + const [productProcessStatusFilter, setProductProcessStatusFilter] = + useState(""); + + const [lastDataRefreshTime, setLastDataRefreshTime] = useState( + null, + ); + + const [processPage, setProcessPage] = useState(0); + + const [cellDetailMode, setCellDetailMode] = + useState("time"); + useEffect(() => { const timer = setInterval(() => { setCurrentTime(dayjs()); }, 1000); + return () => clearInterval(timer); }, []); const loadData = useCallback(async () => { setLoading(true); + try { - const result = await fetchJobProcessStatus(selectedDate.format("YYYY-MM-DD")); + const result = await fetchJobProcessStatus( + selectedDate.format("YYYY-MM-DD"), + productProcessStatusFilter || undefined, + ); + setData(result); - refreshCountRef.current += 1; + setLastDataRefreshTime(dayjs()); } catch (error) { - console.error('Error fetching job process status:', error); + console.error("Error fetching job process status:", error); + setData([]); } finally { setLoading(false); } - }, [selectedDate]); + }, [selectedDate, productProcessStatusFilter]); useEffect(() => { loadData(); + const interval = setInterval(() => { loadData(); }, REFRESH_INTERVAL); + return () => clearInterval(interval); }, [loadData]); - const formatTime = (timeData: any): string => { - if (!timeData) return '-'; // 改为返回 '-' 而不是 'N/A' - - // Handle array format [year, month, day, hour, minute, second] - if (Array.isArray(timeData)) { - try { - const parsed = arrayToDayjs(timeData, true); - if (parsed.isValid()) { - return parsed.format('HH:mm'); - } - } catch (error) { - console.error('Error parsing array time:', error); + useEffect(() => { + setProcessPage(0); + }, [selectedDate, productProcessStatusFilter]); + + const formatTime = (timeData: unknown): string => { + const parsed = parseTimeToDayjs(timeData); + + return parsed ? parsed.format("HH:mm") : "-"; + }; + + const formatDurationMs = useCallback( + (ms: number): string => { + if (ms < 0) ms = 0; + + const totalSeconds = Math.floor(ms / 1000); + + const hours = Math.floor(totalSeconds / 3600); + + const minutes = Math.floor((totalSeconds % 3600) / 60); + + const seconds = totalSeconds % 60; + + const parts: string[] = []; + + if (hours > 0) { + parts.push(t("Duration hours", { count: hours })); } - } - - // Handle LocalDateTime ISO string format (e.g., "2026-01-09T18:01:54") - if (typeof timeData === 'string') { - const parsed = dayjs(timeData); - if (parsed.isValid()) { - return parsed.format('HH:mm'); + + if (minutes > 0) { + parts.push(t("Duration minutes", { count: minutes })); } - } - - return '-'; - }; - const calculateRemainingTime = (planEndTime: any, processingTime: number | null, setupTime: number | null, changeoverTime: number | null): string => { - if (!planEndTime) return '-'; - - let endTime: dayjs.Dayjs; - - // Handle array format [year, month, day, hour, minute, second] - // Use arrayToDayjs for consistency with other parts of the codebase + if (seconds > 0 || parts.length === 0) { + parts.push(t("Duration seconds", { count: seconds })); + } + + return parts.join(" "); + }, + + [t], + ); + + const calculateRemainingTime = (planEndTime: unknown): string => { + if (!planEndTime) return "-"; + + let endTime: Dayjs; + if (Array.isArray(planEndTime)) { try { endTime = arrayToDayjs(planEndTime, true); - console.log('Parsed planEndTime array:', { - array: planEndTime, - parsed: endTime.format('YYYY-MM-DD HH:mm:ss'), - isValid: endTime.isValid() - }); - } catch (error) { - console.error('Error parsing array planEndTime:', error); - return '-'; + } catch { + return "-"; } - } else if (typeof planEndTime === 'string') { + } else if (typeof planEndTime === "string") { endTime = dayjs(planEndTime); - console.log('Parsed planEndTime string:', { - string: planEndTime, - parsed: endTime.format('YYYY-MM-DD HH:mm:ss'), - isValid: endTime.isValid() - }); } else { - return '-'; + return "-"; } - + if (!endTime.isValid()) { - console.error('Invalid endTime:', planEndTime); - return '-'; + return "-"; } - - const diff = endTime.diff(currentTime, 'minute'); - console.log('Remaining time calculation:', { - endTime: endTime.format('YYYY-MM-DD HH:mm:ss'), - currentTime: currentTime.format('YYYY-MM-DD HH:mm:ss'), - diffMinutes: diff - }); - - // If the planned end time is in the past, show 0 (or you could show negative time) - if (diff < 0) return '0'; - + + const diff = endTime.diff(currentTime, "minute"); + + if (diff < 0) return "0"; + const hours = Math.floor(diff / 60); + const minutes = diff % 60; - return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + + return `${hours.toString().padStart(2, "0")}:${minutes + + .toString() + + .padStart(2, "0")}`; }; - const calculateWaitTime = ( - currentProcessEndTime: any, - nextProcessStartTime: any, - isLastProcess: boolean - ): string => { - if (isLastProcess) return '-'; - if (!currentProcessEndTime) return '-'; - if (nextProcessStartTime) return '0'; // Next process has started, stop counting - - let endTime: dayjs.Dayjs; - - // Handle array format - if (Array.isArray(currentProcessEndTime)) { - try { - endTime = arrayToDayjs(currentProcessEndTime, true); - } catch (error) { - console.error('Error parsing array endTime:', error); - return '-'; + const maxProcessSlots = useMemo(() => { + if (data.length === 0) return 0; + + return Math.max( + 0, + ...data.map((d) => d.processes?.length ?? 0), + ); + }, [data]); + + const totalPages = + maxProcessSlots > 0 ? Math.ceil(maxProcessSlots / PROCESSES_PER_PAGE) : 0; + + useEffect(() => { + if (totalPages === 0) { + setProcessPage(0); + + return; + } + + setProcessPage((p) => Math.min(p, totalPages - 1)); + }, [totalPages]); + + const startSlotIndex = processPage * PROCESSES_PER_PAGE; + + const visibleCount = + maxProcessSlots > 0 + ? Math.min(PROCESSES_PER_PAGE, maxProcessSlots - startSlotIndex) + : 0; + + const rangeFrom = maxProcessSlots > 0 ? startSlotIndex + 1 : 0; + + const rangeTo = maxProcessSlots > 0 ? startSlotIndex + visibleCount : 0; + + const renderProcessCell = (row: JobProcessStatusResponse, index: number) => { + const process = row.processes[index]; + + if (!process) { + return ( + + + — + + + ); + } + + if (!process.isRequired) { + return ( + + N/A + + ); + } + + const startD = parseTimeToDayjs(process.startTime); + const endD = parseTimeToDayjs(process.endTime); + let durationLabel = "-"; + if (startD && endD) { + durationLabel = formatDurationMs(endD.diff(startD)); + } else if (startD && !endD) { + durationLabel = formatDurationMs(currentTime.diff(startD)); + } + + const equipmentLine = [ + process.equipmentName, + process.equipmentDetailName ? `-${process.equipmentDetailName}` : "", + ] + .filter((s) => s != null && String(s).length > 0) + .join(""); + + let lineText = "—"; + let lineColor: "text.primary" | "info.main" = "text.primary"; + + switch (cellDetailMode) { + case "time": { + const startStr = formatTime(process.startTime); + const endStr = formatTime(process.endTime); + if (startD && endD) { + lineText = `${startStr} – ${endStr} · ${durationLabel}`; + } else if (startD && !endD) { + lineText = `${startStr} – … · ${durationLabel} · ${t("In progress")}`; + lineColor = "info.main"; + } else { + lineText = "—"; + } + break; } - } else if (typeof currentProcessEndTime === 'string') { - endTime = dayjs(currentProcessEndTime); - } else { - return '-'; + case "processName": + lineText = process.processName?.trim() || "—"; + break; + case "equipment": + lineText = equipmentLine.trim().length ? equipmentLine : "—"; + break; + case "handler": { + const h = process.handlerName?.trim(); + lineText = h?.length ? h : "—"; + break; + } + default: + break; } - - if (!endTime.isValid()) return '-'; - - const diff = currentTime.diff(endTime, 'minute'); - return diff > 0 ? diff.toString() : '0'; + + const isTimeMode = cellDetailMode === "time"; + + return ( + + + {lineText} + + + ); }; return ( - {/* Title */} {t("Job Process Status Dashboard")} - {/* Filters */} - + { /> + + + {t("Product process status")} + + + + - - - {t("Now")}: {currentTime.format('HH:mm')} + + + + {t("Now")}: {currentTime.format("HH:mm")} - - {t("Auto-refresh every 10 minutes")} | {t("Last updated")}: {lastDataRefreshTime ? lastDataRefreshTime.format('HH:mm:ss') : '--:--:--'} + + + {t("Auto-refresh every 10 minutes")} | {t("Last updated")}:{" "} + {lastDataRefreshTime + ? lastDataRefreshTime.format("HH:mm:ss") + : "--:--:--"} + + + + {t("Job process detail mode label")} + + { + if (value != null) setCellDetailMode(value); + }} + > + + {t("Job process detail: time")} + + + {t("Job process detail: process name")} + + + {t("Job process detail: equipment")} + + + {t("Job process detail: handler")} + + + + + {maxProcessSlots > 0 && ( + + + + + {t("Process page summary", { + from: rangeFrom, + + to: rangeTo, + + total: maxProcessSlots, + })} + + + + + )} + {loading ? ( - + ) : ( + -
- - - + + + {t("Job Order No.")} - + + {t("FG / WIP Item")} - + + {t("Production Time Remaining")} - - - - {Array.from({ length: 16 }, (_, i) => i + 1).map((num) => ( - - - {t("Process")} {num} - - - ))} - - - {Array.from({ length: 16 }, (_, i) => i + 1).map((num) => ( - - - - {t("Start")} - - - {t("Finish")} + + {maxProcessSlots > 0 && + Array.from({ length: visibleCount }, (_, i) => ( + + + {t("Process")} {startSlotIndex + i + 1} - - {t("Wait Time [minutes]")} + + ))} + + + + {maxProcessSlots > 0 && + Array.from({ length: visibleCount }, (_, i) => ( + + + {cellDetailMode === "time" + ? t("Job process detail: time") + : cellDetailMode === "processName" + ? t("Job process detail: process name") + : cellDetailMode === "equipment" + ? t("Job process detail: equipment") + : t("Job process detail: handler")} - - - ))} + + ))} + {data.length === 0 ? ( - + + {t("No data available")} + + + ) : maxProcessSlots === 0 ? ( + + {t("No data available")} ) : ( data.map((row) => ( - - {row.jobOrderCode || '-'} + + {row.jobOrderCode || "-"} - - {row.itemCode || '-'} - {row.itemName || '-'} + + + + {row.itemCode || "-"} + + + {row.itemName || "-"} - - {row.status === 'pending' ? '-' : calculateRemainingTime(row.planEndTime, row.processingTime, row.setupTime, row.changeoverTime)} + + {row.status === "pending" + ? "-" + : calculateRemainingTime(row.planEndTime)} - {row.processes.map((process, index) => { - const isLastProcess = index === row.processes.length - 1 || - !row.processes.slice(index + 1).some(p => p.isRequired); - const nextProcess = index < row.processes.length - 1 ? row.processes[index + 1] : null; - const waitTime = calculateWaitTime( - process.endTime, - nextProcess?.startTime, - isLastProcess - ); - - // 如果工序不是必需的,只显示一个 N/A - if (!process.isRequired) { - return ( - - - N/A - - - ); - } - const label = [ - process.processName, - process.equipmentName, - process.equipmentDetailName ? `-${process.equipmentDetailName}` : "", - ].filter(Boolean).join(" "); - // 如果工序是必需的,显示三行(Start、Finish、Wait Time) - return ( - - - {label || "-"} - - {formatTime(process.startTime)} - - - {formatTime(process.endTime)} - - 0 ? 'warning.main' : 'text.primary', - py: 0.5 - }}> - {waitTime} - - - - ); - })} + + {Array.from({ length: visibleCount }, (_, i) => + renderProcessCell(row, startSlotIndex + i), + )} )) )} @@ -344,11 +724,9 @@ const JobProcessStatus: React.FC = () => { )} - - ); }; -export default JobProcessStatus; \ No newline at end of file +export default JobProcessStatus; diff --git a/src/i18n/en/inventory.json b/src/i18n/en/inventory.json index 1939d18..79dcd95 100644 --- a/src/i18n/en/inventory.json +++ b/src/i18n/en/inventory.json @@ -25,5 +25,14 @@ "No stock take sections from warehouse": "No stock take sections returned from warehouse.", "Expand floor sections": "Expand sections for this floor", "Collapse floor sections": "Collapse sections for this floor", - "Select all on this floor": "Select all sections on this floor" + "Select all on this floor": "Select all on this floor ({{floor}})", + "Deselect all on this floor": "Deselect all on this floor ({{floor}})", + "Creation date": "Creation date", + "Floor area selection header": "{{floor}} area selection ({{count}} areas)", + "Search section code or name": "Search code or name (e.g. ST-042 or drinks)", + "Select all sections all floors": "Select all areas (all floors)", + "Clear selection all floors": "Clear selection (all floors)", + "Total selected sections label": "Total selected:", + "sections unit": "area(s)", + "No sections match search": "No areas match your search" } \ No newline at end of file diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index c04fcce..39c9241 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -43,7 +43,7 @@ const languageDetector: LanguageDetectorAsyncModule = { const initI18next = async (namespaces: string[]): Promise => { const label = `[i18n] initI18next ns=${namespaces.join(",")}`; - console.time(label); + //console.time(label); const i18nInstance = createInstance(); await i18nInstance .use(languageDetector) diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 4bbb770..daaee97 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -121,7 +121,29 @@ "Assignment failed: ": "分配失敗: ", "Unknown error": "未知錯誤", "Job Process Status Dashboard": "儀表板 - 工單狀態", - + "Time used": "耗時", + "In progress": "進行中", + "Previous page": "上一頁", + "Next page": "下一頁", + "Process page summary": "工序 {{from}}–{{to}} / 共 {{total}} 道", + "Duration hours": "{{count}} 小時", + "Duration minutes": "{{count}} 分鐘", + "Duration seconds": "{{count}} 秒", + "Job process detail: time": "時間", + "Job process detail: process name": "工序", + "Job process detail: equipment": "設備", + "Job process detail: handler": "員工", + "Job process detail mode label": "工序格顯示", + "Product process status": "生產流程狀態", + "Job dashboard PP status: pending": "工序待處理", + "Job dashboard PP status: in_progress": "工序進行中", + "Job dashboard PP status: stopped": "工序暫停", + "Job dashboard PP status: completed": "工序完成", + "Job dashboard PP status: cancelled": "工序已取消", + "stopped": "已停止", + "cancelled": "已取消", + + "Total Time": "總時間", "Remaining Time": "剩餘時間", "Wait Time": "等待時間", diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index 9e80f0e..726a51e 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -141,7 +141,16 @@ "No stock take sections from warehouse": "目前沒有盤點區域資料", "Expand floor sections": "展開此樓層區域", "Collapse floor sections": "收合此樓層區域", - "Select all on this floor": "全選此樓層", + "Select all on this floor": "全選此樓層 ({{floor}})", + "Deselect all on this floor": "取消全選此樓層 ({{floor}})", + "Creation date": "建立日期", + "Floor area selection header": "{{floor}} 區域選擇 ({{count}} 區域)", + "Search section code or name": "搜尋代碼或名稱 (例如 ST-042 或 飲品)", + "Select all sections all floors": "全選區域 (所有樓層)", + "Clear selection all floors": "清除已選 (所有樓層)", + "Total selected sections label": "總計已選擇 :", + "sections unit": "個區域", + "No sections match search": "沒有符合搜尋條件的區域", "section": "區域", "Stock Take Section": "盤點區域", "Store ID":"樓層", diff --git a/src/i18n/zh/jo.json b/src/i18n/zh/jo.json index eccb36c..f408eed 100644 --- a/src/i18n/zh/jo.json +++ b/src/i18n/zh/jo.json @@ -17,6 +17,27 @@ "Confirm All": "確認所有提料", "Wait Time [minutes]": "等待時間(分鐘)", "Job Process Status Dashboard": "儀表板 - 工單狀態", + "Time used": "耗時", + "In progress": "進行中", + "Previous page": "上一頁", + "Next page": "下一頁", + "Process page summary": "工序 {{from}}–{{to}} / 共 {{total}} 道", + "Duration hours": "{{count}} 小時", + "Duration minutes": "{{count}} 分鐘", + "Duration seconds": "{{count}} 秒", + "Job process detail: time": "時間", + "Job process detail: process name": "工序", + "Job process detail: equipment": "設備", + "Job process detail: handler": "員工", + "Job process detail mode label": "工序格顯示", + "Product process status": "生產流程狀態", + "stopped": "暫停", + "cancelled": "已取消", + "Job dashboard PP status: pending": "工序待處理", + "Job dashboard PP status: in_progress": "工序進行中", + "Job dashboard PP status: stopped": "工序暫停", + "Job dashboard PP status: completed": "工序完成", + "Job dashboard PP status: cancelled": "工序已取消", "This lot is rejected, please scan another lot.": "此批次已拒收,請掃描另一個批次。", "Edit": "改數", "Code / Lot No": "工單編號/批號",