Просмотр исходного кода

jo dashboard update

production
CANCERYS\kw093 1 месяц назад
Родитель
Сommit
895b92fc71
7 измененных файлов: 664 добавлений и 218 удалений
  1. +17
    -10
      src/app/api/jo/actions.ts
  2. +582
    -204
      src/components/ProductionProcess/JobProcessStatus.tsx
  3. +10
    -1
      src/i18n/en/inventory.json
  4. +1
    -1
      src/i18n/index.tsx
  5. +23
    -1
      src/i18n/zh/common.json
  6. +10
    -1
      src/i18n/zh/inventory.json
  7. +21
    -0
      src/i18n/zh/jo.json

+ 17
- 10
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<JobProcessStatusResponse[]>(url, {
method: "GET",
next: { tags: ["jobProcessStatus"] },
});
});
return serverFetchJson<JobProcessStatusResponse[]>(url, {
method: "GET",
next: { tags: ["jobProcessStatus"] },
});
},
);

// ===== Operator KPI Dashboard =====



+ 582
- 204
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<JobProcessStatusResponse[]>([]);

const [loading, setLoading] = useState<boolean>(true);
const refreshCountRef = useRef<number>(0);

/** 每秒更新一次:剩餘時間、進行中耗時(與現有時鐘共用,成本可忽略) */

const [currentTime, setCurrentTime] = useState(dayjs());

const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs());
const [lastDataRefreshTime, setLastDataRefreshTime] = useState<Dayjs | null>(null);

// Update current time every second for countdown
/** 後端 ProductProcessStatus,空字串 = 不篩選 */
const [productProcessStatusFilter, setProductProcessStatusFilter] =
useState<string>("");

const [lastDataRefreshTime, setLastDataRefreshTime] = useState<Dayjs | null>(
null,
);

const [processPage, setProcessPage] = useState(0);

const [cellDetailMode, setCellDetailMode] =
useState<ProcessCellDetailMode>("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 (
<TableCell
key={`empty-${row.jobOrderId}-${index}`}
sx={{
padding: "10px 8px",
verticalAlign: "top",
wordBreak: "break-word",
}}
>
<Typography variant="body2" color="text.secondary">
</Typography>
</TableCell>
);
}

if (!process.isRequired) {
return (
<TableCell
key={index}
sx={{
padding: "10px 8px",
verticalAlign: "top",
wordBreak: "break-word",
}}
>
<Typography variant="body2">N/A</Typography>
</TableCell>
);
}

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 (
<TableCell
key={index}
sx={{
padding: "10px 8px",
verticalAlign: "top",
// 時間欄內容較長,允許換行並顯示完整;其他模式維持單行省略
...(isTimeMode ? { minWidth: 0 } : { maxWidth: 0 }),
}}
>
<Typography
variant="body2"
sx={{
color: lineColor,
...(isTimeMode
? {
whiteSpace: "normal",
wordBreak: "break-word",
overflow: "visible",
lineHeight: 1.35,
}
: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}),
}}
title={!isTimeMode && lineText !== "—" ? lineText : undefined}
>
{lineText}
</Typography>
</TableCell>
);
};

return (
<Card sx={{ mb: 2 }}>
<CardContent>
{/* Title */}
<Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
{t("Job Process Status Dashboard")}
</Typography>

{/* Filters */}
<Stack direction="row" spacing={2} sx={{ mb: 3 }}>
<Stack
direction="row"
spacing={2}
sx={{ mb: 3, flexWrap: "wrap", alignItems: "center", gap: 1 }}
>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
label={t("Date")}
@@ -197,145 +416,306 @@ const JobProcessStatus: React.FC = () => {
/>
</LocalizationProvider>

<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel id="jp-pp-status-filter-label">
{t("Product process status")}
</InputLabel>
<Select
labelId="jp-pp-status-filter-label"
id="jp-pp-status-filter"
label={t("Product process status")}
value={productProcessStatusFilter}
onChange={(e) => {
setProductProcessStatusFilter(
typeof e.target.value === "string" ? e.target.value : "",
);
}}
>
<MenuItem value="">
<em>{t("All")}</em>
</MenuItem>
{PRODUCT_PROCESS_STATUS_FILTER_VALUES.map((v) => (
<MenuItem key={v} value={v}>
{t(JOB_DASHBOARD_PP_STATUS_I18N[v])}
</MenuItem>
))}
</Select>
</FormControl>

<Box sx={{ flexGrow: 1 }} />
<Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }} suppressHydrationWarning>
{t("Now")}: {currentTime.format('HH:mm')}

<Stack direction="row" spacing={2} sx={{ alignSelf: "center" }}>
<Typography
variant="body2"
sx={{ color: "text.secondary" }}
suppressHydrationWarning
>
{t("Now")}: {currentTime.format("HH:mm")}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t("Auto-refresh every 10 minutes")} | {t("Last updated")}: {lastDataRefreshTime ? lastDataRefreshTime.format('HH:mm:ss') : '--:--:--'}

<Typography variant="body2" sx={{ color: "text.secondary" }}>
{t("Auto-refresh every 10 minutes")} | {t("Last updated")}:{" "}
{lastDataRefreshTime
? lastDataRefreshTime.format("HH:mm:ss")
: "--:--:--"}
</Typography>
</Stack>
</Stack>

<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ mb: 2, flexWrap: "wrap", gap: 1 }}
>
<Typography variant="body2" color="text.secondary">
{t("Job process detail mode label")}
</Typography>
<ToggleButtonGroup
value={cellDetailMode}
exclusive
size="small"
color="primary"
onChange={(_, value: ProcessCellDetailMode | null) => {
if (value != null) setCellDetailMode(value);
}}
>
<ToggleButton value="time">
{t("Job process detail: time")}
</ToggleButton>
<ToggleButton value="processName">
{t("Job process detail: process name")}
</ToggleButton>
<ToggleButton value="equipment">
{t("Job process detail: equipment")}
</ToggleButton>
<ToggleButton value="handler">
{t("Job process detail: handler")}
</ToggleButton>
</ToggleButtonGroup>
</Stack>

{maxProcessSlots > 0 && (
<Stack
direction="row"
spacing={2}
alignItems="center"
sx={{ mb: 2, flexWrap: "wrap", gap: 1 }}
>
<Button
variant="outlined"
size="small"
disabled={processPage <= 0}
onClick={() => setProcessPage((p) => Math.max(0, p - 1))}
>
{t("Previous page")}
</Button>

<Typography variant="body2" color="text.secondary">
{t("Process page summary", {
from: rangeFrom,

to: rangeTo,

total: maxProcessSlots,
})}
</Typography>

<Button
variant="outlined"
size="small"
disabled={processPage >= totalPages - 1}
onClick={() =>
setProcessPage((p) => Math.min(totalPages - 1, p + 1))
}
>
{t("Next page")}
</Button>
</Stack>
)}

<Box sx={{ mt: 2 }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : (
<TableContainer
component={Paper}
component={Paper}
sx={{
border: "3px solid #135fed",

maxHeight: 540,

overflow: "auto",

overflowX: "hidden",
}}
>
<Table
size="small"
sx={{
border: '3px solid #135fed',
overflowX: 'auto',
maxHeight: 540,
overflow: 'auto'
width: "100%",

tableLayout: "fixed",
}}
>
<Table size="small" sx={{ minWidth: 1800 }}>
<TableHead sx={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'grey.100' }}>
<TableRow sx={{ backgroundColor: 'grey.100' }}>
<TableCell rowSpan={3} sx={{ padding: '16px 20px' }}>
<TableHead
sx={{
position: "sticky",

top: 0,

zIndex: 1,

backgroundColor: "grey.100",
}}
>
<TableRow sx={{ backgroundColor: "grey.100" }}>
<TableCell
rowSpan={2}
sx={{
padding: "12px 10px",

width: "12%",

verticalAlign: "middle",
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Job Order No.")}
</Typography>
</TableCell>
<TableCell rowSpan={3} sx={{ padding: '16px 20px' }}>

<TableCell
rowSpan={2}
sx={{
padding: "12px 10px",

width: "18%",

verticalAlign: "middle",
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("FG / WIP Item")}
</Typography>
</TableCell>
<TableCell rowSpan={3} sx={{ padding: '16px 20px' }}>

<TableCell
rowSpan={2}
sx={{
padding: "12px 10px",

width: "10%",

verticalAlign: "middle",
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Production Time Remaining")}
</Typography>
</TableCell>
</TableRow>
<TableRow sx={{ backgroundColor: 'grey.100' }}>
{Array.from({ length: 16 }, (_, i) => i + 1).map((num) => (
<TableCell key={num} sx={{ padding: '16px 20px', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Process")} {num}
</Typography>
</TableCell>
))}
</TableRow>
<TableRow sx={{ backgroundColor: 'grey.100' }}>
{Array.from({ length: 16 }, (_, i) => i + 1).map((num) => (
<TableCell key={num} sx={{ padding: '16px 20px', minWidth: 150 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{t("Start")}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{t("Finish")}

{maxProcessSlots > 0 &&
Array.from({ length: visibleCount }, (_, i) => (
<TableCell
key={`h1-${startSlotIndex + i}`}
sx={{ padding: "10px 8px" }}
align="center"
>
<Typography
variant="subtitle2"
sx={{ fontWeight: 600 }}
>
{t("Process")} {startSlotIndex + i + 1}
</Typography>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{t("Wait Time [minutes]")}
</TableCell>
))}
</TableRow>

<TableRow sx={{ backgroundColor: "grey.100" }}>
{maxProcessSlots > 0 &&
Array.from({ length: visibleCount }, (_, i) => (
<TableCell
key={`h3-${startSlotIndex + i}`}
sx={{ padding: "10px 8px" }}
align="center"
>
<Typography
variant="caption"
sx={{ fontWeight: 600 }}
>
{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")}
</Typography>
</Box>
</TableCell>
))}
</TableCell>
))}
</TableRow>
</TableHead>

<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center" sx={{ padding: '20px' }}>
<TableCell
colSpan={Math.max(3, 3 + visibleCount)}
align="center"
sx={{ padding: "20px" }}
>
{t("No data available")}
</TableCell>
</TableRow>
) : maxProcessSlots === 0 ? (
<TableRow>
<TableCell
colSpan={3}
align="center"
sx={{ padding: "20px" }}
>
{t("No data available")}
</TableCell>
</TableRow>
) : (
data.map((row) => (
<TableRow key={row.jobOrderId}>
<TableCell sx={{ padding: '16px 20px' }}>
{row.jobOrderCode || '-'}
<TableCell
sx={{ padding: "10px", verticalAlign: "top" }}
>
{row.jobOrderCode || "-"}
</TableCell>
<TableCell sx={{ padding: '16px 20px' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>{row.itemCode || '-'}</Box>
<Box>{row.itemName || '-'}</Box>

<TableCell
sx={{ padding: "10px", verticalAlign: "top" }}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 0.5,
}}
>
{row.itemCode || "-"}
</Box>

<Box>{row.itemName || "-"}</Box>
</TableCell>
<TableCell sx={{ padding: '16px 20px' }}>

{row.status === 'pending' ? '-' : calculateRemainingTime(row.planEndTime, row.processingTime, row.setupTime, row.changeoverTime)}
<TableCell
sx={{ padding: "10px", verticalAlign: "top" }}
>
{row.status === "pending"
? "-"
: calculateRemainingTime(row.planEndTime)}
</TableCell>
{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 (
<TableCell key={index} sx={{ padding: '16px 20px', minWidth: 150 }}>
<Typography variant="body2">
N/A
</Typography>
</TableCell>
);
}
const label = [
process.processName,
process.equipmentName,
process.equipmentDetailName ? `-${process.equipmentDetailName}` : "",
].filter(Boolean).join(" ");
// 如果工序是必需的,显示三行(Start、Finish、Wait Time)
return (
<TableCell key={index} sx={{ padding: '16px 20px', minWidth: 150 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography variant="body2" sx={{ mb: 0.5 }}>{label || "-"}</Typography>
<Typography variant="body2" sx={{ py: 0.5 }}>
{formatTime(process.startTime)}
</Typography>
<Typography variant="body2" sx={{ py: 0.5 }}>
{formatTime(process.endTime)}
</Typography>
<Typography variant="body2" sx={{
color: waitTime !== '-' && parseInt(waitTime) > 0 ? 'warning.main' : 'text.primary',
py: 0.5
}}>
{waitTime}
</Typography>
</Box>
</TableCell>
);
})}

{Array.from({ length: visibleCount }, (_, i) =>
renderProcessCell(row, startSlotIndex + i),
)}
</TableRow>
))
)}
@@ -344,11 +724,9 @@ const JobProcessStatus: React.FC = () => {
</TableContainer>
)}
</Box>

</CardContent>
</Card>
);
};

export default JobProcessStatus;
export default JobProcessStatus;

+ 10
- 1
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"
}

+ 1
- 1
src/i18n/index.tsx Просмотреть файл

@@ -43,7 +43,7 @@ const languageDetector: LanguageDetectorAsyncModule = {

const initI18next = async (namespaces: string[]): Promise<i18n> => {
const label = `[i18n] initI18next ns=${namespaces.join(",")}`;
console.time(label);
//console.time(label);
const i18nInstance = createInstance();
await i18nInstance
.use(languageDetector)


+ 23
- 1
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": "等待時間",


+ 10
- 1
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":"樓層",


+ 21
- 0
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": "工單編號/批號",


Загрузка…
Отмена
Сохранить