| @@ -0,0 +1,47 @@ | |||
| "use server"; | |||
| import { cache } from 'react'; | |||
| import { Pageable, serverFetchBlob, serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | |||
| //import { JobOrder, JoStatus, Machine, Operator } from "."; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | |||
| import { FileResponse } from "@/app/api/pdf/actions"; | |||
| export interface GetBagInfoResponse { | |||
| id: number; | |||
| bagId: number; | |||
| bagName: string; | |||
| lotId: number; | |||
| lotNo: string; | |||
| stockOutLineId: number; | |||
| code: string; | |||
| balanceQty: number; | |||
| } | |||
| export const getBagInfo = cache(async () => { | |||
| return serverFetchJson<GetBagInfoResponse[]>( | |||
| `${BASE_API_URL}/bag/bagInfo`, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["bagInfo"] }, | |||
| } | |||
| ); | |||
| }); | |||
| export interface CreateJoBagConsumptionRequest { | |||
| bagId: number; | |||
| bagLotLineId: number; | |||
| jobId: number; | |||
| //startQty: number; | |||
| consumedQty: number; | |||
| scrapQty: number; | |||
| } | |||
| export const createJoBagConsumption = cache(async (request: CreateJoBagConsumptionRequest) => { | |||
| return serverFetchJson<any>( | |||
| `${BASE_API_URL}/bag/createJoBagConsumption`, | |||
| { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(request), | |||
| } | |||
| ); | |||
| }); | |||
| @@ -16,6 +16,7 @@ export interface SaveJo { | |||
| type: string; | |||
| //jobType?: string; | |||
| jobTypeId?: number; | |||
| productionPriority?: number; | |||
| } | |||
| export interface SaveJoResponse { | |||
| @@ -246,6 +247,7 @@ export interface ProductProcessWithLinesResponse { | |||
| jobOrderId?: number; | |||
| jobOrderCode: string; | |||
| jobOrderStatus: string; | |||
| bomDescription: string; | |||
| jobType: string; | |||
| isDark: string; | |||
| isDense: number; | |||
| @@ -321,6 +323,7 @@ export interface AllJoborderProductProcessInfoResponse { | |||
| date: string; | |||
| matchStatus: string; | |||
| bomId?: number; | |||
| productionPriority: number; | |||
| assignedTo: number; | |||
| pickOrderId: number; | |||
| pickOrderStatus: string; | |||
| @@ -328,6 +331,7 @@ export interface AllJoborderProductProcessInfoResponse { | |||
| itemName: string; | |||
| requiredQty: number; | |||
| jobOrderId: number; | |||
| timeNeedToComplete: number; | |||
| uom: string; | |||
| stockInLineId: number; | |||
| jobOrderCode: string; | |||
| @@ -578,6 +582,11 @@ export interface JobOrderListForPrintQrCodeResponse { | |||
| stockOutLineStatus: string; | |||
| finihedTime: string; | |||
| } | |||
| export interface UpdateJoPlanStartRequest { | |||
| id: number; | |||
| planStart: string; // Format: YYYY-MM-DDTHH:mm:ss or YYYY-MM-DD | |||
| } | |||
| export const saveProductProcessIssueTime = cache(async (request: SaveProductProcessIssueTimeRequest) => { | |||
| return serverFetchJson<any>( | |||
| `${BASE_API_URL}/product-process/Demo/ProcessLine/issue`, | |||
| @@ -1086,4 +1095,38 @@ export const fetchFGStockInLabel = async (data: ExportFGStockInLabelRequest): Pr | |||
| ); | |||
| return reportBlob; | |||
| }; | |||
| export const updateJoPlanStart = cache(async (data: UpdateJoPlanStartRequest) => { | |||
| return serverFetchJson<SaveJoResponse>(`${BASE_API_URL}/jo/update-jo-plan-start`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }) | |||
| }) | |||
| export interface UpdateProductProcessLineStatusRequest { | |||
| productProcessLineId: number; | |||
| status: string; | |||
| } | |||
| export const updateProductProcessLineStatus = async (request: UpdateProductProcessLineStatusRequest) => { | |||
| return serverFetchJson<any>( | |||
| `${BASE_API_URL}/product-process/Demo/ProcessLine/update/status`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(request), | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ); | |||
| }; | |||
| export const passProductProcessLine = async (lineId: number) => { | |||
| return serverFetchJson<any>( | |||
| `${BASE_API_URL}/product-process/Demo/ProcessLine/pass/${lineId}`, | |||
| { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ); | |||
| }; | |||
| @@ -32,6 +32,7 @@ export interface JobOrder { | |||
| jobTypeName: string; | |||
| sufficientCount: number; | |||
| insufficientCount: number; | |||
| productionPriority: number; | |||
| // TODO pack below into StockInLineInfo | |||
| stockInLineId?: number; | |||
| stockInLineStatus?: string; | |||
| @@ -8,7 +8,7 @@ import { DatePicker, DateTimePicker, LocalizationProvider } from "@mui/x-date-pi | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import dayjs, { Dayjs } from "dayjs"; | |||
| import { isFinite } from "lodash"; | |||
| import React, { SetStateAction, SyntheticEvent, useCallback, useEffect, useMemo } from "react"; | |||
| import React, { SetStateAction, SyntheticEvent, useCallback, useEffect, useMemo, useState} from "react"; | |||
| import { Controller, FormProvider, SubmitErrorHandler, SubmitHandler, useForm, useFormContext } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { msg } from "../Swal/CustomAlerts"; | |||
| @@ -30,17 +30,52 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| onSearch, | |||
| }) => { | |||
| const { t } = useTranslation("jo"); | |||
| const [multiplier, setMultiplier] = useState<number>(1); | |||
| const formProps = useForm<SaveJo>({ | |||
| mode: "onChange", | |||
| defaultValues: { | |||
| productionPriority: 50 | |||
| } | |||
| }); | |||
| const { reset, trigger, watch, control, register, formState: { errors }, setValue } = formProps | |||
| // 监听 bomId 变化 | |||
| const selectedBomId = watch("bomId"); | |||
| /* | |||
| const handleAutoCompleteChange = useCallback( | |||
| (event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { | |||
| console.log("BOM changed to:", value); | |||
| onChange(value.id); | |||
| // 重置倍数为 1 | |||
| setMultiplier(1); | |||
| // 1) 根据 BOM 设置数量(倍数 * outputQty) | |||
| if (value.outputQty != null) { | |||
| const calculatedQty = 1 * Number(value.outputQty); | |||
| formProps.setValue("reqQty", calculatedQty, { shouldValidate: true, shouldDirty: true }); | |||
| } | |||
| // 2) 选 BOM 时,把日期默认设为"今天" | |||
| const today = dayjs(); | |||
| const todayStr = dayjsToDateString(today, "input"); | |||
| formProps.setValue("planStart", todayStr, { shouldValidate: true, shouldDirty: true }); | |||
| }, | |||
| [formProps] | |||
| ); | |||
| */ | |||
| // 添加 useEffect 来监听倍数变化,自动计算 reqQty | |||
| useEffect(() => { | |||
| const selectedBom = bomCombo.find(bom => bom.id === selectedBomId); | |||
| if (selectedBom && selectedBom.outputQty != null) { | |||
| const calculatedQty = multiplier * Number(selectedBom.outputQty); | |||
| formProps.setValue("reqQty", calculatedQty, { shouldValidate: true, shouldDirty: true }); | |||
| } | |||
| }, [multiplier, selectedBomId, bomCombo, formProps]); | |||
| const onModalClose = useCallback(() => { | |||
| reset() | |||
| onClose() | |||
| setMultiplier(1); | |||
| }, [reset, onClose]) | |||
| const handleAutoCompleteChange = useCallback( | |||
| @@ -61,65 +96,7 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| [formProps] | |||
| ); | |||
| // 使用 useMemo 来计算过滤后的 jobTypes,响应 selectedBomId 变化 | |||
| /* | |||
| const filteredJobTypes = useMemo(() => { | |||
| console.log("getFilteredJobTypes called, selectedBomId:", selectedBomId); | |||
| if (!selectedBomId) { | |||
| console.log("No BOM selected, returning all jobTypes:", jobTypes); | |||
| return jobTypes; | |||
| } | |||
| const selectedBom = bomCombo.find(bom => bom.id === selectedBomId); | |||
| console.log("Selected BOM:", selectedBom); | |||
| console.log("Selected BOM full object:", JSON.stringify(selectedBom, null, 2)); | |||
| if (!selectedBom) { | |||
| console.log("BOM not found, returning all jobTypes"); | |||
| return jobTypes; | |||
| } | |||
| // 检查 description 是否存在 | |||
| const description = selectedBom.description; | |||
| console.log("BOM description (raw):", description); | |||
| console.log("BOM description type:", typeof description); | |||
| console.log("BOM description is undefined?", description === undefined); | |||
| console.log("BOM description is null?", description === null); | |||
| if (!description) { | |||
| console.log("BOM description is missing or empty, returning all jobTypes"); | |||
| return jobTypes; | |||
| } | |||
| const descriptionUpper = description.toUpperCase(); | |||
| console.log("BOM description (uppercase):", descriptionUpper); | |||
| console.log("All jobTypes:", jobTypes); | |||
| let filtered: JobTypeResponse[] = []; | |||
| if (descriptionUpper === "WIP") { | |||
| filtered = jobTypes.filter(jt => { | |||
| const jobTypeName = jt.name.toUpperCase(); | |||
| const shouldInclude = jobTypeName !== "FG"; | |||
| console.log(`JobType ${jt.name} (${jobTypeName}): ${shouldInclude ? "included" : "excluded"}`); | |||
| return shouldInclude; | |||
| }); | |||
| } else if (descriptionUpper === "FG") { | |||
| filtered = jobTypes.filter(jt => { | |||
| const jobTypeName = jt.name.toUpperCase(); | |||
| const shouldInclude = jobTypeName !== "WIP"; | |||
| console.log(`JobType ${jt.name} (${jobTypeName}): ${shouldInclude ? "included" : "excluded"}`); | |||
| return shouldInclude; | |||
| }); | |||
| } else { | |||
| filtered = jobTypes; | |||
| } | |||
| console.log("Filtered jobTypes:", filtered); | |||
| return filtered; | |||
| }, [bomCombo, jobTypes, selectedBomId]); | |||
| */ | |||
| // 当 BOM 改变时,自动选择匹配的 Job Type | |||
| useEffect(() => { | |||
| if (!selectedBomId) { | |||
| @@ -174,6 +151,10 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| data.planStart = dayjsToDateTimeString(dateDayjs.startOf('day')) | |||
| } | |||
| data.jobTypeId = Number(data.jobTypeId); | |||
| // 如果 productionPriority 为空或无效,使用默认值 50 | |||
| data.productionPriority = data.productionPriority != null && !isNaN(data.productionPriority) | |||
| ? Number(data.productionPriority) | |||
| : 50; | |||
| const response = await manualCreateJo(data) | |||
| if (response) { | |||
| onSearch(); | |||
| @@ -283,31 +264,73 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| render={({ field, fieldState: { error } }) => { | |||
| const selectedBom = bomCombo.find(bom => bom.id === formProps.watch("bomId")); | |||
| const uom = selectedBom?.outputQtyUom || ""; | |||
| const outputQty = selectedBom?.outputQty ?? 0; | |||
| const calculatedValue = multiplier * outputQty; | |||
| return ( | |||
| <TextField | |||
| {...field} | |||
| label={t("Req. Qty")} | |||
| fullWidth | |||
| error={Boolean(error)} | |||
| variant="outlined" | |||
| type="number" | |||
| disabled={true} | |||
| value={field.value ?? ""} | |||
| onChange={(e) => { | |||
| const val = e.target.value === "" ? undefined : Number(e.target.value); | |||
| field.onChange(val); | |||
| }} | |||
| InputProps={{ | |||
| endAdornment: uom ? ( | |||
| <InputAdornment position="end"> | |||
| <Typography variant="body2" sx={{ color: "text.secondary" }}> | |||
| {uom} | |||
| </Typography> | |||
| </InputAdornment> | |||
| ) : null | |||
| }} | |||
| /> | |||
| <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> | |||
| <TextField | |||
| label={t("Base Qty")} | |||
| fullWidth | |||
| type="number" | |||
| variant="outlined" | |||
| value={outputQty} | |||
| disabled | |||
| InputProps={{ | |||
| endAdornment: uom ? ( | |||
| <InputAdornment position="end"> | |||
| <Typography variant="body2" sx={{ color: "text.secondary" }}> | |||
| {uom} | |||
| </Typography> | |||
| </InputAdornment> | |||
| ) : null | |||
| }} | |||
| sx={{ flex: 1 }} | |||
| /> | |||
| <Typography variant="body1" sx={{ color: "text.secondary" }}> | |||
| × | |||
| </Typography> | |||
| <TextField | |||
| label={t("Batch Count")} | |||
| fullWidth | |||
| type="number" | |||
| variant="outlined" | |||
| value={multiplier} | |||
| onChange={(e) => { | |||
| const val = e.target.value === "" ? 1 : Math.max(1, Math.floor(Number(e.target.value))); | |||
| setMultiplier(val); | |||
| }} | |||
| inputProps={{ | |||
| min: 1, | |||
| step: 1 | |||
| }} | |||
| sx={{ flex: 1 }} | |||
| /> | |||
| <Typography variant="body1" sx={{ color: "text.secondary" }}> | |||
| = | |||
| </Typography> | |||
| <TextField | |||
| {...field} | |||
| label={t("Req. Qty")} | |||
| fullWidth | |||
| error={Boolean(error)} | |||
| variant="outlined" | |||
| type="number" | |||
| value={calculatedValue || ""} | |||
| disabled | |||
| InputProps={{ | |||
| endAdornment: uom ? ( | |||
| <InputAdornment position="end"> | |||
| <Typography variant="body2" sx={{ color: "text.secondary" }}> | |||
| {uom} | |||
| </Typography> | |||
| </InputAdornment> | |||
| ) : null | |||
| }} | |||
| sx={{ flex: 1 }} | |||
| /> | |||
| </Box> | |||
| ); | |||
| }} | |||
| /> | |||
| @@ -349,6 +372,58 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} sm={12} md={6}> | |||
| <Controller | |||
| control={control} | |||
| name="productionPriority" | |||
| rules={{ | |||
| required: t("Production Priority required!") as string, | |||
| max: { | |||
| value: 100, | |||
| message: t("Production Priority cannot exceed 100") as string | |||
| }, | |||
| min: { | |||
| value: 1, | |||
| message: t("Production Priority must be at least 1") as string | |||
| }, | |||
| validate: (value) => { | |||
| if (value === undefined || value === null || isNaN(value)) { | |||
| return t("Production Priority required!") as string; | |||
| } | |||
| return true; | |||
| } | |||
| }} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <TextField | |||
| {...field} | |||
| label={t("Production Priority")} | |||
| fullWidth | |||
| error={Boolean(error)} | |||
| variant="outlined" | |||
| type="number" | |||
| inputProps={{ | |||
| min: 1, | |||
| max: 100, | |||
| step: 1 | |||
| }} | |||
| value={field.value ?? ""} | |||
| onChange={(e) => { | |||
| const inputValue = e.target.value; | |||
| // 允许空字符串(用户正在删除) | |||
| if (inputValue === "") { | |||
| field.onChange(""); | |||
| return; | |||
| } | |||
| // 转换为数字并验证范围 | |||
| const numValue = Number(inputValue); | |||
| if (!isNaN(numValue) && numValue >= 1 && numValue <= 100) { | |||
| field.onChange(numValue); | |||
| } | |||
| }} | |||
| /> | |||
| )} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} sm={12} md={6}> | |||
| <Controller | |||
| control={control} | |||
| @@ -1,5 +1,5 @@ | |||
| "use client" | |||
| import { SearchJoResultRequest, fetchJos, updateJo } from "@/app/api/jo/actions"; | |||
| import { SearchJoResultRequest, fetchJos, updateJo,updateProductProcessPriority } from "@/app/api/jo/actions"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { Criterion } from "../SearchBox"; | |||
| @@ -12,10 +12,11 @@ import { useRouter } from "next/navigation"; | |||
| import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; | |||
| import { StockInLineInput } from "@/app/api/stockIn"; | |||
| import { JobOrder, JoDetailPickLine, JoStatus } from "@/app/api/jo"; | |||
| import { Button, Stack } from "@mui/material"; | |||
| import { Button, Stack, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, InputAdornment } from "@mui/material"; | |||
| import { BomCombo } from "@/app/api/bom"; | |||
| import JoCreateFormModal from "./JoCreateFormModal"; | |||
| import AddIcon from '@mui/icons-material/Add'; | |||
| import EditIcon from '@mui/icons-material/Edit'; | |||
| import QcStockInModal from "../Qc/QcStockInModal"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| @@ -26,6 +27,10 @@ import { fetchInventories } from "@/app/api/inventory/actions"; | |||
| import { InventoryResult } from "@/app/api/inventory"; | |||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||
| import { JobTypeResponse } from "@/app/api/jo/actions"; | |||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import { updateJoPlanStart } from "@/app/api/jo/actions"; | |||
| import { arrayToDayjs } from "@/app/utils/formatUtil"; | |||
| interface Props { | |||
| defaultInputs: SearchJoResultRequest, | |||
| @@ -50,7 +55,14 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| const [inventoryData, setInventoryData] = useState<InventoryResult[]>([]); | |||
| const [detailedJos, setDetailedJos] = useState<Map<number, JobOrder>>(new Map()); | |||
| const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false); | |||
| const [operationPriority, setOperationPriority] = useState<number>(50); | |||
| const [selectedJo, setSelectedJo] = useState<JobOrder | null>(null); | |||
| const [selectedProductProcessId, setSelectedProductProcessId] = useState<number | null>(null); | |||
| const [openPlanStartDialog, setOpenPlanStartDialog] = useState(false); | |||
| const [planStartDate, setPlanStartDate] = useState<dayjs.Dayjs | null>(null); | |||
| const [selectedJoForDate, setSelectedJoForDate] = useState<JobOrder | null>(null); | |||
| const fetchJoDetailClient = async (id: number): Promise<JobOrder> => { | |||
| const response = await fetch(`/api/jo/detail?id=${id}`); | |||
| if (!response.ok) { | |||
| @@ -98,6 +110,32 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| fetchInventoryData(); | |||
| }, []); | |||
| const handleOpenPriorityDialog = useCallback(async (jo: JobOrder) => { | |||
| setSelectedJo(jo); | |||
| setOperationPriority(jo.productionPriority ?? 50); | |||
| // 获取 productProcessId | |||
| try { | |||
| const { fetchProductProcessesByJobOrderId } = await import("@/app/api/jo/actions"); | |||
| const processes = await fetchProductProcessesByJobOrderId(jo.id); | |||
| if (processes && processes.length > 0) { | |||
| setSelectedProductProcessId(processes[0].id); | |||
| setOpenOperationPriorityDialog(true); | |||
| } else { | |||
| msg(t("No product process found for this job order")); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error fetching product process:", error); | |||
| msg(t("Error loading product process")); | |||
| } | |||
| }, [t]); | |||
| const handleClosePriorityDialog = useCallback((_event?: object, _reason?: "backdropClick" | "escapeKeyDown") => { | |||
| setOpenOperationPriorityDialog(false); | |||
| setSelectedJo(null); | |||
| setSelectedProductProcessId(null); | |||
| }, []); | |||
| const getStockAvailable = (pickLine: JoDetailPickLine) => { | |||
| const inventory = inventoryData.find(inventory => | |||
| @@ -137,14 +175,71 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| options: jobTypes.map(jt => jt.name) | |||
| }, | |||
| ], [t, jobTypes]) | |||
| const handleOpenPlanStartDialog = useCallback((jo: JobOrder) => { | |||
| setSelectedJoForDate(jo); | |||
| // 将 planStart 数组转换为 dayjs 对象 | |||
| if (jo.planStart && Array.isArray(jo.planStart)) { | |||
| setPlanStartDate(arrayToDayjs(jo.planStart)); | |||
| } else { | |||
| setPlanStartDate(dayjs()); | |||
| } | |||
| setOpenPlanStartDialog(true); | |||
| }, []); | |||
| const columns = useMemo<Column<JobOrder>[]>( | |||
| () => [ | |||
| { | |||
| name: "planStart", | |||
| label: t("Estimated Production Date"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| renderCell: (row) => { | |||
| return ( | |||
| <Stack direction="row" alignItems="center" spacing={1}> | |||
| <span>{row.planStart ? arrayToDateString(row.planStart) : '-'}</span> | |||
| {row.status == "planning" && ( | |||
| <IconButton | |||
| size="small" | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| handleOpenPlanStartDialog(row); | |||
| }} | |||
| sx={{ padding: '4px' }} | |||
| > | |||
| <EditIcon fontSize="small" /> | |||
| </IconButton> | |||
| )} | |||
| </Stack> | |||
| ); | |||
| } | |||
| }, | |||
| { | |||
| name: "productionPriority", | |||
| label: t("Production Priority"), | |||
| renderCell: (row) => { | |||
| return ( | |||
| <Stack direction="row" alignItems="center" spacing={1}> | |||
| <span>{integerFormatter.format(row.productionPriority)}</span> | |||
| <IconButton | |||
| size="small" | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| handleOpenPriorityDialog(row); | |||
| }} | |||
| sx={{ padding: '4px' }} | |||
| > | |||
| <EditIcon fontSize="small" /> | |||
| </IconButton> | |||
| </Stack> | |||
| ); | |||
| } | |||
| }, | |||
| { | |||
| name: "code", | |||
| label: t("Code"), | |||
| flex: 2 | |||
| }, | |||
| { | |||
| name: "item", | |||
| label: `${t("Item Name")}`, | |||
| @@ -170,23 +265,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| return row.item?.uom ? t(row.item.uom.udfudesc) : '-' | |||
| } | |||
| }, | |||
| { | |||
| name: "status", | |||
| label: t("Status"), | |||
| renderCell: (row) => { | |||
| return <span style={{color: row.stockInLineStatus == "escalated" ? "red" : "inherit"}}> | |||
| {t(upperFirst(row.status))} | |||
| </span> | |||
| } | |||
| },{ | |||
| name: "planStart", | |||
| label: t("Estimated Production Date"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| renderCell: (row) => { | |||
| return row.planStart ? arrayToDateString(row.planStart) : '-' | |||
| } | |||
| }, | |||
| { | |||
| name: "stockStatus" as keyof JobOrder, | |||
| label: t("BOM Status"), | |||
| @@ -201,6 +279,15 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| ); | |||
| } | |||
| }, | |||
| { | |||
| name: "status", | |||
| label: t("Status"), | |||
| renderCell: (row) => { | |||
| return <span style={{color: row.stockInLineStatus == "escalated" ? "red" : "inherit"}}> | |||
| {t(upperFirst(row.status))} | |||
| </span> | |||
| } | |||
| }, | |||
| { | |||
| name: "jobTypeName", | |||
| label: t("Job Type"), | |||
| @@ -226,7 +313,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| ) | |||
| } | |||
| }, | |||
| ], [t, inventoryData, detailedJos] | |||
| ], [t, inventoryData, detailedJos, handleOpenPriorityDialog,handleOpenPlanStartDialog] | |||
| ) | |||
| // 按照 PoSearch 的模式:创建 newPageFetch 函数 | |||
| @@ -256,7 +343,20 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| }, | |||
| [], | |||
| ); | |||
| const handleUpdateOperationPriority = useCallback(async (productProcessId: number, productionPriority: number) => { | |||
| const response = await updateProductProcessPriority(productProcessId, productionPriority) | |||
| if (response) { | |||
| // 刷新数据 | |||
| await newPageFetch(pagingController, inputs); | |||
| } | |||
| }, [pagingController, inputs, newPageFetch]); | |||
| const handleConfirmPriority = useCallback(async () => { | |||
| if (!selectedProductProcessId) return; | |||
| await handleUpdateOperationPriority(selectedProductProcessId, Number(operationPriority)); | |||
| setOpenOperationPriorityDialog(false); | |||
| setSelectedJo(null); | |||
| setSelectedProductProcessId(null); | |||
| }, [selectedProductProcessId, operationPriority, handleUpdateOperationPriority]); | |||
| // 按照 PoSearch 的模式:使用相同的 useEffect 逻辑 | |||
| useEffect(() => { | |||
| newPageFetch(pagingController, inputs); | |||
| @@ -352,6 +452,31 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| setPagingController(defaultPagingController); | |||
| }, [defaultInputs]) | |||
| const handleClosePlanStartDialog = useCallback((_event?: object, _reason?: "backdropClick" | "escapeKeyDown") => { | |||
| setOpenPlanStartDialog(false); | |||
| setSelectedJoForDate(null); | |||
| setPlanStartDate(null); | |||
| }, []); | |||
| const handleUpdatePlanStart = useCallback(async (jobOrderId: number, planStart: string) => { | |||
| const response = await updateJoPlanStart({ id: jobOrderId, planStart }); | |||
| if (response) { | |||
| // 刷新数据 | |||
| await newPageFetch(pagingController, inputs); | |||
| } | |||
| }, [pagingController, inputs, newPageFetch]); | |||
| const handleConfirmPlanStart = useCallback(async () => { | |||
| if (!selectedJoForDate?.id || !planStartDate) return; | |||
| // 将日期转换为后端需要的格式 (YYYY-MM-DDTHH:mm:ss) | |||
| const dateString = `${dayjsToDateString(planStartDate, "input")}T00:00:00`; | |||
| await handleUpdatePlanStart(selectedJoForDate.id, dateString); | |||
| setOpenPlanStartDialog(false); | |||
| setSelectedJoForDate(null); | |||
| setPlanStartDate(null); | |||
| }, [selectedJoForDate, planStartDate, handleUpdatePlanStart]); | |||
| const onReset = useCallback(() => { | |||
| setInputs(defaultInputs); | |||
| @@ -413,7 +538,66 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| inputDetail={modalInfo} | |||
| printerCombo={printerCombo} | |||
| /> | |||
| <Dialog | |||
| open={openOperationPriorityDialog} | |||
| onClose={handleClosePriorityDialog} | |||
| fullWidth | |||
| maxWidth="xs" | |||
| > | |||
| <DialogTitle>{t("Update Production Priority")}</DialogTitle> | |||
| <DialogContent> | |||
| <TextField | |||
| autoFocus | |||
| margin="dense" | |||
| label={t("Production Priority")} | |||
| type="number" | |||
| fullWidth | |||
| value={operationPriority} | |||
| onChange={(e) => setOperationPriority(Number(e.target.value))} | |||
| /> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={handleClosePriorityDialog}>{t("Cancel")}</Button> | |||
| <Button variant="contained" onClick={handleConfirmPriority}>{t("Save")}</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog | |||
| open={openPlanStartDialog} | |||
| onClose={handleClosePlanStartDialog} | |||
| fullWidth | |||
| maxWidth="xs" | |||
| > | |||
| <DialogTitle>{t("Update Estimated Production Date")}</DialogTitle> | |||
| <DialogContent> | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <DatePicker | |||
| label={t("Estimated Production Date")} | |||
| value={planStartDate} | |||
| onChange={(newValue) => setPlanStartDate(newValue)} | |||
| slotProps={{ | |||
| textField: { | |||
| fullWidth: true, | |||
| margin: "dense", | |||
| autoFocus: true, | |||
| } | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={handleClosePlanStartDialog}>{t("Cancel")}</Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={handleConfirmPlanStart} | |||
| disabled={!planStartDate} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </> | |||
| } | |||
| export default JoSearch; | |||
| @@ -203,32 +203,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| fetchNewPageConsoPickOrder({ limit: 10, offset: 0 }, filterArgs); | |||
| }, [fetchNewPageConsoPickOrder, filterArgs]); | |||
| const handleUpdateStockOutLineStatus = useCallback(async ( | |||
| stockOutLineId: number, | |||
| status: string, | |||
| qty?: number | |||
| ) => { | |||
| try { | |||
| const updateData = { | |||
| id: stockOutLineId, | |||
| status: status, | |||
| qty: qty | |||
| }; | |||
| console.log("Updating stock out line status:", updateData); | |||
| const result = await updateStockOutLineStatus(updateData); | |||
| if (result) { | |||
| console.log("Stock out line status updated successfully:", result); | |||
| if (selectedRowId) { | |||
| handleRowSelect(selectedRowId); | |||
| } | |||
| } | |||
| } catch (error) { | |||
| console.error("Error updating stock out line status:", error); | |||
| } | |||
| }, [selectedRowId]); | |||
| const isReleasable = useCallback((itemList: ByItemsSummary[]): boolean => { | |||
| let isReleasable = true; | |||
| @@ -0,0 +1,309 @@ | |||
| "use client"; | |||
| import React, { useState, useEffect, useCallback, useMemo } from "react"; | |||
| import { | |||
| Box, | |||
| Paper, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableHead, | |||
| TableRow, | |||
| TextField, | |||
| Select, | |||
| MenuItem, | |||
| Button, | |||
| IconButton, | |||
| CircularProgress, | |||
| } from "@mui/material"; | |||
| import AddIcon from "@mui/icons-material/Add"; | |||
| import DeleteIcon from "@mui/icons-material/Delete"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| getBagInfo, | |||
| createJoBagConsumption, | |||
| GetBagInfoResponse, | |||
| CreateJoBagConsumptionRequest, | |||
| } from "@/app/api/bag/action"; | |||
| import { fetchProductProcessLineDetail } from "@/app/api/jo/actions"; | |||
| export interface BagConsumptionRow { | |||
| bagId: number; | |||
| bagLotLineId: number; | |||
| consumedQty: number; | |||
| scrapQty: number; | |||
| } | |||
| interface BagConsumptionFormProps { | |||
| jobOrderId: number; | |||
| lineId: number; | |||
| bomDescription?: string; | |||
| isLastLine: boolean; | |||
| onRefresh?: () => void; | |||
| } | |||
| const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({ | |||
| jobOrderId, | |||
| lineId, | |||
| bomDescription, | |||
| isLastLine, | |||
| onRefresh, | |||
| }) => { | |||
| const { t } = useTranslation(["common", "jo"]); | |||
| const [bagList, setBagList] = useState<GetBagInfoResponse[]>([]); | |||
| const [bagConsumptionRows, setBagConsumptionRows] = useState<BagConsumptionRow[]>([ | |||
| { bagId: 0, bagLotLineId: 0, consumedQty: 0, scrapQty: 0 }, | |||
| ]); | |||
| const [isLoadingBags, setIsLoadingBags] = useState(false); | |||
| const [isSubmitting, setIsSubmitting] = useState(false); | |||
| // 判断是否显示表单 | |||
| const shouldShow = useMemo(() => { | |||
| return bomDescription === "FG" && isLastLine; | |||
| }, [bomDescription, isLastLine]); | |||
| // 加载 Bag 列表 | |||
| useEffect(() => { | |||
| if (shouldShow) { | |||
| setIsLoadingBags(true); | |||
| getBagInfo() | |||
| .then((bags) => { | |||
| setBagList(bags); | |||
| console.log("✅ Bag list loaded:", bags); | |||
| }) | |||
| .catch((error) => { | |||
| console.error("❌ Error loading bag list:", error); | |||
| }) | |||
| .finally(() => { | |||
| setIsLoadingBags(false); | |||
| }); | |||
| } | |||
| }, [shouldShow]); | |||
| // 添加 Bag 行 | |||
| const handleAddBagRow = useCallback(() => { | |||
| setBagConsumptionRows((prev) => [ | |||
| ...prev, | |||
| { bagId: 0, bagLotLineId: 0, consumedQty: 0, scrapQty: 0 }, | |||
| ]); | |||
| }, []); | |||
| // 删除 Bag 行 | |||
| const handleDeleteBagRow = useCallback((index: number) => { | |||
| setBagConsumptionRows((prev) => prev.filter((_, i) => i !== index)); | |||
| }, []); | |||
| // 更新 Bag 行数据 | |||
| const handleBagRowChange = useCallback( | |||
| (index: number, field: keyof BagConsumptionRow, value: any) => { | |||
| setBagConsumptionRows((prev) => | |||
| prev.map((row, i) => (i === index ? { ...row, [field]: value } : row)) | |||
| ); | |||
| }, | |||
| [] | |||
| ); | |||
| // 当选择 bag 时,自动填充 bagLotLineId | |||
| const handleBagSelect = useCallback( | |||
| (index: number, bagLotLineId: number) => { | |||
| const selectedBag = bagList.find((b) => b.id === bagLotLineId); | |||
| if (selectedBag) { | |||
| handleBagRowChange(index, "bagId", selectedBag.bagId); | |||
| handleBagRowChange(index, "bagLotLineId", selectedBag.id); | |||
| } | |||
| }, | |||
| [bagList, handleBagRowChange] | |||
| ); | |||
| // 提交 Bag Consumption | |||
| const handleSubmitBagConsumption = useCallback(async () => { | |||
| if (!jobOrderId || !lineId) { | |||
| alert(t("Missing job order ID or line ID")); | |||
| return; | |||
| } | |||
| try { | |||
| setIsSubmitting(true); | |||
| // 过滤掉未选择 bag 的行 | |||
| const validRows = bagConsumptionRows.filter( | |||
| (row) => row.bagId > 0 && row.bagLotLineId > 0 | |||
| ); | |||
| if (validRows.length === 0) { | |||
| alert(t("Please select at least one bag")); | |||
| return; | |||
| } | |||
| // 提交每个 bag consumption | |||
| const promises = validRows.map((row) => { | |||
| const selectedBag = bagList.find((b) => b.id === row.bagLotLineId); | |||
| const request: CreateJoBagConsumptionRequest = { | |||
| bagId: row.bagId, | |||
| bagLotLineId: row.bagLotLineId, | |||
| jobId: jobOrderId, | |||
| //startQty: selectedBag?.balanceQty || 0, | |||
| consumedQty: row.consumedQty, | |||
| scrapQty: row.scrapQty, | |||
| }; | |||
| return createJoBagConsumption(request); | |||
| }); | |||
| await Promise.all(promises); | |||
| console.log("✅ Bag consumption submitted successfully"); | |||
| // 清空表单 | |||
| setBagConsumptionRows([ | |||
| { bagId: 0, bagLotLineId: 0, consumedQty: 0, scrapQty: 0 }, | |||
| ]); | |||
| // 刷新 line detail | |||
| if (onRefresh) { | |||
| onRefresh(); | |||
| } else { | |||
| const detail = await fetchProductProcessLineDetail(lineId); | |||
| console.log("✅ Line detail refreshed:", detail); | |||
| } | |||
| } catch (error: any) { | |||
| console.error("❌ Error submitting bag consumption:", error); | |||
| // ✅ 显示更详细的错误信息 | |||
| const errorMessage = error?.message || | |||
| error?.response?.data?.message || | |||
| t("Failed to submit bag consumption. Please try again."); | |||
| alert(errorMessage); | |||
| } finally { | |||
| setIsSubmitting(false); | |||
| } | |||
| }, [bagConsumptionRows, bagList, jobOrderId, lineId, onRefresh, t]); | |||
| // 如果不满足显示条件,不渲染 | |||
| if (!shouldShow) { | |||
| return null; | |||
| } | |||
| return ( | |||
| <Box sx={{ mt: 3 }}> | |||
| <Paper sx={{ p: 3, bgcolor: "info.50" }}> | |||
| <Typography variant="h6" gutterBottom> | |||
| {t("Bag Consumption")} | |||
| </Typography> | |||
| {isLoadingBags ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <> | |||
| <Table size="small" sx={{ mt: 2 }}> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell width="40%">{t("Bag")}</TableCell> | |||
| <TableCell width="25%" align="right"> | |||
| {t("Consumed Qty")} | |||
| </TableCell> | |||
| <TableCell width="25%" align="right"> | |||
| {t("Scrap Qty")} | |||
| </TableCell> | |||
| <TableCell width="10%" align="center"> | |||
| {t("Action")} | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {bagConsumptionRows.map((row, index) => ( | |||
| <TableRow key={index}> | |||
| <TableCell> | |||
| <Select | |||
| fullWidth | |||
| size="small" | |||
| value={row.bagLotLineId || 0} // ✅ 改为使用 bagLotLineId | |||
| onChange={(e) => | |||
| handleBagSelect(index, Number(e.target.value)) | |||
| } | |||
| displayEmpty | |||
| > | |||
| <MenuItem value={0}> | |||
| <em>{t("Select Bag")}</em> | |||
| </MenuItem> | |||
| {bagList.map((bag) => ( | |||
| <MenuItem key={bag.id} value={bag.id}> {/* ✅ 改为使用 bag.id (bagLotLineId) */} | |||
| {bag.bagName} ({bag.code}) - {t("Balance")}:{" "} | |||
| {bag.balanceQty} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| fullWidth | |||
| value={row.consumedQty} | |||
| onChange={(e) => | |||
| handleBagRowChange( | |||
| index, | |||
| "consumedQty", | |||
| Number(e.target.value) || 0 | |||
| ) | |||
| } | |||
| inputProps={{ min: 0 }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| fullWidth | |||
| value={row.scrapQty} | |||
| onChange={(e) => | |||
| handleBagRowChange( | |||
| index, | |||
| "scrapQty", | |||
| Number(e.target.value) || 0 | |||
| ) | |||
| } | |||
| inputProps={{ min: 0 }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| {bagConsumptionRows.length > 1 && ( | |||
| <IconButton | |||
| size="small" | |||
| color="error" | |||
| onClick={() => handleDeleteBagRow(index)} | |||
| > | |||
| <DeleteIcon /> | |||
| </IconButton> | |||
| )} | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| <Box sx={{ mt: 2, display: "flex", gap: 2 }}> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<AddIcon />} | |||
| onClick={handleAddBagRow} | |||
| > | |||
| {t("Select Another Bag Lot")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleSubmitBagConsumption} | |||
| disabled={isSubmitting} | |||
| > | |||
| {isSubmitting ? t("Submitting...") : t("Submit Bag Consumption")} | |||
| </Button> | |||
| </Box> | |||
| </> | |||
| )} | |||
| </Paper> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default BagConsumptionForm; | |||
| @@ -0,0 +1,206 @@ | |||
| "use client"; | |||
| import React, { useEffect, useState } from "react"; | |||
| import { Card, CardContent, Typography, Box } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import dayjs from "dayjs"; | |||
| import { ProductProcessWithLinesResponse } from "@/app/api/jo/actions"; | |||
| interface OverallTimeRemainingCardProps { | |||
| processData?: ProductProcessWithLinesResponse | null; | |||
| } | |||
| const OverallTimeRemainingCard: React.FC<OverallTimeRemainingCardProps> = ({ | |||
| processData, | |||
| }) => { | |||
| const { t } = useTranslation(["common", "jo"]); | |||
| const [overallRemainingTime, setOverallRemainingTime] = useState<string | null>(null); | |||
| const [isOverTime, setIsOverTime] = useState(false); | |||
| useEffect(() => { | |||
| console.log("🕐 OverallTimeRemainingCard - processData:", processData); | |||
| console.log("🕐 OverallTimeRemainingCard - processData?.startTime:", processData?.startTime); | |||
| console.log("🕐 OverallTimeRemainingCard - processData?.startTime type:", typeof processData?.startTime); | |||
| console.log("🕐 OverallTimeRemainingCard - processData?.startTime isArray:", Array.isArray(processData?.startTime)); | |||
| if (!processData?.startTime) { | |||
| console.log("❌ OverallTimeRemainingCard - No startTime found"); | |||
| setOverallRemainingTime(null); | |||
| setIsOverTime(false); | |||
| return; | |||
| } | |||
| // 计算 Assume Time Need:从所有 productProcessLines 的 durationInMinutes 求和 | |||
| const assumeTimeNeed = processData?.productProcessLines?.reduce( | |||
| (sum, line) => sum + (line.durationInMinutes || 0), | |||
| 0 | |||
| ) || 0; | |||
| console.log("🕐 OverallTimeRemainingCard - productProcessLines:", processData?.productProcessLines); | |||
| console.log("🕐 OverallTimeRemainingCard - assumeTimeNeed (minutes):", assumeTimeNeed); | |||
| if (assumeTimeNeed === 0) { | |||
| console.log("❌ OverallTimeRemainingCard - assumeTimeNeed is 0"); | |||
| setOverallRemainingTime(null); | |||
| setIsOverTime(false); | |||
| return; | |||
| } | |||
| // ✅ 修复:正确处理 startTime 可能是数组的情况 | |||
| let start: dayjs.Dayjs; | |||
| if (Array.isArray(processData.startTime)) { | |||
| console.log("🕐 OverallTimeRemainingCard - startTime is array:", processData.startTime); | |||
| const [year, month, day, hour = 0, minute = 0, second = 0] = processData.startTime; | |||
| console.log("🕐 OverallTimeRemainingCard - Parsed array values:", { year, month, day, hour, minute, second }); | |||
| start = dayjs(new Date(year, month - 1, day, hour, minute, second)); | |||
| } else if (typeof processData.startTime === 'string') { | |||
| console.log("🕐 OverallTimeRemainingCard - startTime is string:", processData.startTime); | |||
| // ✅ 检查是否是 "MM-DD HH:mm" 格式(缺少年份) | |||
| const mmddHhmmPattern = /^(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2})$/; | |||
| const match = processData.startTime.match(mmddHhmmPattern); | |||
| if (match) { | |||
| console.log("🕐 OverallTimeRemainingCard - Detected MM-DD HH:mm format"); | |||
| const month = parseInt(match[1], 10); | |||
| const day = parseInt(match[2], 10); | |||
| const hour = parseInt(match[3], 10); | |||
| const minute = parseInt(match[4], 10); | |||
| // ✅ 使用当前年份,但如果跨年(startTime 是年末,当前是年初),使用上一年 | |||
| const now = dayjs(); | |||
| let year = now.year(); | |||
| // 检查是否跨年:如果 startTime 的月份大于当前月份,或者月份相同但日期大于当前日期 | |||
| // 且当前日期是年初(1月前10天),则可能是跨年情况 | |||
| const startMonthDay = month * 100 + day; // 例如 1229 = 12月29日 | |||
| const nowMonthDay = now.month() * 100 + now.date(); // 例如 101 = 1月1日 | |||
| // 如果 startTime 是年末(12月且日期>=20),当前是年初(1月且日期<=10),则使用上一年 | |||
| if (month === 12 && day >= 20 && now.month() === 0 && now.date() <= 10) { | |||
| year = now.year() - 1; | |||
| console.log("🕐 OverallTimeRemainingCard - Detected year crossover, using previous year:", year); | |||
| } else { | |||
| console.log("🕐 OverallTimeRemainingCard - Using current year:", year); | |||
| } | |||
| console.log("🕐 OverallTimeRemainingCard - Parsed MM-DD HH:mm values:", { year, month, day, hour, minute }); | |||
| start = dayjs(new Date(year, month - 1, day, hour, minute, 0)); | |||
| } else { | |||
| // 尝试直接解析(可能是其他格式) | |||
| console.log("🕐 OverallTimeRemainingCard - Trying to parse as standard date string"); | |||
| start = dayjs(processData.startTime); | |||
| } | |||
| } else { | |||
| console.log("🕐 OverallTimeRemainingCard - startTime is unknown type, trying dayjs"); | |||
| start = dayjs(processData.startTime as any); | |||
| } | |||
| console.log("🕐 OverallTimeRemainingCard - start (dayjs):", start.format("YYYY-MM-DD HH:mm:ss")); | |||
| console.log("🕐 OverallTimeRemainingCard - start isValid:", start.isValid()); | |||
| console.log("🕐 OverallTimeRemainingCard - start timestamp:", start.valueOf()); | |||
| if (!start.isValid()) { | |||
| console.error("❌ OverallTimeRemainingCard - Invalid startTime:", processData.startTime); | |||
| setOverallRemainingTime(null); | |||
| setIsOverTime(false); | |||
| return; | |||
| } | |||
| const assumeEnd = start.add(assumeTimeNeed, 'minute'); | |||
| console.log("🕐 OverallTimeRemainingCard - assumeEnd (dayjs):", assumeEnd.format("YYYY-MM-DD HH:mm:ss")); | |||
| console.log("🕐 OverallTimeRemainingCard - assumeEnd timestamp:", assumeEnd.valueOf()); | |||
| console.log("🕐 OverallTimeRemainingCard - Duration from start to end (minutes):", assumeTimeNeed); | |||
| const update = () => { | |||
| const now = dayjs(); | |||
| const remaining = assumeEnd.diff(now, 'millisecond'); | |||
| console.log("🕐 OverallTimeRemainingCard - update() called:"); | |||
| console.log(" - now:", now.format("YYYY-MM-DD HH:mm:ss")); | |||
| console.log(" - now timestamp:", now.valueOf()); | |||
| console.log(" - assumeEnd:", assumeEnd.format("YYYY-MM-DD HH:mm:ss")); | |||
| console.log(" - assumeEnd timestamp:", assumeEnd.valueOf()); | |||
| console.log(" - remaining (ms):", remaining); | |||
| console.log(" - remaining (minutes):", remaining / 60000); | |||
| console.log(" - remaining (hours):", remaining / 3600000); | |||
| if (remaining <= 0) { | |||
| const overTime = Math.abs(remaining); | |||
| console.log(" - overTime (ms):", overTime); | |||
| console.log(" - overTime (minutes):", overTime / 60000); | |||
| console.log(" - overTime (hours):", overTime / 3600000); | |||
| // ✅ 修复:正确格式化时间,显示小时:分钟:秒 | |||
| const hours = Math.floor(overTime / 3600000); | |||
| const minutes = Math.floor((overTime % 3600000) / 60000); | |||
| const seconds = Math.floor((overTime % 60000) / 1000); | |||
| console.log(" - Calculated hours:", hours); | |||
| console.log(" - Calculated minutes:", minutes); | |||
| console.log(" - Calculated seconds:", seconds); | |||
| // 如果超过1小时,显示 HH:MM:SS,否则显示 MM:SS | |||
| if (hours > 0) { | |||
| const formatted = `-${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; | |||
| console.log(" - Formatted time (with hours):", formatted); | |||
| setOverallRemainingTime(formatted); | |||
| } else { | |||
| const formatted = `-${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; | |||
| console.log(" - Formatted time (no hours):", formatted); | |||
| setOverallRemainingTime(formatted); | |||
| } | |||
| setIsOverTime(true); | |||
| } else { | |||
| // ✅ 修复:正确格式化时间,显示小时:分钟:秒 | |||
| const hours = Math.floor(remaining / 3600000); | |||
| const minutes = Math.floor((remaining % 3600000) / 60000); | |||
| const seconds = Math.floor((remaining % 60000) / 1000); | |||
| console.log(" - Calculated hours:", hours); | |||
| console.log(" - Calculated minutes:", minutes); | |||
| console.log(" - Calculated seconds:", seconds); | |||
| // 如果超过1小时,显示 HH:MM:SS,否则显示 MM:SS | |||
| if (hours > 0) { | |||
| const formatted = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; | |||
| console.log(" - Formatted time (with hours):", formatted); | |||
| setOverallRemainingTime(formatted); | |||
| } else { | |||
| const formatted = `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; | |||
| console.log(" - Formatted time (no hours):", formatted); | |||
| setOverallRemainingTime(formatted); | |||
| } | |||
| setIsOverTime(false); | |||
| } | |||
| }; | |||
| update(); | |||
| const timer = setInterval(update, 1000); | |||
| return () => clearInterval(timer); | |||
| }, [processData?.startTime, processData?.productProcessLines]); | |||
| if (!processData?.startTime || overallRemainingTime === null) { | |||
| return null; | |||
| } | |||
| return ( | |||
| <Card sx={{ bgcolor: isOverTime ? 'error.50' : 'info.50', border: '2px solid', borderColor: isOverTime ? 'error.main' : 'info.main', mb: 3 }}> | |||
| <CardContent> | |||
| <Typography variant="h6" color={isOverTime ? 'error.main' : 'info.main'} gutterBottom fontWeight="bold"> | |||
| {t("Overall Time Remaining")} | |||
| </Typography> | |||
| <Box sx={{ mt: 2, p: 2, bgcolor: isOverTime ? 'error.100' : 'info.100', borderRadius: 1 }}> | |||
| <Typography | |||
| variant="h4" | |||
| fontWeight="bold" | |||
| color={isOverTime ? 'error.main' : 'info.main'} | |||
| align="center" | |||
| > | |||
| {isOverTime ? `${t("Over Time")}: ${overallRemainingTime}` : overallRemainingTime} | |||
| </Typography> | |||
| </Box> | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default OverallTimeRemainingCard; | |||
| @@ -2,21 +2,29 @@ import { Card, CardContent, Stack, Typography } from "@mui/material"; | |||
| import dayjs from "dayjs"; | |||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { ProductProcessWithLinesResponse } from "@/app/api/jo/actions"; | |||
| interface Props { | |||
| processData?: { | |||
| jobOrderCode?: string; | |||
| itemCode?: string; | |||
| itemName?: string; | |||
| jobType?: string; | |||
| outputQty?: number | string; | |||
| outputQtyUom?: string; | |||
| date?: string; | |||
| }; | |||
| processData?: ProductProcessWithLinesResponse | null; | |||
| } | |||
| const ProcessSummaryHeader: React.FC<Props> = ({ processData }) => { | |||
| const { t } = useTranslation(); | |||
| const { t } = useTranslation(["common", "jo"]); | |||
| // 计算 Assume Time Need:从所有 productProcessLines 的 durationInMinutes 求和 | |||
| const assumeTimeNeed = processData?.productProcessLines?.reduce( | |||
| (sum, line) => sum + (line.durationInMinutes || 0), | |||
| 0 | |||
| ) || 0; | |||
| // Start Time:使用 processData.startTime | |||
| const startTime = processData?.startTime; | |||
| // Assume End Time:Start Time + Assume Time Need(分钟) | |||
| const assumeEndTime = startTime | |||
| ? dayjs(startTime).add(assumeTimeNeed, 'minute') | |||
| : null; | |||
| return ( | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| @@ -38,6 +46,33 @@ const ProcessSummaryHeader: React.FC<Props> = ({ processData }) => { | |||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||
| {t("Production Date")}: <strong style={{ color: "green" }}>{processData?.date ? dayjs(processData.date).format(OUTPUT_DATE_FORMAT) : ""}</strong> | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||
| {t("Assume Time Need")}: <strong style={{ color: "blue" }}>{assumeTimeNeed} {t("minutes")}</strong> | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||
| {t("Start Time")}: <strong> | |||
| {startTime | |||
| ? ( | |||
| <> | |||
| <span style={{ color: "green" }}>{dayjs(startTime).format("MM-DD")}</span> | |||
| {" "} | |||
| <span style={{ color: "blue" }}>{dayjs(startTime).format("HH:mm")}</span> | |||
| </> | |||
| ) | |||
| : "-"} | |||
| </strong> | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||
| {t("Assume End Time")}: <strong> | |||
| {assumeEndTime ? ( | |||
| <> | |||
| <span style={{ color: "green" }}>{assumeEndTime.format("MM-DD")}</span> | |||
| {" "} | |||
| <span style={{ color: "blue" }}>{assumeEndTime.format("HH:mm")}</span> | |||
| </> | |||
| ) : "-"} | |||
| </strong> | |||
| </Typography> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| @@ -39,8 +39,13 @@ import { | |||
| JobOrderProcessLineDetailResponse, | |||
| ProductProcessLineInfoResponse, | |||
| startProductProcessLine, | |||
| fetchProductProcessesByJobOrderId | |||
| fetchProductProcessesByJobOrderId, | |||
| ProductProcessWithLinesResponse, // ✅ 添加 | |||
| ProductProcessLineResponse, | |||
| passProductProcessLine, | |||
| } from "@/app/api/jo/actions"; | |||
| import { updateProductProcessLineStatus } from "@/app/api/jo/actions"; | |||
| import { fetchNameList, NameList } from "@/app/api/user/actions"; | |||
| import ProductionProcessStepExecution from "./ProductionProcessStepExecution"; | |||
| import ProductionOutputFormPage from "./ProductionOutputFormPage"; | |||
| @@ -62,8 +67,8 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||
| const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||
| const [showOutputPage, setShowOutputPage] = useState(false); | |||
| // 基本信息 | |||
| const [processData, setProcessData] = useState<any>(null); | |||
| const [lines, setLines] = useState<ProductProcessLineInfoResponse[]>([]); | |||
| const [processData, setProcessData] = useState<ProductProcessWithLinesResponse | null>(null); // ✅ 修改类型 | |||
| const [lines, setLines] = useState<ProductProcessLineResponse[]>([]); // ✅ 修改类型 | |||
| const [loading, setLoading] = useState(false); | |||
| // 选中的 line 和执行状态 | |||
| @@ -125,7 +130,7 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||
| setProcessData(currentProcess); | |||
| // 使用 productProcessLines 字段(API 返回的字段名) | |||
| const lines = (currentProcess as any).productProcessLines || []; | |||
| const lines = currentProcess.productProcessLines || []; | |||
| setLines(lines); | |||
| console.log(" Process data loaded:", currentProcess); | |||
| @@ -143,105 +148,27 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||
| fetchProcessDetail(); | |||
| }, [fetchProcessDetail]); | |||
| // 开始执行某个 line | |||
| // 提交产出数据 | |||
| /* | |||
| const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| // 操作员格式:{2fitestu1} - 键盘模拟输入(测试用) | |||
| if (qrValue.match(/\{2fitestu(\d+)\}/)) { | |||
| const match = qrValue.match(/\{2fitestu(\d+)\}/); | |||
| const userId = parseInt(match![1]); | |||
| fetchNameList().then((users: NameList[]) => { | |||
| const user = users.find((u: NameList) => u.id === userId); | |||
| if (user) { | |||
| setScannedOperatorId(user.id); | |||
| } | |||
| }); | |||
| return; | |||
| } | |||
| // 设备格式:{2fiteste1} - 键盘模拟输入(测试用) | |||
| if (qrValue.match(/\{2fiteste(\d+)\}/)) { | |||
| const match = qrValue.match(/\{2fiteste(\d+)\}/); | |||
| const equipmentId = parseInt(match![1]); | |||
| setScannedEquipmentId(equipmentId); | |||
| return; | |||
| } | |||
| // 正常 QR 扫描器扫描:格式为 "operatorId: 1" 或 "equipmentId: 1" | |||
| const trimmedValue = qrValue.trim(); | |||
| // 检查 operatorId 格式 | |||
| const operatorMatch = trimmedValue.match(/^operatorId:\s*(\d+)$/i); | |||
| if (operatorMatch) { | |||
| const operatorId = parseInt(operatorMatch[1]); | |||
| fetchNameList().then((users: NameList[]) => { | |||
| const user = users.find((u: NameList) => u.id === operatorId); | |||
| if (user) { | |||
| setScannedOperatorId(user.id); | |||
| } else { | |||
| console.warn(`User with ID ${operatorId} not found`); | |||
| } | |||
| }); | |||
| return; | |||
| } | |||
| // 检查 equipmentId 格式 | |||
| const equipmentMatch = trimmedValue.match(/^equipmentId:\s*(\d+)$/i); | |||
| if (equipmentMatch) { | |||
| const equipmentId = parseInt(equipmentMatch[1]); | |||
| setScannedEquipmentId(equipmentId); | |||
| return; | |||
| } | |||
| // 其他格式处理(JSON、普通文本等) | |||
| const handlePassLine = useCallback(async (lineId: number) => { | |||
| try { | |||
| const qrData = JSON.parse(qrValue); | |||
| // TODO: 处理 JSON 格式的 QR 码 | |||
| } catch { | |||
| // 普通文本格式 | |||
| // TODO: 处理普通文本格式 | |||
| await passProductProcessLine(lineId); | |||
| // 刷新数据 | |||
| await fetchProcessDetail(); | |||
| } catch (error) { | |||
| console.error("Error passing line:", error); | |||
| alert(t("Failed to pass line. Please try again.")); | |||
| } | |||
| }, []); | |||
| */ | |||
| }, [fetchProcessDetail, t]); | |||
| // 提交产出数据 | |||
| const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| // 设备快捷格式:{2fiteste数字} - 自动生成 equipmentTypeSubTypeEquipmentNo | |||
| // 格式:{2fiteste数字} = line.equipment_name + "-数字號" | |||
| // 例如:{2fiteste1} = "包裝機類-真空八爪魚機-1號" | |||
| if (qrValue.match(/\{2fiteste(\d+)\}/)) { | |||
| const match = qrValue.match(/\{2fiteste(\d+)\}/); | |||
| const equipmentNo = parseInt(match![1]); | |||
| // 根据 lineId 找到对应的 line | |||
| const currentLine = lines.find(l => l.id === lineId); | |||
| if (currentLine && currentLine.equipment_name) { | |||
| const equipmentTypeSubTypeEquipmentNo = `${currentLine.equipment_name}-${equipmentNo}號`; | |||
| setScannedEquipmentCode(equipmentTypeSubTypeEquipmentNo); | |||
| console.log(`Generated equipmentTypeSubTypeEquipmentNo: ${equipmentTypeSubTypeEquipmentNo}`); | |||
| } else { | |||
| // 如果找不到 line,尝试从 API 获取 line detail | |||
| console.warn(`Line with ID ${lineId} not found in current lines, fetching from API...`); | |||
| fetchProductProcessLineDetail(lineId) | |||
| .then((lineDetail) => { | |||
| // 从 lineDetail 中获取 equipment_name | |||
| const equipmentName = (lineDetail as any).equipment || (lineDetail as any).equipmentType || ""; | |||
| if (equipmentName) { | |||
| const equipmentTypeSubTypeEquipmentNo = `${equipmentName}-${equipmentNo}號`; | |||
| setScannedEquipmentCode(equipmentTypeSubTypeEquipmentNo); | |||
| console.log(`Generated equipmentTypeSubTypeEquipmentNo from API: ${equipmentTypeSubTypeEquipmentNo}`); | |||
| } else { | |||
| console.warn(`Equipment name not found in line detail for lineId: ${lineId}`); | |||
| } | |||
| }) | |||
| .catch((err) => { | |||
| console.error(`Failed to fetch line detail for lineId ${lineId}:`, err); | |||
| }); | |||
| } | |||
| // 设备快捷格式:{2fitesteXXX} - XXX 直接作为设备代码 | |||
| // 格式:{2fitesteXXX} = equipmentCode: "XXX" | |||
| // 例如:{2fiteste包裝機類-真空八爪魚機-1號} = equipmentCode: "包裝機類-真空八爪魚機-1號" | |||
| if (qrValue.match(/\{2fiteste(.+)\}/)) { | |||
| const match = qrValue.match(/\{2fiteste(.+)\}/); | |||
| const equipmentCode = match![1]; | |||
| setScannedEquipmentCode(equipmentCode); | |||
| console.log(`Set equipmentCode from shortcut: ${equipmentCode}`); | |||
| return; | |||
| } | |||
| @@ -334,7 +261,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| const effectiveEquipmentCode = | |||
| scannedEquipmentCode ?? null; | |||
| console.log("Submitting scan data with equipmentCode:", { | |||
| productProcessLineId: lineId, | |||
| staffNo: scannedStaffNo, | |||
| @@ -538,53 +465,6 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| return ( | |||
| <Box> | |||
| {/* | |||
| <Box sx={{ mb: 2 }}> | |||
| <Button variant="outlined" onClick={onBack}> | |||
| {t("Back to List")} | |||
| </Button> | |||
| </Box> | |||
| <Paper sx={{ p: 3, mb: 3 }}> | |||
| <Typography variant="h6" gutterBottom fontWeight="bold"> | |||
| {t("Production Process Information")} | |||
| </Typography> | |||
| <Stack spacing={2} direction="row" useFlexGap flexWrap="wrap"> | |||
| <Typography variant="subtitle1"> | |||
| <strong>{t("Job Order Code")}:</strong> {processData?.jobOrderCode} | |||
| </Typography> | |||
| <Typography variant="subtitle1"> | |||
| <strong>{t("Is Dark")}:</strong> {processData?.isDark} | |||
| </Typography> | |||
| <Typography variant="subtitle1"> | |||
| <strong>{t("Is Dense")}:</strong> {processData?.isDense} | |||
| </Typography> | |||
| <Typography variant="subtitle1"> | |||
| <strong>{t("Is Float")}:</strong> {processData?.isFloat} | |||
| </Typography> | |||
| <Typography variant="subtitle1"> | |||
| <strong>{t("Output Qty")}:</strong> {processData?.outputQty+" "+"("+processData?.outputQtyUom +")"} | |||
| </Typography> | |||
| <Box> | |||
| <strong>{t("Status")}:</strong>{" "} | |||
| <Chip | |||
| label={ | |||
| processData?.status === 'completed' ? t("Completed") : processData?.status === 'IN_PROGRESS' ? t("In Progress") : processData?.status === 'pending' ? t("Pending") : t("Unknown") | |||
| } | |||
| color={processData?.status === 'completed' ? 'success' : processData?.status === 'IN_PROGRESS' ? 'success' : processData?.status === 'pending' ? 'primary' : 'error'} | |||
| size="small" | |||
| /> | |||
| </Box> | |||
| <Typography variant="subtitle1"> | |||
| <strong>{t("Date")}:</strong> {dayjs(processData?.date).format(OUTPUT_DATE_FORMAT)} | |||
| </Typography> | |||
| <Typography variant="subtitle1"> | |||
| <strong>{t("Total Steps")}:</strong> {lines.length} | |||
| </Typography> | |||
| </Stack> | |||
| </Paper> | |||
| */} | |||
| {/* ========== 第二部分:Process Lines ========== */} | |||
| <Paper sx={{ p: 3 }}> | |||
| <Typography variant="h6" gutterBottom fontWeight="bold"> | |||
| @@ -602,25 +482,12 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| <TableCell>{t("Description")}</TableCell> | |||
| <TableCell>{t("EquipmentType-EquipmentName-Code")}</TableCell> | |||
| <TableCell>{t("Operator")}</TableCell> | |||
| {/*} | |||
| <TableCell>{t("Processing Time (mins)")}</TableCell> | |||
| <TableCell>{t("Setup Time (mins)")}</TableCell> | |||
| <TableCell>{t("Changeover Time (mins)")}</TableCell> | |||
| */} | |||
| <TableCell>{t("Assume End Time")}</TableCell> | |||
| <TableCell> | |||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Time Information(mins)")} | |||
| </Typography> | |||
| {/* | |||
| <Typography variant="caption" sx={{ color: 'text.secondary' }}> | |||
| {t("Processing Time")}- | |||
| </Typography> | |||
| <Typography variant="caption" sx={{ color: 'text.secondary' }}> | |||
| {t("Setup Time")} - {t("Changeover Time")} | |||
| </Typography> | |||
| */} | |||
| </Box> | |||
| </TableCell> | |||
| <TableCell align="center">{t("Status")}</TableCell> | |||
| @@ -638,7 +505,8 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| const isInProgress = statusLower === 'inprogress' || statusLower === 'in progress'; | |||
| const isPaused = statusLower === 'paused'; | |||
| const isPending = statusLower === 'pending' || status === ''; | |||
| const isPass = statusLower === 'pass'; | |||
| const isPassDisabled = isCompleted || isPass; | |||
| return ( | |||
| <TableRow key={line.id}> | |||
| <TableCell>{line.seqNo}</TableCell> | |||
| @@ -648,12 +516,16 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| <TableCell><Typography fontWeight={500}>{line.description || "-"}</Typography></TableCell> | |||
| <TableCell><Typography fontWeight={500}>{line.equipmentDetailCode||equipmentName}</Typography></TableCell> | |||
| <TableCell><Typography fontWeight={500}>{line.operatorName}</Typography></TableCell> | |||
| {/* | |||
| <TableCell><Typography fontWeight={500}>{line.durationInMinutes} </Typography></TableCell> | |||
| <TableCell><Typography fontWeight={500}>{line.prepTimeInMinutes} </Typography></TableCell> | |||
| <TableCell><Typography fontWeight={500}>{line.postProdTimeInMinutes} </Typography></TableCell> | |||
| */} | |||
| <TableCell> | |||
| <Typography fontWeight={500}> | |||
| {line.startTime && line.durationInMinutes | |||
| ? dayjs(line.startTime) | |||
| .add(line.durationInMinutes, 'minute') | |||
| .format('MM-DD HH:mm') | |||
| : '-'} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||
| <Typography variant="body2" > | |||
| {t("Processing Time")}: {line.durationInMinutes}{t("mins")} | |||
| @@ -662,7 +534,6 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| {t("Setup Time")}: {line.prepTimeInMinutes} {t("mins")} | |||
| </Typography> | |||
| <Typography variant="body2" > | |||
| {t("Changeover Time")}: {line.postProdTimeInMinutes} {t("mins")} | |||
| </Typography> | |||
| </Box> | |||
| @@ -689,53 +560,92 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| <Chip label={t("Pending")} color="default" size="small" /> | |||
| ) : isPaused ? ( | |||
| <Chip label={t("Paused")} color="warning" size="small" /> | |||
| ) : isPass ? ( | |||
| <Chip label={t("Pass")} color="success" size="small" /> | |||
| ) : ( | |||
| <Chip label={t("Unknown")} color="error" size="small" /> | |||
| )} | |||
| ) | |||
| } | |||
| </TableCell> | |||
| {!fromJosave&&( | |||
| <TableCell align="center"> | |||
| {statusLower === 'pending' ? ( | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| startIcon={<PlayArrowIcon />} | |||
| onClick={() => handleStartLineWithScan(line.id)} | |||
| > | |||
| {t("Start")} | |||
| </Button> | |||
| ) : statusLower === 'in_progress' || statusLower === 'in progress' || statusLower === 'paused' ? ( | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| startIcon={<CheckCircleIcon />} | |||
| onClick={async () => { | |||
| setSelectedLineId(line.id); | |||
| setIsExecutingLine(true); | |||
| await fetchProcessDetail(); | |||
| }} | |||
| > | |||
| {t("View")} | |||
| </Button> | |||
| ) : ( | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={async() => { | |||
| setSelectedLineId(line.id); | |||
| setIsExecutingLine(true); | |||
| await fetchProcessDetail(); | |||
| }} | |||
| > | |||
| {t("View")} | |||
| </Button> | |||
| )} | |||
| </TableCell> | |||
| )} | |||
| </TableRow> | |||
| ); | |||
| })} | |||
| </TableBody> | |||
| <TableCell align="center"> | |||
| <Stack direction="row" spacing={1} justifyContent="center"> | |||
| {statusLower === 'pending' ? ( | |||
| <> | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| startIcon={<PlayArrowIcon />} | |||
| onClick={() => handleStartLineWithScan(line.id)} | |||
| > | |||
| {t("Start")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| color="success" | |||
| onClick={() => handlePassLine(line.id)} | |||
| disabled={isPassDisabled} | |||
| > | |||
| {t("Pass")} | |||
| </Button> | |||
| </> | |||
| ) : statusLower === 'in_progress' || statusLower === 'in progress' || statusLower === 'paused' ? ( | |||
| <> | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| startIcon={<CheckCircleIcon />} | |||
| onClick={async () => { | |||
| setSelectedLineId(line.id); | |||
| setShowOutputPage(false); | |||
| setIsExecutingLine(true); | |||
| await fetchProcessDetail(); | |||
| }} | |||
| > | |||
| {t("View")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| color="success" | |||
| onClick={() => handlePassLine(line.id)} | |||
| disabled={isPassDisabled} | |||
| > | |||
| {t("Pass")} | |||
| </Button> | |||
| </> | |||
| ) : ( | |||
| <> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={async() => { | |||
| setSelectedLineId(line.id); | |||
| setIsExecutingLine(true); | |||
| await fetchProcessDetail(); | |||
| }} | |||
| > | |||
| {t("View")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| color="success" | |||
| onClick={() => handlePassLine(line.id)} | |||
| disabled={isPassDisabled} | |||
| > | |||
| {t("Pass")} | |||
| </Button> | |||
| </> | |||
| )} | |||
| </Stack> | |||
| </TableCell> | |||
| )} | |||
| </TableRow> | |||
| ); | |||
| })} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| ) : ( | |||
| @@ -743,13 +653,9 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| <ProductionProcessStepExecution | |||
| lineId={selectedLineId} | |||
| onBack={handleBackFromStep} | |||
| //onClose={() => { | |||
| // setIsExecutingLine(false) | |||
| // setSelectedLineId(null) | |||
| //}} | |||
| //onOutputSubmitted={async () => { | |||
| // await fetchProcessDetail() | |||
| //}} | |||
| processData={processData} // ✅ 添加 | |||
| allLines={lines} // ✅ 添加 | |||
| jobOrderId={jobOrderId} // ✅ 添加 | |||
| /> | |||
| )} | |||
| </Paper> | |||
| @@ -778,7 +684,6 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| <Box> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {/* ✅ Show both options */} | |||
| {scannedEquipmentCode | |||
| ? `${t("Equipment Code")}: ${scannedEquipmentCode}` | |||
| : t("Please scan equipment code") | |||
| @@ -23,7 +23,7 @@ import { | |||
| } from "@mui/material"; | |||
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { fetchProductProcessesByJobOrderId ,deleteJobOrder, updateProductProcessPriority} from "@/app/api/jo/actions"; | |||
| import { fetchProductProcessesByJobOrderId ,deleteJobOrder, updateProductProcessPriority, updateJoPlanStart} from "@/app/api/jo/actions"; | |||
| import ProductionProcessDetail from "./ProductionProcessDetail"; | |||
| import dayjs from "dayjs"; | |||
| import { OUTPUT_DATE_FORMAT, integerFormatter, arrayToDateString } from "@/app/utils/formatUtil"; | |||
| @@ -38,6 +38,9 @@ import { releaseJo, startJo } from "@/app/api/jo/actions"; | |||
| import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan"; | |||
| import ProcessSummaryHeader from "./ProcessSummaryHeader"; | |||
| import EditIcon from "@mui/icons-material/Edit"; | |||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import { dayjsToDateString } from "@/app/utils/formatUtil"; | |||
| interface JobOrderLine { | |||
| id: number; | |||
| @@ -74,6 +77,9 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp | |||
| const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | |||
| const [operationPriority, setOperationPriority] = useState<number>(50); | |||
| const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false); | |||
| const [openPlanStartDialog, setOpenPlanStartDialog] = useState(false); | |||
| const [planStartDate, setPlanStartDate] = useState<dayjs.Dayjs | null>(null); | |||
| const fetchData = useCallback(async () => { | |||
| setLoading(true); | |||
| @@ -126,6 +132,38 @@ const getStockAvailable = (line: JobOrderLine) => { | |||
| } | |||
| return line.stockQty || 0; | |||
| }; | |||
| const handleOpenPlanStartDialog = useCallback(() => { | |||
| // 将 processData.date 转换为 dayjs 对象 | |||
| if (processData?.date) { | |||
| // processData.date 可能是字符串或 Date 对象 | |||
| setPlanStartDate(dayjs(processData.date)); | |||
| } else { | |||
| setPlanStartDate(dayjs()); | |||
| } | |||
| setOpenPlanStartDialog(true); | |||
| }, [processData?.date]); | |||
| const handleClosePlanStartDialog = useCallback((_event?: object, _reason?: "backdropClick" | "escapeKeyDown") => { | |||
| setOpenPlanStartDialog(false); | |||
| setPlanStartDate(null); | |||
| }, []); | |||
| const handleUpdatePlanStart = useCallback(async (jobOrderId: number, planStart: string) => { | |||
| const response = await updateJoPlanStart({ id: jobOrderId, planStart }); | |||
| if (response) { | |||
| await fetchData(); | |||
| } | |||
| }, [fetchData]); | |||
| const handleConfirmPlanStart = useCallback(async () => { | |||
| if (!jobOrderId || !planStartDate) return; | |||
| // 将日期转换为后端需要的格式 (YYYY-MM-DDTHH:mm:ss) | |||
| const dateString = `${dayjsToDateString(planStartDate, "input")}T00:00:00`; | |||
| await handleUpdatePlanStart(jobOrderId, dateString); | |||
| setOpenPlanStartDialog(false); | |||
| setPlanStartDate(null); | |||
| }, [jobOrderId, planStartDate, handleUpdatePlanStart]); | |||
| const handleUpdateOperationPriority = useCallback(async (productProcessId: number, productionPriority: number) => { | |||
| const response = await updateProductProcessPriority(productProcessId, productionPriority) | |||
| if (response) { | |||
| @@ -273,6 +311,15 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| label={t("Target Production Date")} | |||
| fullWidth | |||
| disabled={true} | |||
| InputProps={{ | |||
| endAdornment: (processData?.jobOrderStatus === "planning" ? ( | |||
| <InputAdornment position="end"> | |||
| <IconButton size="small" onClick={handleOpenPlanStartDialog}> | |||
| <EditIcon fontSize="small" /> | |||
| </IconButton> | |||
| </InputAdornment> | |||
| ) : null), | |||
| }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| @@ -600,6 +647,40 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| <Button variant="contained" onClick={handleConfirmPriority}>{t("Save")}</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog | |||
| open={openPlanStartDialog} | |||
| onClose={handleClosePlanStartDialog} | |||
| fullWidth | |||
| maxWidth="xs" | |||
| > | |||
| <DialogTitle>{t("Update Target Production Date")}</DialogTitle> | |||
| <DialogContent> | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <DatePicker | |||
| label={t("Target Production Date")} | |||
| value={planStartDate} | |||
| onChange={(newValue) => setPlanStartDate(newValue)} | |||
| slotProps={{ | |||
| textField: { | |||
| fullWidth: true, | |||
| margin: "dense", | |||
| autoFocus: true, | |||
| } | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={handleClosePlanStartDialog}>{t("Cancel")}</Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={handleConfirmPlanStart} | |||
| disabled={!planStartDate} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| @@ -212,9 +212,11 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| <Card | |||
| sx={{ | |||
| minHeight: 160, | |||
| maxHeight: 240, | |||
| maxHeight: 300, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| border: "1px solid", | |||
| borderColor: "success.main", | |||
| }} | |||
| > | |||
| <CardContent | |||
| @@ -238,11 +240,17 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| {t("Item Name")}: {process.itemCode} {process.itemName} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Required Qty")}: {process.requiredQty} {process.uom} | |||
| {t("Production Priority")}: {process.productionPriority} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Required Qty")}: {process.requiredQty} ({process.uom}) | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Production date")}: {process.date ? dayjs(process.date as any).format(OUTPUT_DATE_FORMAT) : "-"} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Assume Time Need")}: {process.timeNeedToComplete} {t("minutes")} | |||
| </Typography> | |||
| {statusLower !== "pending" && linesWithStatus.length > 0 && ( | |||
| <Box sx={{ mt: 1 }}> | |||
| <Typography variant="body2" fontWeight={600}> | |||
| @@ -261,6 +269,19 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| )} | |||
| </Box> | |||
| )} | |||
| {statusLower == "pending" && ( | |||
| <Box sx={{ mt: 1 }}> | |||
| <Typography variant="body2" fontWeight={600} color= "white"> | |||
| {t("t")} | |||
| </Typography> | |||
| <Box sx={{ mt: 1 }}> | |||
| <Typography variant="caption" color="text.secondary" display="block"> | |||
| {""} | |||
| </Typography> | |||
| </Box> | |||
| </Box> | |||
| )} | |||
| </CardContent> | |||
| <CardActions sx={{ pt: 0.5 }}> | |||
| @@ -27,24 +27,42 @@ import StopIcon from "@mui/icons-material/Stop"; | |||
| import PauseIcon from "@mui/icons-material/Pause"; | |||
| import PlayArrowIcon from "@mui/icons-material/PlayArrow"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { JobOrderProcessLineDetailResponse, updateProductProcessLineQty,updateProductProcessLineQrscan,fetchProductProcessLineDetail ,UpdateProductProcessLineQtyRequest,saveProductProcessResumeTime,saveProductProcessIssueTime} from "@/app/api/jo/actions"; | |||
| import { | |||
| JobOrderProcessLineDetailResponse, | |||
| updateProductProcessLineQty, | |||
| updateProductProcessLineQrscan, | |||
| fetchProductProcessLineDetail, | |||
| UpdateProductProcessLineQtyRequest, | |||
| saveProductProcessResumeTime, | |||
| saveProductProcessIssueTime, | |||
| ProductProcessWithLinesResponse, // ✅ 添加 | |||
| ProductProcessLineResponse, // ✅ 添加 | |||
| } from "@/app/api/jo/actions"; | |||
| import { Operator, Machine } from "@/app/api/jo"; | |||
| import React, { useCallback, useEffect, useState } from "react"; | |||
| import React, { useCallback, useEffect, useState, useMemo } from "react"; // ✅ 添加 useMemo | |||
| import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | |||
| import { fetchNameList, NameList } from "@/app/api/user/actions"; | |||
| import BagConsumptionForm from "./BagConsumptionForm"; // ✅ 添加导入 | |||
| import OverallTimeRemainingCard from "./OverallTimeRemainingCard"; // ✅ 添加导入 | |||
| import dayjs from "dayjs"; | |||
| interface ProductionProcessStepExecutionProps { | |||
| lineId: number | null | |||
| onBack: () => void | |||
| //onClose: () => void | |||
| // onOutputSubmitted: () => Promise<void> | |||
| lineId: number | null; | |||
| onBack: () => void; | |||
| processData?: ProductProcessWithLinesResponse | null; // ✅ 添加 | |||
| allLines?: ProductProcessLineResponse[]; // ✅ 添加 | |||
| jobOrderId?: number; // ✅ 添加 | |||
| } | |||
| const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionProps> = ({ | |||
| lineId, | |||
| onBack, | |||
| processData, // ✅ 添加 | |||
| allLines, // ✅ 添加 | |||
| jobOrderId, // ✅ 添加 | |||
| }) => { | |||
| const { t } = useTranslation( ["common","jo"]); | |||
| const [lineDetail, setLineDetail] = useState<JobOrderProcessLineDetailResponse | null>(null); | |||
| const isCompleted = lineDetail?.status === "Completed"; | |||
| const isCompleted = lineDetail?.status === "Completed" || lineDetail?.status === "Pass"; | |||
| const [outputData, setOutputData] = useState<UpdateProductProcessLineQtyRequest & { | |||
| byproductName: string; | |||
| byproductQty: number; | |||
| @@ -82,11 +100,34 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| const [lastPauseTime, setLastPauseTime] = useState<Date | null>(null); | |||
| const[isOpenReasonModel, setIsOpenReasonModel] = useState(false); | |||
| const [pauseReason, setPauseReason] = useState(""); | |||
| // 检查是否两个都已扫描 | |||
| //const bothScanned = lineDetail?.operatorId && lineDetail?.equipmentId; | |||
| // ✅ 添加:判断是否显示 Bag 表单的条件 | |||
| const shouldShowBagForm = useMemo(() => { | |||
| if (!processData || !allLines || !lineDetail) return false; | |||
| // 检查 BOM description 是否为 "FG" | |||
| const bomDescription = processData.bomDescription; | |||
| if (bomDescription !== "FG") return false; | |||
| // 检查是否是最后一个 process line(按 seqNo 排序) | |||
| const sortedLines = [...allLines].sort((a, b) => (a.seqNo || 0) - (b.seqNo || 0)); | |||
| const maxSeqNo = sortedLines[sortedLines.length - 1]?.seqNo; | |||
| const isLastLine = lineDetail.seqNo === maxSeqNo; | |||
| return isLastLine; | |||
| }, [processData, allLines, lineDetail]); | |||
| // ✅ 添加:刷新 line detail 的函数 | |||
| const handleRefreshLineDetail = useCallback(async () => { | |||
| if (lineId) { | |||
| try { | |||
| const detail = await fetchProductProcessLineDetail(lineId); | |||
| setLineDetail(detail as any); | |||
| } catch (error) { | |||
| console.error("Failed to refresh line detail", error); | |||
| } | |||
| } | |||
| }, [lineId]); | |||
| useEffect(() => { | |||
| if (!lineId) { | |||
| @@ -108,8 +149,8 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| setOutputData(prev => ({ | |||
| ...prev, | |||
| productProcessLineId: detail.id, | |||
| outputFromProcessQty: (detail as any).outputFromProcessQty || 0, // 取消注释,使用类型断言 | |||
| outputFromProcessUom: (detail as any).outputFromProcessUom || "", // 取消注释,使用类型断言 | |||
| outputFromProcessQty: (detail as any).outputFromProcessQty || 0, | |||
| outputFromProcessUom: (detail as any).outputFromProcessUom || "", | |||
| defectQty: detail.defectQty || 0, | |||
| defectUom: detail.defectUom || "", | |||
| scrapQty: detail.scrapQty || 0, | |||
| @@ -124,16 +165,16 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| setLineDetail(null); | |||
| }); | |||
| }, [lineId]); | |||
| useEffect(() => { | |||
| // Don't show time remaining if completed | |||
| if (lineDetail?.status === "Completed") { | |||
| if (lineDetail?.status === "Completed" || lineDetail?.status === "Pass") { | |||
| console.log("Line is completed"); | |||
| setRemainingTime(null); | |||
| setIsOverTime(false); | |||
| return; | |||
| } | |||
| // ✅ 问题1:添加详细的调试打印 | |||
| console.log("🔍 Time Remaining Debug:", { | |||
| lineId: lineDetail?.id, | |||
| equipmentId: lineDetail?.equipmentId, | |||
| @@ -159,11 +200,9 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| return; | |||
| } | |||
| // Handle startTime format - it can be string or number array | |||
| let start: Date; | |||
| if (Array.isArray(lineDetail.startTime)) { | |||
| console.log("Line start time is an array:", lineDetail.startTime); | |||
| // If it's an array like [2025, 12, 15, 10, 30, 0], convert to Date | |||
| const [year, month, day, hour = 0, minute = 0, second = 0] = lineDetail.startTime; | |||
| start = new Date(year, month - 1, day, hour, minute, second); | |||
| } else { | |||
| @@ -171,7 +210,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| console.log("Line start time is a string:", lineDetail.startTime); | |||
| } | |||
| // Check if date is valid | |||
| if (isNaN(start.getTime())) { | |||
| console.error("Invalid startTime:", lineDetail.startTime); | |||
| setRemainingTime(null); | |||
| @@ -181,10 +219,8 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| const durationMs = lineDetail.durationInMinutes * 60_000; | |||
| // Check if line is paused | |||
| const isPaused = lineDetail.status === "Paused" || lineDetail.productProcessIssueStatus === "Paused"; | |||
| // ✅ 问题2:修复 stopTime 类型处理,像 startTime 一样处理 | |||
| const parseStopTime = (stopTime: string | number[] | undefined): Date | null => { | |||
| if (!stopTime) return null; | |||
| @@ -198,20 +234,15 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| const update = () => { | |||
| if (isPaused) { | |||
| // If paused, freeze the time at the last calculated value | |||
| // If we don't have a frozen value yet, calculate it based on stopTime | |||
| if (!frozenRemainingTime) { | |||
| // ✅ 修复问题2:正确处理 stopTime 的类型(string | number[]) | |||
| const pauseTime = lineDetail.stopTime | |||
| ? parseStopTime(lineDetail.stopTime) | |||
| : null; | |||
| // 如果没有 stopTime,使用当前时间(首次暂停时) | |||
| const pauseTimeToUse = pauseTime && !isNaN(pauseTime.getTime()) | |||
| ? pauseTime | |||
| : new Date(); | |||
| // ✅ 计算总暂停时间(所有已恢复的暂停记录) | |||
| const totalPausedTimeMs = (lineDetail as any).totalPausedTimeMs || 0; | |||
| console.log("⏸️ Paused - calculating frozen time:", { | |||
| @@ -221,7 +252,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| totalPausedTimeMs: totalPausedTimeMs, | |||
| }); | |||
| // ✅ 实际工作时间 = 暂停时间 - 开始时间 - 已恢复的暂停时间 | |||
| const elapsed = pauseTimeToUse.getTime() - start.getTime() - totalPausedTimeMs; | |||
| const remaining = durationMs - elapsed; | |||
| @@ -244,25 +274,21 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| console.log("⏸️ Frozen time:", frozenValue); | |||
| } | |||
| } else { | |||
| // ✅ 关键修复:暂停时始终使用冻结的值,不重新计算 | |||
| setRemainingTime(frozenRemainingTime); | |||
| console.log("⏸️ Using frozen time:", frozenRemainingTime); | |||
| } | |||
| return; | |||
| } | |||
| // If resumed or in progress, clear frozen time and continue counting | |||
| if (frozenRemainingTime && !isPaused) { | |||
| console.log("▶️ Resumed - clearing frozen time"); | |||
| setFrozenRemainingTime(null); | |||
| setLastPauseTime(null); | |||
| } | |||
| // ✅ 关键修复:计算剩余时间时,需要减去所有已恢复的暂停时间 | |||
| const totalPausedTimeMs = (lineDetail as any).totalPausedTimeMs || 0; | |||
| const now = new Date(); | |||
| // ✅ 实际工作时间 = 当前时间 - 开始时间 - 所有已恢复的暂停时间 | |||
| const elapsed = now.getTime() - start.getTime() - totalPausedTimeMs; | |||
| const remaining = durationMs - elapsed; | |||
| @@ -276,7 +302,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| }); | |||
| if (remaining <= 0) { | |||
| // Over time - show negative time in red | |||
| const overTime = Math.abs(remaining); | |||
| const minutes = Math.floor(overTime / 60000).toString().padStart(2, "0"); | |||
| const seconds = Math.floor((overTime % 60000) / 1000).toString().padStart(2, "0"); | |||
| @@ -292,31 +317,25 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| update(); | |||
| // Only set interval if not paused | |||
| if (!isPaused) { | |||
| const timer = setInterval(update, 1000); | |||
| return () => clearInterval(timer); | |||
| } | |||
| }, [lineDetail?.durationInMinutes, lineDetail?.startTime, lineDetail?.status, lineDetail?.productProcessIssueStatus, lineDetail?.stopTime, frozenRemainingTime]); | |||
| // Reset frozen time when status changes from paused to in progress | |||
| useEffect(() => { | |||
| const wasPaused = lineDetail?.status === "Paused" || lineDetail?.productProcessIssueStatus === "Paused"; | |||
| const isNowInProgress = lineDetail?.status === "InProgress"; | |||
| if (wasPaused && isNowInProgress && frozenRemainingTime) { | |||
| // When resuming, we need to account for the pause duration | |||
| // For now, we'll continue from the frozen time | |||
| // In a more accurate implementation, you'd fetch the issue details to get exact pause duration | |||
| setFrozenRemainingTime(null); | |||
| } | |||
| }, [lineDetail?.status, lineDetail?.productProcessIssueStatus]); | |||
| const handleSubmitOutput = async () => { | |||
| if (!lineDetail?.id) return; | |||
| try { | |||
| // 直接使用 actions.ts 中定义的函数 | |||
| await updateProductProcessLineQty({ | |||
| productProcessLineId: lineDetail?.id || 0 as number, | |||
| byproductName: outputData.byproductName, | |||
| @@ -324,7 +343,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| byproductUom: outputData.byproductUom, | |||
| outputFromProcessQty: outputData.outputFromProcessQty, | |||
| outputFromProcessUom: outputData.outputFromProcessUom, | |||
| // outputFromProcessUom: outputData.outputFromProcessUom, | |||
| defectQty: outputData.defectQty, | |||
| defectUom: outputData.defectUom, | |||
| defect2Qty: outputData.defect2Qty, | |||
| @@ -350,12 +368,11 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| productProcessIssueStatus: detail.productProcessIssueStatus | |||
| }); | |||
| setLineDetail(detail as any); | |||
| // 初始化 outputData 从 lineDetail | |||
| setOutputData(prev => ({ | |||
| ...prev, | |||
| productProcessLineId: detail.id, | |||
| outputFromProcessQty: (detail as any).outputFromProcessQty || 0, // 取消注释,使用类型断言 | |||
| outputFromProcessUom: (detail as any).outputFromProcessUom || "", // 取消注释,使用类型断言 | |||
| outputFromProcessQty: (detail as any).outputFromProcessQty || 0, | |||
| outputFromProcessUom: (detail as any).outputFromProcessUom || "", | |||
| defectQty: detail.defectQty || 0, | |||
| defectUom: detail.defectUom || "", | |||
| defectDescription: detail.defectDescription || "", | |||
| @@ -382,7 +399,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| } | |||
| }; | |||
| // 处理 QR 码扫描效果 | |||
| useEffect(() => { | |||
| if (isManualScanning && qrValues.length > 0 && lineDetail?.id) { | |||
| const latestQr = qrValues[qrValues.length - 1]; | |||
| @@ -392,20 +408,88 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| } | |||
| setProcessedQrCodes(prev => new Set(prev).add(latestQr)); | |||
| //processQrCode(latestQr); | |||
| } | |||
| }, [qrValues, isManualScanning, lineDetail?.id, processedQrCodes]); | |||
| // 开始扫描 | |||
| const lineAssumeEndTime = useMemo(() => { | |||
| if (!lineDetail?.startTime || !lineDetail?.durationInMinutes) return null; | |||
| // 解析 startTime(可能是数组或字符串) | |||
| let start: dayjs.Dayjs; | |||
| if (Array.isArray(lineDetail.startTime)) { | |||
| const [year, month, day, hour = 0, minute = 0, second = 0] = lineDetail.startTime; | |||
| start = dayjs(new Date(year, month - 1, day, hour, minute, second)); | |||
| } else if (typeof lineDetail.startTime === 'string') { | |||
| // 检查是否是 "MM-DD HH:mm" 格式 | |||
| const mmddHhmmPattern = /^(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2})$/; | |||
| const match = lineDetail.startTime.match(mmddHhmmPattern); | |||
| if (match) { | |||
| const month = parseInt(match[1], 10); | |||
| const day = parseInt(match[2], 10); | |||
| const hour = parseInt(match[3], 10); | |||
| const minute = parseInt(match[4], 10); | |||
| // 使用当前年份,但如果跨年(startTime 是年末,当前是年初),使用上一年 | |||
| const now = dayjs(); | |||
| let year = now.year(); | |||
| if (month === 12 && day >= 20 && now.month() === 0 && now.date() <= 10) { | |||
| year = now.year() - 1; | |||
| } | |||
| start = dayjs(new Date(year, month - 1, day, hour, minute, 0)); | |||
| } else { | |||
| start = dayjs(lineDetail.startTime); | |||
| } | |||
| } else { | |||
| start = dayjs(lineDetail.startTime as any); | |||
| } | |||
| if (!start.isValid()) return null; | |||
| return start.add(lineDetail.durationInMinutes, 'minute'); | |||
| }, [lineDetail?.startTime, lineDetail?.durationInMinutes]); | |||
| const lineStartTime = useMemo(() => { | |||
| if (!lineDetail?.startTime) return null; | |||
| let start: dayjs.Dayjs; | |||
| if (Array.isArray(lineDetail.startTime)) { | |||
| const [year, month, day, hour = 0, minute = 0, second = 0] = lineDetail.startTime; | |||
| start = dayjs(new Date(year, month - 1, day, hour, minute, second)); | |||
| } else if (typeof lineDetail.startTime === 'string') { | |||
| const mmddHhmmPattern = /^(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2})$/; | |||
| const match = lineDetail.startTime.match(mmddHhmmPattern); | |||
| if (match) { | |||
| const month = parseInt(match[1], 10); | |||
| const day = parseInt(match[2], 10); | |||
| const hour = parseInt(match[3], 10); | |||
| const minute = parseInt(match[4], 10); | |||
| const now = dayjs(); | |||
| let year = now.year(); | |||
| if (month === 12 && day >= 20 && now.month() === 0 && now.date() <= 10) { | |||
| year = now.year() - 1; | |||
| } | |||
| start = dayjs(new Date(year, month - 1, day, hour, minute, 0)); | |||
| } else { | |||
| start = dayjs(lineDetail.startTime); | |||
| } | |||
| } else { | |||
| start = dayjs(lineDetail.startTime as any); | |||
| } | |||
| return start.isValid() ? start : null; | |||
| }, [lineDetail?.startTime]); | |||
| const handleOpenReasonModel = () => { | |||
| setIsOpenReasonModel(true); | |||
| setPauseReason(""); // 重置原因 | |||
| setPauseReason(""); | |||
| }; | |||
| const handleCloseReasonModel = () => { | |||
| setIsOpenReasonModel(false); | |||
| setPauseReason(""); // 清空原因 | |||
| setPauseReason(""); | |||
| }; | |||
| const handleSaveReason = async () => { | |||
| if (!pauseReason.trim()) { | |||
| @@ -421,7 +505,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| }); | |||
| setIsOpenReasonModel(false); | |||
| setPauseReason(""); | |||
| // 刷新 line detail | |||
| fetchProductProcessLineDetail(lineDetail.id) | |||
| .then((detail) => { | |||
| setLineDetail(detail as any); | |||
| @@ -435,7 +518,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| } | |||
| }; | |||
| // ✅ Add this new handler for resume | |||
| const handleResume = async () => { | |||
| if (!lineDetail?.productProcessIssueId) { | |||
| console.error("No productProcessIssueId found"); | |||
| @@ -446,13 +528,11 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| await saveProductProcessResumeTime(lineDetail.productProcessIssueId); | |||
| console.log("✅ Resume API called successfully"); | |||
| // ✅ Refresh line detail after resume | |||
| if (lineDetail?.id) { | |||
| fetchProductProcessLineDetail(lineDetail.id) | |||
| .then((detail) => { | |||
| console.log("✅ Line detail refreshed after resume:", detail); | |||
| setLineDetail(detail as any); | |||
| // Clear frozen time when resuming | |||
| setFrozenRemainingTime(null); | |||
| setLastPauseTime(null); | |||
| }) | |||
| @@ -465,6 +545,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| alert(t("Failed to resume. Please try again.")); | |||
| } | |||
| }; | |||
| return ( | |||
| <Box> | |||
| <Box sx={{ mb: 2 }}> | |||
| @@ -472,18 +553,21 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| {t("Back to List")} | |||
| </Button> | |||
| </Box> | |||
| {/* 如果已完成,显示合并的视图 */} | |||
| {processData && ( | |||
| <OverallTimeRemainingCard processData={processData} /> | |||
| )} | |||
| {isCompleted ? ( | |||
| <Card sx={{ bgcolor: 'success.50', border: '2px solid', borderColor: 'success.main', mb: 3 }}> | |||
| <CardContent> | |||
| {lineDetail?.status === "Pass" ? ( | |||
| <Typography variant="h5" color="success.main" gutterBottom fontWeight="bold"> | |||
| {t("Passed Step")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo}) | |||
| </Typography> | |||
| ) : ( | |||
| <Typography variant="h5" color="success.main" gutterBottom fontWeight="bold"> | |||
| {t("Completed Step")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo}) | |||
| </Typography> | |||
| {/*<Divider sx={{ my: 2 }} />*/} | |||
| {/* 步骤信息部分 */} | |||
| {t("Completed Step")}: {lineDetail?.name} ({t("Seq")}: {lineDetail?.seqNo}) | |||
| </Typography> | |||
| )} | |||
| <Typography variant="h6" gutterBottom sx={{ mt: 2 }}> | |||
| {t("Step Information")} | |||
| </Typography> | |||
| @@ -510,9 +594,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| </Grid> | |||
| </Grid> | |||
| {/*<Divider sx={{ my: 2 }} />*/} | |||
| {/* 产出数据部分 */} | |||
| <Typography variant="h6" gutterBottom sx={{ mt: 2 }}> | |||
| {t("Production Output Data")} | |||
| </Typography> | |||
| @@ -526,7 +607,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {/* Output from Process */} | |||
| <TableRow> | |||
| <TableCell> | |||
| <Typography fontWeight={500}>{t("Output from Process")}</Typography> | |||
| @@ -539,27 +619,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| </TableCell> | |||
| </TableRow> | |||
| {/* By-product */} | |||
| {/* | |||
| <TableRow> | |||
| <TableCell> | |||
| <Typography fontWeight={500}>{t("By-product")}</Typography> | |||
| {lineDetail.byproductName && ( | |||
| <Typography variant="caption" color="text.secondary"> | |||
| ({lineDetail.byproductName}) | |||
| </Typography> | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.byproductQty}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography>{lineDetail.byproductUom || "-"}</Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| */} | |||
| {/* Defect */} | |||
| <TableRow sx={{ bgcolor: 'warning.50' }}> | |||
| <TableCell> | |||
| <Typography fontWeight={500} color="warning.dark">{t("Defect")}</Typography> | |||
| @@ -573,7 +632,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| <TableCell> | |||
| <Typography>{lineDetail.defectDescription || "-"}</Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| <TableRow sx={{ bgcolor: 'warning.50' }}> | |||
| <TableCell> | |||
| @@ -588,7 +646,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| <TableCell> | |||
| <Typography>{lineDetail.defectDescription3 || "-"}</Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| <TableRow sx={{ bgcolor: 'warning.50' }}> | |||
| <TableCell> | |||
| @@ -603,9 +660,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| <TableCell> | |||
| <Typography>{lineDetail.defectDescription2 || "-"}</Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| {/* Scrap */} | |||
| <TableRow sx={{ bgcolor: 'error.50' }}> | |||
| <TableCell> | |||
| <Typography fontWeight={500} color="error.dark">{t("Scrap")}</Typography> | |||
| @@ -623,8 +678,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| </Card> | |||
| ) : ( | |||
| <> | |||
| {/* 如果未完成,显示原来的两个部分 */} | |||
| {/* 当前步骤信息 */} | |||
| {!showOutputTable && ( | |||
| <Grid container spacing={2} sx={{ mb: 3 }}> | |||
| <Grid item xs={12} > | |||
| @@ -654,6 +707,27 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| > | |||
| {isOverTime ? `${t("Over Time")}: ${remainingTime}` : remainingTime} | |||
| </Typography> | |||
| {/* ✅ 添加:Process Start Time 和 Assume End Time */} | |||
| {/* ✅ 添加:Process Start Time 和 Assume End Time */} | |||
| {processData?.startTime && ( | |||
| <Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}> | |||
| <Typography variant="body2" color="text.secondary" gutterBottom> | |||
| <strong>{t("Process Start Time")}:</strong> {dayjs(processData.startTime).format("MM-DD")} {dayjs(processData.startTime).format("HH:mm")} | |||
| </Typography> | |||
| </Box> | |||
| )} | |||
| {lineStartTime && ( | |||
| <Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}> | |||
| <Typography variant="body2" color="text.secondary" gutterBottom> | |||
| <strong>{t("Step Start Time")}:</strong> {lineStartTime.format("MM-DD")} {lineStartTime.format("HH:mm")} | |||
| </Typography> | |||
| {lineAssumeEndTime && ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| <strong>{t("Assume End Time")}:</strong> {lineAssumeEndTime.format("MM-DD")} {lineAssumeEndTime.format("HH:mm")} | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| )} | |||
| {lineDetail?.status === "Paused" && ( | |||
| <Typography variant="caption" color="warning.main" sx={{ mt: 0.5, display: 'block' }}> | |||
| {t("Timer Paused")} | |||
| @@ -662,17 +736,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| </Box> | |||
| )} | |||
| <Stack direction="row" spacing={2} justifyContent="center" sx={{ mt: 2 }}> | |||
| {/* | |||
| <Button | |||
| variant="contained" | |||
| color="error" | |||
| startIcon={<StopIcon />} | |||
| onClick={() => saveProductProcessIssueTime(lineDetail?.id || 0 as number)} | |||
| > | |||
| {t("Stop")} | |||
| </Button> | |||
| */ | |||
| } | |||
| { lineDetail?.status === 'InProgress'? ( | |||
| <Button | |||
| variant="contained" | |||
| @@ -687,7 +750,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| variant="contained" | |||
| color="success" | |||
| startIcon={<PlayArrowIcon />} | |||
| onClick={handleResume} // ✅ Change from inline call to handler | |||
| onClick={handleResume} | |||
| > | |||
| {t("Continue")} | |||
| </Button> | |||
| @@ -710,8 +773,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| {/* ========== 产出输入表单 ========== */} | |||
| {showOutputTable && ( | |||
| <Box> | |||
| <Paper sx={{ p: 3, bgcolor: 'grey.50' }}> | |||
| <Table size="small"> | |||
| <TableHead> | |||
| @@ -723,7 +784,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {/* start line output */} | |||
| <TableRow> | |||
| <TableCell> | |||
| <Typography fontWeight={500}>{t("Output from Process")}</Typography> | |||
| @@ -756,8 +816,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| </TableCell> | |||
| </TableRow> | |||
| {/* defect 1 */} | |||
| <TableRow sx={{ bgcolor: 'warning.50' }}> | |||
| <TableCell> | |||
| <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(1)")}</Typography> | |||
| @@ -789,7 +847,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| <TextField | |||
| fullWidth | |||
| size="small" | |||
| //value={outputData.defectUom} | |||
| onChange={(e) => setOutputData({ | |||
| ...outputData, | |||
| defectDescription: e.target.value | |||
| @@ -797,7 +854,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| /> | |||
| </TableCell> | |||
| </TableRow> | |||
| {/* defect 2 */} | |||
| <TableRow sx={{ bgcolor: 'warning.50' }}> | |||
| <TableCell> | |||
| <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(2)")}</Typography> | |||
| @@ -829,7 +885,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| <TextField | |||
| fullWidth | |||
| size="small" | |||
| //value={outputData.defectUom} | |||
| onChange={(e) => setOutputData({ | |||
| ...outputData, | |||
| defectDescription2: e.target.value | |||
| @@ -837,7 +892,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| /> | |||
| </TableCell> | |||
| </TableRow> | |||
| {/* defect 3 */} | |||
| <TableRow sx={{ bgcolor: 'warning.50' }}> | |||
| <TableCell> | |||
| <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(3)")}</Typography> | |||
| @@ -869,7 +923,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| <TextField | |||
| fullWidth | |||
| size="small" | |||
| //value={outputData.defectUom} | |||
| onChange={(e) => setOutputData({ | |||
| ...outputData, | |||
| defectDescription3: e.target.value | |||
| @@ -877,7 +930,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| /> | |||
| </TableCell> | |||
| </TableRow> | |||
| {/* scrap */} | |||
| <TableRow sx={{ bgcolor: 'error.50' }}> | |||
| <TableCell> | |||
| <Typography fontWeight={500} color="error.dark">{t("Scrap")}</Typography> | |||
| @@ -909,7 +961,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| </TableBody> | |||
| </Table> | |||
| {/* submit button */} | |||
| <Box sx={{ mt: 3, display: 'flex', gap: 2 }}> | |||
| <Button | |||
| variant="outlined" | |||
| @@ -928,6 +979,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| </Paper> | |||
| </Box> | |||
| )} | |||
| {/* ========== Bag Consumption Form ========== */} | |||
| {((showOutputTable || isCompleted) && shouldShowBagForm && jobOrderId && lineId) && ( | |||
| <BagConsumptionForm | |||
| jobOrderId={jobOrderId} | |||
| lineId={lineId} | |||
| bomDescription={processData?.bomDescription} | |||
| isLastLine={shouldShowBagForm} | |||
| onRefresh={handleRefreshLineDetail} | |||
| /> | |||
| )} | |||
| </> | |||
| )} | |||
| <Dialog | |||
| @@ -947,7 +1009,6 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| rows={4} | |||
| value={pauseReason} | |||
| onChange={(e) => setPauseReason(e.target.value)} | |||
| //required | |||
| /> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| @@ -142,28 +142,25 @@ const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false }) => { | |||
| if (isNaN(accQty) || accQty === undefined || accQty === null || typeof(accQty) != "number") { | |||
| setError("acceptQty", { message: t("value must be a number") }); | |||
| } else | |||
| if (!Number.isInteger(accQty)) { | |||
| setError("acceptQty", { message: t("value must be integer") }); | |||
| } | |||
| if (accQty > itemDetail.acceptedQty) { | |||
| setError("acceptQty", { message: `${t("acceptQty must not greater than")} ${ | |||
| itemDetail.acceptedQty}` }); | |||
| } else | |||
| if (accQty < 1) { | |||
| if (accQty <= 0) { | |||
| setError("acceptQty", { message: t("minimal value is 1") }); | |||
| } else | |||
| console.log("%c Validated accQty:", "color:yellow", accQty); | |||
| } | |||
| },[setError, qcDecision, accQty, itemDetail]) | |||
| useEffect(() => { // W I P // ----- | |||
| if (qcDecision == 1) { | |||
| if (validateFieldFail("acceptQty", accQty > itemDetail.acceptedQty, `${t("acceptQty must not greater than")} ${ | |||
| itemDetail.acceptedQty}`)) return; | |||
| if (validateFieldFail("acceptQty", accQty < 1, t("minimal value is 1"))) return; | |||
| if (validateFieldFail("acceptQty", isNaN(accQty), t("value must be a number"))) return; | |||
| } | |||
| useEffect(() => { // W I P // ----- | |||
| if (qcDecision == 1) { | |||
| if (validateFieldFail("acceptQty", accQty > itemDetail.acceptedQty, `${t("acceptQty must not greater than")} ${ | |||
| itemDetail.acceptedQty}`)) return; | |||
| if (validateFieldFail("acceptQty", accQty <= 0, t("minimal value is 1"))) return; | |||
| if (validateFieldFail("acceptQty", isNaN(accQty), t("value must be a number"))) return; | |||
| } | |||
| const qcResultItems = qcResult; //console.log("Validating:", qcResultItems); | |||
| @@ -586,17 +583,26 @@ useEffect(() => { | |||
| onInput={(e: React.ChangeEvent<HTMLInputElement>) => { | |||
| const input = e.target.value; | |||
| const numReg = /^[0-9]+$/ | |||
| // 允许数字和小数点,但只允许一个小数点 | |||
| const numReg = /^\d+(\.\d*)?$/ | |||
| let r = ''; | |||
| if (!numReg.test(input)) { | |||
| const result = input.replace(/\D/g, ""); | |||
| r = (result === '' ? result : Number(result)).toString(); | |||
| if (input === '' || input === '.') { | |||
| r = input; | |||
| } else if (!numReg.test(input)) { | |||
| // 移除非数字字符,但保留一个小数点 | |||
| let result = input.replace(/[^0-9.]/g, ''); | |||
| // 确保只有一个小数点 | |||
| const parts = result.split('.'); | |||
| if (parts.length > 2) { | |||
| result = parts[0] + '.' + parts.slice(1).join(''); | |||
| } | |||
| r = result; | |||
| } else { | |||
| r = Number(input).toString() | |||
| r = input; | |||
| } | |||
| e.target.value = r; | |||
| }} | |||
| inputProps={{ min: 1, max:itemDetail.acceptedQty }} | |||
| inputProps={{ min: 0.01, max:itemDetail.acceptedQty, step: 0.01 }} | |||
| // onChange={(e) => { | |||
| // const inputValue = e.target.value; | |||
| // if (inputValue === '' || /^[0-9]*$/.test(inputValue)) { | |||