Browse Source

update dashBoard

MergeProblem1
CANCERYS\kw093 20 hours ago
parent
commit
5e6a440aae
12 changed files with 912 additions and 57 deletions
  1. +79
    -1
      src/app/api/jo/actions.ts
  2. +5
    -3
      src/components/Jodetail/JobPickExecutionForm.tsx
  3. +363
    -0
      src/components/ProductionProcess/EquipmentStatusDashboard.tsx
  4. +30
    -23
      src/components/ProductionProcess/JobProcessStatus.tsx
  5. +343
    -0
      src/components/ProductionProcess/OperatorKpiDashboard.tsx
  6. +25
    -10
      src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx
  7. +11
    -1
      src/components/ProductionProcess/ProductionProcessPage.tsx
  8. +3
    -2
      src/components/StockIssue/SubmitIssueForm.tsx
  9. +27
    -9
      src/i18n/zh/common.json
  10. +3
    -0
      src/i18n/zh/do.json
  11. +5
    -0
      src/i18n/zh/inventory.json
  12. +18
    -8
      src/i18n/zh/jo.json

+ 79
- 1
src/app/api/jo/actions.ts View File

@@ -1218,7 +1218,6 @@ export interface ProcessStatusInfo {
isRequired: boolean;
}


export interface JobProcessStatusResponse {
jobOrderId: number;
jobOrderCode: string;
@@ -1244,6 +1243,85 @@ export const fetchJobProcessStatus = cache(async (date?: string) => {
next: { tags: ["jobProcessStatus"] },
});
});

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

export interface OperatorKpiProcessInfo {
jobOrderId?: number | null;
jobOrderCode?: string | null;
productProcessId?: number | null;
productProcessLineId?: number | null;
processName?: string | null;
equipmentName?: string | null;
equipmentDetailName?: string | null;
startTime?: string | number[] | null;
endTime?: string | number[] | null;
processingTime?: number | null;
itemCode?: string | null;
itemName?: string | null;
}

export interface OperatorKpiResponse {
operatorId: number;
operatorName?: string | null;
staffNo?: string | null;
totalProcessingMinutes: number;
totalJobOrderCount: number;
currentProcesses: OperatorKpiProcessInfo[];
}

export const fetchOperatorKpi = cache(async (date?: string) => {
const params = new URLSearchParams();
if (date) params.set("date", date);
const qs = params.toString();
const url = `${BASE_API_URL}/product-process/Demo/OperatorKpi${qs ? `?${qs}` : ""}`;

return serverFetchJson<OperatorKpiResponse[]>(url, {
method: "GET",
next: { tags: ["operatorKpi"] },
});
});

// ===== Equipment Status Dashboard =====

export interface EquipmentStatusProcessInfo {
jobOrderId?: number | null;
jobOrderCode?: string | null;
productProcessId?: number | null;
productProcessLineId?: number | null;
processName?: string | null;
operatorName?: string | null;
startTime?: string | number[] | null;
processingTime?: number | null;
}

export interface EquipmentStatusPerDetail {
equipmentDetailId: number;
equipmentDetailCode?: string | null;
equipmentDetailName?: string | null;
equipmentId?: number | null;
equipmentTypeName?: string | null;
status: string;
repairAndMaintenanceStatus?: boolean | null;
latestRepairAndMaintenanceDate?: string | null;
lastRepairAndMaintenanceDate?: string | null;
repairAndMaintenanceRemarks?: string | null;
currentProcess?: EquipmentStatusProcessInfo | null;
}

export interface EquipmentStatusByTypeResponse {
equipmentTypeId: number;
equipmentTypeName?: string | null;
details: EquipmentStatusPerDetail[];
}

export const fetchEquipmentStatus = cache(async () => {
const url = `${BASE_API_URL}/product-process/Demo/EquipmentStatus`;
return serverFetchJson<EquipmentStatusByTypeResponse[]>(url, {
method: "GET",
next: { tags: ["equipmentStatus"] },
});
});
export const deleteProductProcessLine = async (lineId: number) => {
return serverFetchJson<any>(
`${BASE_API_URL}/product-process/Demo/ProcessLine/delete/${lineId}`,


+ 5
- 3
src/components/Jodetail/JobPickExecutionForm.tsx View File

@@ -243,10 +243,12 @@ useEffect(() => {
return;
}
// ✅ 只允许 Verified>0 且没有问题时,走 normal pick
// 增加 badPackageQty 判断,确保有坏包装会走 issue 流程
const badPackageQty = Number((formData as any).badPackageQty) || 0;
const isNormalPick = verifiedQty > 0
&& formData.missQty == 0
&& formData.badItemQty == 0;
&& formData.missQty == 0
&& formData.badItemQty == 0
&& badPackageQty == 0;
if (isNormalPick) {
if (onNormalPickSubmit) {


+ 363
- 0
src/components/ProductionProcess/EquipmentStatusDashboard.tsx View File

@@ -0,0 +1,363 @@
"use client";

import React, { useState, useEffect, useCallback } from "react";
import {
Box,
Card,
CardContent,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Typography,
Tabs,
Tab,
Chip,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import dayjs from "dayjs";
import {
fetchEquipmentStatus,
EquipmentStatusByTypeResponse,
EquipmentStatusPerDetail,
} from "@/app/api/jo/actions";
import { arrayToDayjs } from "@/app/utils/formatUtil";

const REFRESH_INTERVAL = 60 * 1000; // 1 分鐘

const STATUS_COLORS: Record<string, "success" | "default" | "warning" | "error"> = {
Processing: "success",
Idle: "default",
Repair: "warning",
};

const formatDateTime = (value: any): string => {
if (!value) return "-";

if (Array.isArray(value)) {
try {
const parsed = arrayToDayjs(value, true);
if (parsed.isValid()) {
return parsed.format("YYYY-MM-DD HH:mm");
}
} catch (e) {
console.error("Error parsing datetime array:", e);
}
}

if (typeof value === "string") {
const parsed = dayjs(value);
if (parsed.isValid()) {
return parsed.format("YYYY-MM-DD HH:mm");
}
}

return "-";
};

const formatTime = (value: any): string => {
if (!value) return "-";

if (Array.isArray(value)) {
try {
const parsed = arrayToDayjs(value, true);
if (parsed.isValid()) {
return parsed.format("HH:mm");
}
} catch (e) {
console.error("Error parsing time array:", e);
}
}

if (typeof value === "string") {
const parsed = dayjs(value);
if (parsed.isValid()) {
return parsed.format("HH:mm");
}
}

return "-";
};

// 计算预计完成时间
const calculateEstimatedCompletionTime = (
startTime: any,
processingTime: number | null | undefined
): string => {
if (!startTime || !processingTime || processingTime <= 0) return "-";

try {
const start = arrayToDayjs(startTime, true);
if (!start.isValid()) return "-";

const estimated = start.add(processingTime, "minute");
return estimated.format("YYYY-MM-DD HH:mm");
} catch (e) {
console.error("Error calculating estimated completion time:", e);
return "-";
}
};

// 计算剩余时间(分钟)
const calculateRemainingTime = (
startTime: any,
processingTime: number | null | undefined
): string => {
if (!startTime || !processingTime || processingTime <= 0) return "-";

try {
const start = arrayToDayjs(startTime, true);
if (!start.isValid()) return "-";

const now = dayjs();
const estimated = start.add(processingTime, "minute");
const remainingMinutes = estimated.diff(now, "minute");

if (remainingMinutes < 0) {
return `-${Math.abs(remainingMinutes)}`;
}
return remainingMinutes.toString();
} catch (e) {
console.error("Error calculating remaining time:", e);
return "-";
}
};

const EquipmentStatusDashboard: React.FC = () => {
const { t } = useTranslation(["common", "jo"]);
const [data, setData] = useState<EquipmentStatusByTypeResponse[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [tabIndex, setTabIndex] = useState<number>(0);

const loadData = useCallback(async () => {
setLoading(true);
try {
const result = await fetchEquipmentStatus();
setData(result || []);
} catch (error) {
console.error("Error fetching equipment status:", error);
setData([]);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
loadData();
const interval = setInterval(() => {
loadData();
}, REFRESH_INTERVAL);
return () => clearInterval(interval);
}, [loadData]);

// 添加定时更新剩余时间
useEffect(() => {
const timer = setInterval(() => {
// 触发重新渲染以更新剩余时间
setData((prev) => [...prev]);
}, 60000); // 每分钟更新一次
return () => clearInterval(timer);
}, []);

const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
setTabIndex(newValue);
};

const displayTypes =
tabIndex === 0
? data
: data.filter((_, index) => index === tabIndex - 1);

return (
<Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
{t("Production Equipment Status Dashboard")}
</Typography>
</Box>

<Tabs
value={tabIndex}
onChange={handleTabChange}
sx={{ mb: 2 }}
variant="scrollable"
scrollButtons="auto"
>
<Tab label={t("All")} />
{data.map((type, index) => (
<Tab
key={type.equipmentTypeId}
label={type.equipmentTypeName || `${t("Equipment Type")} ${index + 1}`}
/>
))}
</Tabs>

{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : displayTypes.length === 0 ? (
<Box sx={{ textAlign: "center", p: 3 }}>
{t("No data available")}
</Box>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{displayTypes.map((type) => {
const details = type.details || [];
if (details.length === 0) return null;

return (
<Card
key={type.equipmentTypeId}
sx={{
border: "3px solid #135fed",
overflowX: "auto",
}}
>
<CardContent>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
{type.equipmentTypeName || "-"}
</Typography>

<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Equipment Name and Code")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId} align="center">
<Box sx={{ display: "flex", flexDirection: "column" }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{d.equipmentDetailName || "-"}
</Typography>
<Typography variant="caption" color="text.secondary">
{d.equipmentDetailCode || "-"}
</Typography>
</Box>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{/* 工序 Row */}
<TableRow>
<TableCell>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Process")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId} align="center">
{d.status === "Processing" ? d.currentProcess?.processName || "-" : "-"}
</TableCell>
))}
</TableRow>

{/* 狀態 Row - 修改:Processing 时只显示 job order code */}
<TableRow>
<TableCell>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Status")}
</Typography>
</TableCell>
{details.map((d) => {
const chipColor = STATUS_COLORS[d.status] || "default";
const cp = d.currentProcess;
// Processing 时只显示 job order code,不显示 Chip
if (d.status === "Processing" && cp?.jobOrderCode) {
return (
<TableCell key={d.equipmentDetailId} align="center">
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{cp.jobOrderCode}
</Typography>
</TableCell>
);
}
// 其他状态显示 Chip
return (
<TableCell key={d.equipmentDetailId} align="center">
<Chip label={t(`${d.status}`)} color={chipColor} size="small" />
</TableCell>
);
})}
</TableRow>


{/* 開始時間 Row */}
<TableRow>
<TableCell>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Start Time")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId} align="center">
{d.status === "Processing"
? formatDateTime(d.currentProcess?.startTime)
: "-"}
</TableCell>
))}
</TableRow>

{/* 預計完成時間 Row */}
<TableRow>
<TableCell>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("預計完成時間")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId} align="center">
{d.status === "Processing"
? calculateEstimatedCompletionTime(
d.currentProcess?.startTime,
d.currentProcess?.processingTime
)
: "-"}
</TableCell>
))}
</TableRow>

{/* 剩餘時間 Row */}
<TableRow>
<TableCell>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Remaining Time (min)")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId} align="center">
{d.status === "Processing"
? calculateRemainingTime(
d.currentProcess?.startTime,
d.currentProcess?.processingTime
)
: "-"}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
);
})}
</Box>
)}
</Box>
);
};

export default EquipmentStatusDashboard;

+ 30
- 23
src/components/ProductionProcess/JobProcessStatus.tsx View File

@@ -173,7 +173,7 @@ const JobProcessStatus: React.FC = () => {
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
{t("Job Process Status")}
{t("Job Process Status Dashboard")}
</Typography>

<FormControl size="small" sx={{ minWidth: 160 }}>
@@ -194,21 +194,27 @@ const JobProcessStatus: React.FC = () => {
<CircularProgress />
</Box>
) : (
<TableContainer component={Paper}>
<Table size="small" sx={{ minWidth: 1200 }}>
<TableContainer
component={Paper}
sx={{
border: '3px solid #135fed',
overflowX: 'auto', // 关键:允许横向滚动
}}
>
<Table size="small" sx={{ minWidth: 1800 }}>
<TableHead>
<TableRow>
<TableCell rowSpan={3}>
<TableCell rowSpan={3} sx={{ padding: '16px 20px' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Job Order No.")}
</Typography>
</TableCell>
<TableCell rowSpan={3}>
<TableCell rowSpan={3} sx={{ padding: '16px 20px' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("FG / WIP Item")}
</Typography>
</TableCell>
<TableCell rowSpan={3}>
<TableCell rowSpan={3} sx={{ padding: '16px 20px' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Production Time Remaining")}
</Typography>
@@ -216,8 +222,8 @@ const JobProcessStatus: React.FC = () => {
</TableRow>
<TableRow>
{[1, 2, 3, 4, 5, 6].map((num) => (
<TableCell key={num} align="center">
{Array.from({ length: 16 }, (_, i) => i + 1).map((num) => (
<TableCell key={num} align="center" sx={{ padding: '16px 20px', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Process")} {num}
</Typography>
@@ -225,9 +231,9 @@ const JobProcessStatus: React.FC = () => {
))}
</TableRow>
<TableRow>
{[1, 2, 3, 4, 5, 6].map((num) => (
<TableCell key={num} align="center">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{Array.from({ length: 16 }, (_, i) => i + 1).map((num) => (
<TableCell key={num} align="center" sx={{ padding: '16px 20px', minWidth: 150 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600 }}>
{t("Start")}
</Typography>
@@ -245,21 +251,21 @@ const JobProcessStatus: React.FC = () => {
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center">
<TableCell colSpan={9} align="center" sx={{ padding: '20px' }}>
{t("No data available")}
</TableCell>
</TableRow>
) : (
data.map((row) => (
<TableRow key={row.jobOrderId}>
<TableCell>
<TableCell sx={{ padding: '16px 20px' }}>
{row.jobOrderCode || '-'}
</TableCell>
<TableCell>
<Box>{row.itemCode || '-'}</Box>
<TableCell sx={{ padding: '16px 20px' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>{row.itemCode || '-'}</Box>
<Box>{row.itemName || '-'}</Box>
</TableCell>
<TableCell>
<TableCell sx={{ padding: '16px 20px' }}>

{row.status === 'pending' ? '-' : calculateRemainingTime(row.planEndTime, row.processingTime, row.setupTime, row.changeoverTime)}
</TableCell>
@@ -276,7 +282,7 @@ const JobProcessStatus: React.FC = () => {
// 如果工序不是必需的,只显示一个 N/A
if (!process.isRequired) {
return (
<TableCell key={index} align="center">
<TableCell key={index} align="center" sx={{ padding: '16px 20px', minWidth: 150 }}>
<Typography variant="body2">
N/A
</Typography>
@@ -290,17 +296,18 @@ const JobProcessStatus: React.FC = () => {
].filter(Boolean).join(" ");
// 如果工序是必需的,显示三行(Start、Finish、Wait Time)
return (
<TableCell key={index} align="center">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Typography variant="body2">{label || "-"}</Typography>
<Typography variant="body2">
<TableCell key={index} align="center" 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">
<Typography variant="body2" sx={{ py: 0.5 }}>
{formatTime(process.endTime)}
</Typography>
<Typography variant="body2" sx={{
color: waitTime !== '-' && parseInt(waitTime) > 0 ? 'warning.main' : 'text.primary'
color: waitTime !== '-' && parseInt(waitTime) > 0 ? 'warning.main' : 'text.primary',
py: 0.5
}}>
{waitTime}
</Typography>


+ 343
- 0
src/components/ProductionProcess/OperatorKpiDashboard.tsx View File

@@ -0,0 +1,343 @@
"use client";

import React, { useState, useEffect, useCallback, useRef } from "react";
import {
Box,
Card,
CardContent,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Typography,
FormControl,
Select,
MenuItem,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import dayjs from "dayjs";
import { fetchOperatorKpi, OperatorKpiResponse, OperatorKpiProcessInfo } from "@/app/api/jo/actions";
import { arrayToDayjs } from "@/app/utils/formatUtil";

const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 分鐘

const OperatorKpiDashboard: React.FC = () => {
const { t } = useTranslation(["common", "jo"]);
const [data, setData] = useState<OperatorKpiResponse[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [selectedDate, setSelectedDate] = useState(dayjs().format("YYYY-MM-DD"));
const refreshCountRef = useRef<number>(0);

const formatTime = (timeData: any): string => {
if (!timeData) return "-";

if (Array.isArray(timeData)) {
try {
const parsed = arrayToDayjs(timeData, true);
if (parsed.isValid()) {
return parsed.format("HH:mm");
}
} catch (e) {
console.error("Error parsing time array:", e);
}
}

if (typeof timeData === "string") {
const parsed = dayjs(timeData);
if (parsed.isValid()) {
return parsed.format("HH:mm");
}
}

return "-";
};

const formatMinutesToHHmm = (minutes: number): string => {
if (!minutes || minutes <= 0) return "00:00";
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours.toString().padStart(2, "0")}:${mins.toString().padStart(2, "0")}`;
};

const loadData = useCallback(async () => {
setLoading(true);
try {
const result = await fetchOperatorKpi(selectedDate);
setData(result);
refreshCountRef.current += 1;
} catch (error) {
console.error("Error fetching operator KPI:", error);
setData([]);
} finally {
setLoading(false);
}
}, [selectedDate]);

useEffect(() => {
loadData();
const interval = setInterval(() => {
loadData();
}, REFRESH_INTERVAL);
return () => clearInterval(interval);
}, [loadData]);

const renderCurrentProcesses = (processes: OperatorKpiProcessInfo[]) => {
if (!processes || processes.length === 0) {
return (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
-
</Typography>
);
}
// 只顯示目前一個處理中的工序(樣式比照 Excel:欄位名稱縱向排列)
const p = processes[0];
const jobOrder = p.jobOrderCode ? `[${p.jobOrderCode}]` : "-";
const itemInfo = p.itemCode && p.itemName
? `${p.itemCode} - ${p.itemName}`
: p.itemCode || p.itemName || "-";

// 格式化所需時間(分鐘轉換為 HH:mm)
const formatRequiredTime = (minutes: number | null | undefined): string => {
if (!minutes || minutes <= 0) return "-";
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours.toString().padStart(2, "0")}:${mins.toString().padStart(2, "0")}`;
};
// 計算預計完成時間
const calculateEstimatedCompletionTime = (): string => {
if (!p.startTime || !p.processingTime || p.processingTime <= 0) return "-";
try {
const start = arrayToDayjs(p.startTime, true);
if (!start.isValid()) return "-";
const estimated = start.add(p.processingTime, "minute");
return estimated.format("HH:mm");
} catch (e) {
console.error("Error calculating estimated completion time:", e);
return "-";
}
};
return (
<>
<Typography variant="body2" sx={{ lineHeight: 1.6 }}>
{t("Job Order and Product")}: {jobOrder} {itemInfo}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.6 }}>
{t("Process")}: {p.processName || "-"}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.6 }}>
{t("Start Time")}: {formatTime(p.startTime)}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.6 }}>
{t("Required Time")}: {formatRequiredTime(p.processingTime)}
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.6 }}>
{t("Estimated Completion Time")}: {calculateEstimatedCompletionTime()}
</Typography>
</>
);
};

return (
<Card sx={{ mb: 2 }}>
<CardContent>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
{t("Operator KPI Dashboard")}
</Typography>

<FormControl size="small" sx={{ minWidth: 160 }}>
<Select
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
>
<MenuItem value={dayjs().format("YYYY-MM-DD")}>{t("Today")}</MenuItem>
<MenuItem value={dayjs().subtract(1, "day").format("YYYY-MM-DD")}>{t("Yesterday")}</MenuItem>
<MenuItem value={dayjs().subtract(2, "day").format("YYYY-MM-DD")}>{t("Two Days Ago")}</MenuItem>
</Select>
</FormControl>
</Box>

{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : (
<TableContainer
component={Paper}
sx={{
border: "3px solid #135fed",
overflowX: "auto",
}}
>
<Table size="small" sx={{ minWidth: 800 }}>
<TableHead>
<TableRow
sx={{
bgcolor: "#424242",
"& th": {
borderBottom: "none",
py: 1.5,
},
}}
>
<TableCell sx={{ width: 80 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
//color: "#ffffff",
}}
>
{t("No.")}
</Typography>
</TableCell>
<TableCell sx={{ minWidth: 280 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
//color: "#ffffff",
}}
>
{t("Operator")}
</Typography>
</TableCell>
<TableCell sx={{ minWidth: 300 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
//color: "#ffffff",
}}
>
{t("Job Details")}
</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
{t("No data available")}
</TableCell>
</TableRow>
) : (
data.map((row, index) => {
const jobOrderCount = row.totalJobOrderCount || 0;

return (
<TableRow
key={row.operatorId}
sx={{
"&:hover": {
bgcolor: "#f9f9f9",
},
"& td": {
borderBottom: "1px solid #e0e0e0",
py: 2,
verticalAlign: "top",
},
}}
>
<TableCell
sx={{
width: 80,
textAlign: "center",
fontWeight: 500,
verticalAlign: "top",
}}
>
{index + 1}
</TableCell>
<TableCell
sx={{
minWidth: 280,
padding: 0,
verticalAlign: "top",
height: "100%",
}}
>
<Box
sx={{
p: 1.5,
display: "flex",
flexDirection: "column",
gap: 0.75,
bgcolor: "#f5f5f5",
border: "1px solid #e0e0e0",
borderRadius: 1.5,
boxSizing: "border-box",
height: "180px",

}}
>
<Typography variant="body2" sx={{ lineHeight: 1.6 }}>
{t("Operator Name & No.")}:{" "}
<Box component="span" sx={{ fontWeight: 500 }}>
{row.operatorName || "-"}{" "}
{row.staffNo ? `(${row.staffNo})` : ""}
</Box>
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.6 }}>
{t("Count of Job Orders")}:{" "}
<Box component="span" sx={{ fontWeight: 500 }}>
{jobOrderCount}
</Box>
</Typography>
<Typography variant="body2" sx={{ lineHeight: 1.6 }}>
{t("Total Processing Time")}:{" "}
<Box component="span" sx={{ fontWeight: 500 }}>
{formatMinutesToHHmm(row.totalProcessingMinutes || 0)}
</Box>
</Typography>
</Box>
</TableCell>
<TableCell
sx={{
minWidth: 300,
padding: 0,
verticalAlign: "top",
height: "100%",
}}
>
<Box
sx={{
p: 1.5,
display: "flex",
flexDirection: "column",
gap: 0.75,
bgcolor: "#f5f5f5",
border: "1px solid #e0e0e0",
borderRadius: 1.5,
boxSizing: "border-box",
height: "180px",
}}
>
{renderCurrentProcesses(row.currentProcesses)}
</Box>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
);
};

export default OperatorKpiDashboard;


+ 25
- 10
src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx View File

@@ -246,7 +246,13 @@ const isStockSufficient = (line: JobOrderLineInfo) => {
const stockCounts = useMemo(() => {
// 过滤掉 consumables 类型的 lines
const nonConsumablesLines = jobOrderLines.filter(
line => line.type?.toLowerCase() !== "consumables" && line.type?.toLowerCase() !== "cmb" && line.type?.toLowerCase() !== "nm"
line => {
const type = line.type?.toLowerCase();
return type !== "consumables" &&
type !== "consumable" && // ✅ 添加单数形式
type !== "cmb" &&
type !== "nm"
}
);
const total = nonConsumablesLines.length;
const sufficient = nonConsumablesLines.filter(isStockSufficient).length;
@@ -473,7 +479,7 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
field: "itemCode",
headerName: t("Material Code"),
flex: 0.6,
sortable: false, // ✅ 禁用排序
sortable: false,
},
{
field: "itemName",
@@ -490,11 +496,11 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
flex: 0.7,
align: "right",
headerAlign: "right",
sortable: false, // ✅ 禁用排序
// ✅ 将切换功能移到 header
sortable: false,
renderHeader: () => {
const qty = showBaseQty ? t("Base") : t("Req");
const uom = showBaseQty ? t("Base UOM") : t(" ");
const uom = showBaseQty ? t("Base UOM") : t("Bom Uom");
return (
<Box
onClick={toggleBaseQty}
@@ -508,7 +514,10 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
},
}}
>
{t("Bom Req. Qty")} ({uom})
<Typography variant="body2">
{t("Bom Req. Qty")}<br/>
({uom})
</Typography>
</Box>
);
},
@@ -547,7 +556,10 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
},
}}
>
{t("Stock Req. Qty")} ({uom})
<Typography variant="body2">
{t("Stock Req. Qty")} <br/>
({uom})
</Typography>
</Box>
);
},
@@ -587,7 +599,10 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
},
}}
>
{t("Stock Available")} ({uom})
<Typography variant="body2">
{t("Stock Available")} <br/>
({uom})
</Typography>
</Box>
);
},
@@ -684,7 +699,7 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
</Card>

<StyledDataGrid
sx={{ "--DataGrid-overlayHeight": "100px" }}
sx={{ "--DataGrid-overlayHeight": "200px" }}
disableColumnMenu
rows={pickTableRows}
columns={pickTableColumns}


+ 11
- 1
src/components/ProductionProcess/ProductionProcessPage.tsx View File

@@ -9,6 +9,8 @@ import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/Prod
import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan";
import FinishedQcJobOrderList from "@/components/ProductionProcess/FinishedQcJobOrderList";
import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus";
import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard";
import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard";
import {
fetchProductProcesses,
fetchProductProcessesByJobOrderId,
@@ -165,7 +167,9 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
<Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}>
<Tab label={t("Production Process")} />
<Tab label={t("Finished QC Job Orders")} />
<Tab label={t("Job Process Status")} />
<Tab label={t("Job Process Status Dashboard")} />
<Tab label={t("Operator KPI Dashboard")} />
<Tab label={t("Production Equipment Status Dashboard")} />
</Tabs>

{tabIndex === 0 && (
@@ -195,6 +199,12 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
{tabIndex === 2 && (
<JobProcessStatus />
)}
{tabIndex === 3 && (
<OperatorKpiDashboard />
)}
{tabIndex === 4 && (
<EquipmentStatusDashboard />
)}
</Box>
);
};


+ 3
- 2
src/components/StockIssue/SubmitIssueForm.tsx View File

@@ -76,7 +76,8 @@ const SubmitIssueForm: React.FC<Props> = ({
};

const handleSubmit = async () => {
if (!lotId || !submitQty || parseFloat(submitQty) <= 0) {

if (!lotId || !submitQty || parseFloat(submitQty) < 0) {
alert(t("Please enter a valid quantity"));
return;
}
@@ -175,7 +176,7 @@ const SubmitIssueForm: React.FC<Props> = ({
<Button
onClick={handleSubmit}
variant="contained"
disabled={submitting || !submitQty || parseFloat(submitQty) <= 0}
disabled={submitting || !submitQty || parseFloat(submitQty) < 0}
>
{submitting ? t("Submitting...") : t("Submit")}
</Button>


+ 27
- 9
src/i18n/zh/common.json View File

@@ -15,7 +15,7 @@
"Search": "搜索",
"This lot is rejected, please scan another lot.": "此批次已封存,請掃描另一個批號。",
"Process Start Time": "工序開始時間",
"Stock Req. Qty": "需求數(庫存單位)",
"Stock Req. Qty": "需求數",
"Staff No Required": "員工編號必填",
"User Not Found": "用戶不存在",
"Time Remaining": "剩餘時間",
@@ -52,7 +52,7 @@
"No": "沒有",
"Assignment failed: ": "分配失敗: ",
"Unknown error": "未知錯誤",
"Job Process Status": "工單流程狀態",
"Job Process Status Dashboard": "儀表板 - 工單狀態",
"Total Time": "總時間",
"Remaining Time": "剩餘時間",
@@ -94,7 +94,7 @@
"Deliver Order": "送貨訂單",
"Project": "專案",
"Product": "產品",
"Material": "材料",
"mat": "原料",
"consumables": "消耗品",
"non-consumables": "非消耗品",
@@ -109,7 +109,7 @@
"Detail Scheduling": "詳細排程",
"Customer": "客戶",
"qcItem": "品檢項目",
"Item": "成品/半成品",
"Today": "今天",
"Yesterday": "昨天",
"Input Equipment is not match with process": "輸入的設備與流程不匹配",
@@ -209,9 +209,10 @@
"Row per page": "每頁行數",
"Select Unit": "選擇單位",
"No data available": "沒有資料",
"Bom Req. Qty": "需求數(BOM單位)",
"Bom Req. Qty": "BOM",
"Material Name": "材料清單",
"Material Code": "材料清單",
"Bom UOM": "使用單位",
"Base UOM": "基本單位",
"Stock UOM": "庫存單位",
"jodetail": "工單細節",
@@ -238,7 +239,7 @@
"Is Dense": "濃淡",
"Is Float": "浮沉",
"Job Order Code": "工單編號",
"Operator": "操作員",
"Output Qty": "輸出數量",
"Pending": "待處理",
"pending": "待處理",
@@ -264,10 +265,10 @@
"Validation failed. Please check operator and equipment.": "驗證失敗. 請檢查操作員和設備.",
"View": "查看",
"Back": "返回",
"BoM Material": "成品/半成品清單",
"BoM Material": "材料清單",
"N/A": "不適用",
"Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "顔色深淺度 | 濃淡 | 浮沉 | 損耗率 | 過敏原 | 時間次序 | 複雜度",
"Item Code": "成品/半成品名稱",
"Item Code": "材料名稱",
"Please scan equipment code": "請掃描設備編號",
"Equipment Code": "設備編號",
"Seq": "步驟",
@@ -283,7 +284,7 @@
"Seq No": "加入步驟",
"Total pick orders": "總提料單數量",
"Seq No Remark": "序號明細",
"Stock Available": "庫存可用",
"Stock Available": "庫存",
"Confirm": "確認",
"Do you want to delete?": "您確定要刪除嗎?",
"Stock Status": "庫存狀態",
@@ -300,6 +301,7 @@
"update production priority": "更新生產優先序",
"Assume Time Need": "預計所需時間",
"Required Qty": "需求數",
"Bom Required Qty": "Bom 使用份量",
"Total processes": "總流程數",
"View Details": "查看詳情",
"view stockin": "品檢",
@@ -412,6 +414,22 @@
"Equipment Code": "設備編號",
"Yes": "是",
"No": "否",
"No.": "編號",
"Operator Name & No.": "操作員名稱及編號",
"Count of Job Orders": "已處理工單",
"Total Processing Time": "總工時",
"Material Pick Status": "物料提料狀態",
"Operator KPI Dashboard": "儀表板 - 操作員KPI概覽",
"Operator": "員工資訊",
"Equipment Name and Code": "設備名稱及編號",
"Remaining Time (min)": "剩餘時間(分鐘)",
"Production Equipment Status Dashboard": "儀表板 - 生產設備狀態",
"Idle": "閒置",
"Process": "工序",
"Job Details": "工單編號及生產產品",
"Required Time": "所需時間",
"Estimated Completion Time": "預計完成時間",
"Job Order and Product": "工單及貨品",
"Update Equipment Maintenance and Repair": "更新設備的維護和保養",
"Equipment Information": "設備資訊",
"Loading": "載入中...",


+ 3
- 0
src/i18n/zh/do.json View File

@@ -11,9 +11,12 @@
"Status": "來貨狀態",
"Order Date From": "訂單日期",
"Delivery Order Code": "送貨訂單編號",
"Select Remark": "選擇備註",
"Confirm Assignment": "確認分配",
"Required Date": "所需日期",
"Submit Miss Item": "提交缺貨品",
"Submit Quantity": "提交數量",
"Store": "位置",
"Lane Code": "車線號碼",
"Available Orders": "可用訂單",


+ 5
- 0
src/i18n/zh/inventory.json View File

@@ -33,6 +33,11 @@
"Start Time": "開始時間",
"Difference": "差異",
"stockTaking": "盤點中",
"Pick Order Code": "提料單編號",
"DO Order Code": "送貨單編號",
"JO Order Code": "工單編號",
"Picker Name": "提料員",

"rejected": "已拒絕",
"miss": "缺貨",
"bad": "不良",


+ 18
- 8
src/i18n/zh/jo.json View File

@@ -11,11 +11,11 @@
"Picked Qty": "已提料數量",
"Confirm All": "確認所有提料",
"Wait Time [minutes]": "等待時間(分鐘)",
"Job Process Status": "工單流程狀態",
"Job Process Status Dashboard": "儀表板 - 工單狀態",
"This lot is rejected, please scan another lot.": "此批次已拒收,請掃描另一個批次。",
"Edit": "改數",
"Just Complete": "已完成",
"Stock Req. Qty": "需求數(庫存單位)",
"Stock Req. Qty": "需求數",
"Bad Package Qty": "不良包裝數量",
"Progress": "進度",
"Search Job Order/ Create Job Order":"搜尋工單/建立工單",
@@ -100,7 +100,7 @@
"Pause Reason": "暫停原因",
"Bag Usage": "包裝袋使用記錄",
"Reason": "原因",
"Stock Available": "倉庫可用數",
"update production priority": "更新生產優先序",
"Staff No": "員工編號",
"Please scan staff no": "請掃描員工編號",
@@ -108,7 +108,7 @@
"Total lines: ": "所需貨品項目數量: ",
"Lines with sufficient stock: ": "可提料項目數量: ",
"Lines with insufficient stock: ": "未能提料項目數量: ",
"Item Name": "成品/半成品",
"Item Name": "材料名稱",
"Material Code": "材料編號",
"Select Unit": "選擇單位",
"Job Order Pickexcution": "工單提料",
@@ -212,7 +212,8 @@
"No Group": "沒有組",
"No created items": "沒有創建物料",
"Order Quantity": "需求數",
"Bom Req. Qty": "需求數(BOM單位)",
"Bom Req. Qty": "BOM",
"Bom Uom": "使用單位",
"Selected": "已選擇",
"Are you sure you want to delete this procoess?": "您確定要刪除此工序嗎?",
"Please select item": "請選擇物料",
@@ -421,7 +422,7 @@
"View": "查看",
"Back": "返回",
"N/A": "不適用",
"BoM Material": "成品/半成品清單",
"BoM Material": "材料清單",
"Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "顔色深淺度 | 濃淡 | 浮沉 | 損耗率 | 過敏原 | 時間順序 | 複雜度",
"Enter the number of cartons: ": "請輸入箱數:",
"Number of cartons": "箱數",
@@ -443,7 +444,7 @@
"Req. Qty": "需求數量",
"Seq No": "加入步驟",
"Seq No Remark": "序號明細",
"Stock Available": "庫存可用",
"Stock Available": "庫存",
"Stock Status": "庫存狀態",
"Target Production Date": "目標生產日期",
"Description": "描述",
@@ -536,13 +537,22 @@
"Finished Good Order": "成品出倉",
"finishedGood": "成品",
"Router": "執貨路線",

"Equipment Name and Code": "設備名稱及編號",
"Remaining Time (min)": "剩餘時間(分鐘)",
"Idle": "閒置",
"Repair": "維修",
"Production Equipment Status Dashboard": "儀表板 - 生產設備狀態",
"Operator KPI Dashboard": "儀表板 - 操作員KPI概覽",
"Start Scan": "開始掃碼",
"Stop Scan": "停止掃碼",
"Operator Name & No.": "操作員名稱及編號",
"Count of Job Orders": "工單數量",
"Total Processing Time": "總生產時間",
"Material Pick Status": "物料提料狀態",
"Job Order Qty": "工單數量",
"Sign out": "登出",
"Job Order No.": "工單編號",
"Operator KPI": "操作員KPI",
"FG / WIP Item": "成品/半成品",
"Production Time Remaining": "生產剩餘時間",
"Process": "工序",


Loading…
Cancel
Save