| @@ -576,6 +576,7 @@ export interface PickOrderLineWithLotsResponse { | |||
| itemCode: string | null; | |||
| itemName: string | null; | |||
| requiredQty: number | null; | |||
| totalAvailableQty?: number | null; | |||
| uomCode: string | null; | |||
| uomDesc: string | null; | |||
| status: string | null; | |||
| @@ -513,6 +513,8 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||
| const [originalCombinedData, setOriginalCombinedData] = useState<any[]>([]); | |||
| // issue form 里填的 actualPickQty(用于 batch submit 只提交实际拣到数量,而不是补拣到 required) | |||
| const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({}); | |||
| // 防止重复点击(Submit / Just Completed / Issue) | |||
| const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({}); | |||
| const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||
| @@ -2398,8 +2400,14 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||
| console.error("No stock out line found for this lot"); | |||
| return; | |||
| } | |||
| const solId = Number(lot.stockOutLineId); | |||
| if (solId > 0 && actionBusyBySolId[solId]) { | |||
| console.warn("Action already in progress for stockOutLineId:", solId); | |||
| return; | |||
| } | |||
| try { | |||
| if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); | |||
| // Special case: If submitQty is 0 and all values are 0, mark as completed with qty: 0 | |||
| if (submitQty === 0) { | |||
| console.log(`=== SUBMITTING ALL ZEROS CASE ===`); | |||
| @@ -2524,8 +2532,10 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe | |||
| } catch (error) { | |||
| console.error("Error submitting pick quantity:", error); | |||
| } finally { | |||
| if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | |||
| } | |||
| }, [fetchAllCombinedLotData, checkAndAutoAssignNext]); | |||
| }, [fetchAllCombinedLotData, checkAndAutoAssignNext, actionBusyBySolId]); | |||
| const handleSkip = useCallback(async (lot: any) => { | |||
| try { | |||
| @@ -2592,8 +2602,14 @@ const handleStartScan = useCallback(() => { | |||
| console.error(" No stockOutLineId found for lot:", lot); | |||
| return; | |||
| } | |||
| const solId = Number(stockOutLineId); | |||
| if (solId > 0 && actionBusyBySolId[solId]) { | |||
| console.warn("Action already in progress for stockOutLineId:", solId); | |||
| return; | |||
| } | |||
| try { | |||
| if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); | |||
| // Step 1: Update stock out line status | |||
| await updateStockOutLineStatus({ | |||
| id: stockOutLineId, | |||
| @@ -2641,8 +2657,10 @@ const handleStartScan = useCallback(() => { | |||
| await fetchAllCombinedLotData(); | |||
| } catch (error) { | |||
| console.error(" Error in handlelotnull:", error); | |||
| } finally { | |||
| if (solId > 0) setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | |||
| } | |||
| }, [fetchAllCombinedLotData, session, currentUserId, fgPickOrders]); | |||
| }, [fetchAllCombinedLotData, session, currentUserId, fgPickOrders, actionBusyBySolId]); | |||
| const handleBatchScan = useCallback(async () => { | |||
| const startTime = performance.now(); | |||
| console.log(`⏱️ [BATCH SCAN START]`); | |||
| @@ -3299,7 +3317,10 @@ paginatedData.map((lot, index) => { | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={() => handlelotnull(lot)} | |||
| disabled={status === 'completed'} | |||
| disabled={ | |||
| status === 'completed' || | |||
| (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) | |||
| } | |||
| sx={{ | |||
| fontSize: '0.7rem', | |||
| py: 0.5, | |||
| @@ -3330,7 +3351,8 @@ paginatedData.map((lot, index) => { | |||
| lot.lotAvailability === 'status_unavailable' || | |||
| lot.lotAvailability === 'rejected' || | |||
| lot.stockOutLineStatus === 'completed' || | |||
| lot.stockOutLineStatus === 'pending' | |||
| lot.stockOutLineStatus === 'pending' || | |||
| (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) | |||
| } | |||
| sx={{ fontSize: '0.75rem', py: 0.5, minHeight: '28px', minWidth: '70px' }} | |||
| > | |||
| @@ -3343,7 +3365,8 @@ paginatedData.map((lot, index) => { | |||
| onClick={() => handlePickExecutionForm(lot)} | |||
| disabled={ | |||
| lot.lotAvailability === 'expired' || | |||
| lot.stockOutLineStatus === 'completed' | |||
| lot.stockOutLineStatus === 'completed' || | |||
| (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) | |||
| } | |||
| sx={{ | |||
| fontSize: '0.7rem', | |||
| @@ -3361,7 +3384,12 @@ paginatedData.map((lot, index) => { | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={() => handleSkip(lot)} | |||
| disabled={lot.stockOutLineStatus === 'completed'} | |||
| disabled={ | |||
| lot.stockOutLineStatus === 'completed' || | |||
| // 使用 issue form 後,禁用「Just Completed」(避免再次点击造成重复提交) | |||
| (Number(lot.stockOutLineId) > 0 && issuePickedQtyBySolId[Number(lot.stockOutLineId)] !== undefined) || | |||
| (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) | |||
| } | |||
| sx={{ fontSize: '0.7rem', py: 0.5, minHeight: '28px', minWidth: '60px' }} | |||
| > | |||
| {t("Just Completed")} | |||
| @@ -3,7 +3,7 @@ import { JoDetail } from "@/app/api/jo"; | |||
| import { SaveJo, manualCreateJo } from "@/app/api/jo/actions"; | |||
| import { OUTPUT_DATE_FORMAT, OUTPUT_TIME_FORMAT, dateStringToDayjs, dayjsToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil"; | |||
| import { Check } from "@mui/icons-material"; | |||
| import { Autocomplete, Box, Button, Card, Grid, Modal, Stack, TextField, Typography ,FormControl, InputLabel, Select, MenuItem,InputAdornment} from "@mui/material"; | |||
| import { Autocomplete, Box, Button, Card, CircularProgress, Grid, Modal, Stack, TextField, Typography ,FormControl, InputLabel, Select, MenuItem,InputAdornment} from "@mui/material"; | |||
| import { DatePicker, DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import dayjs, { Dayjs } from "dayjs"; | |||
| @@ -31,6 +31,7 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| }) => { | |||
| const { t } = useTranslation("jo"); | |||
| const [multiplier, setMultiplier] = useState<number>(1); | |||
| const [isSubmitting, setIsSubmitting] = useState(false); | |||
| const formProps = useForm<SaveJo>({ | |||
| mode: "onChange", | |||
| defaultValues: { | |||
| @@ -73,10 +74,11 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| } | |||
| }, [multiplier, selectedBomId, bomCombo, formProps]); | |||
| const onModalClose = useCallback(() => { | |||
| if (isSubmitting) return; | |||
| reset() | |||
| onClose() | |||
| setMultiplier(1); | |||
| }, [reset, onClose]) | |||
| }, [reset, onClose, isSubmitting]) | |||
| const duplicateLabels = useMemo(() => { | |||
| const count = new Map<string, number>(); | |||
| bomCombo.forEach((b) => count.set(b.label, (count.get(b.label) ?? 0) + 1)); | |||
| @@ -149,23 +151,32 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| }, []) | |||
| const onSubmit = useCallback<SubmitHandler<SaveJo>>(async (data) => { | |||
| data.type = "manual" | |||
| if (data.planStart) { | |||
| const dateDayjs = dateStringToDayjs(data.planStart) | |||
| 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(); | |||
| msg(t("update success")); | |||
| onModalClose(); | |||
| if (isSubmitting) return; | |||
| setIsSubmitting(true); | |||
| try { | |||
| data.type = "manual" | |||
| if (data.planStart) { | |||
| const dateDayjs = dateStringToDayjs(data.planStart) | |||
| 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(); | |||
| msg(t("update success")); | |||
| onModalClose(); | |||
| } | |||
| } catch (e) { | |||
| console.error(e); | |||
| msg(t("update failed")); | |||
| } finally { | |||
| setIsSubmitting(false); | |||
| } | |||
| }, [onSearch, onModalClose, t]) | |||
| }, [onSearch, onModalClose, t, isSubmitting]) | |||
| const onSubmitError = useCallback<SubmitErrorHandler<SaveJo>>((error) => { | |||
| console.log(error) | |||
| @@ -505,10 +516,11 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| <Button | |||
| name="submit" | |||
| variant="contained" | |||
| startIcon={<Check />} | |||
| startIcon={isSubmitting ? <CircularProgress size={16} color="inherit" /> : <Check />} | |||
| type="submit" | |||
| disabled={isSubmitting} | |||
| > | |||
| {t("Create")} | |||
| {isSubmitting ? t("Creating...") : t("Create")} | |||
| </Button> | |||
| </Stack> | |||
| </LocalizationProvider> | |||
| @@ -76,32 +76,32 @@ interface CompletedJobOrderPickOrder { | |||
| // 新增:Lot 详情接口 | |||
| interface LotDetail { | |||
| lotId: number; | |||
| lotNo: string; | |||
| expiryDate: string; | |||
| location: string; | |||
| availableQty: number; | |||
| requiredQty: number; | |||
| actualPickQty: number; | |||
| lotId: number | null; | |||
| lotNo: string | null; | |||
| expiryDate: string | null; | |||
| location: string | null; | |||
| availableQty: number | null; | |||
| requiredQty: number | null; | |||
| actualPickQty: number | null; | |||
| processingStatus: string; | |||
| lotAvailability: string; | |||
| pickOrderId: number; | |||
| pickOrderCode: string; | |||
| pickOrderConsoCode: string; | |||
| pickOrderLineId: number; | |||
| stockOutLineId: number; | |||
| stockOutLineId: number | null; | |||
| stockOutLineStatus: string; | |||
| routerIndex: number; | |||
| routerArea: string; | |||
| routerRoute: string; | |||
| uomShortDesc: string; | |||
| routerIndex: number | null; | |||
| routerArea: string | null; | |||
| routerRoute: string | null; | |||
| uomShortDesc: string | null; | |||
| secondQrScanStatus: string; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| uomCode: string; | |||
| uomDesc: string; | |||
| match_status: string; | |||
| match_status: string | null; | |||
| } | |||
| const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| @@ -176,7 +176,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| const lotDetails = await fetchCompletedJobOrderPickOrderLotDetailsForCompletedPick(pickOrderId); | |||
| setDetailLotData(lotDetails); | |||
| setDetailLotData(Array.isArray(lotDetails) ? lotDetails : []); | |||
| console.log(" Fetched lot details:", lotDetails); | |||
| } catch (error) { | |||
| console.error("❌ Error fetching lot details:", error); | |||
| @@ -481,7 +481,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| </TableRow> | |||
| ) : ( | |||
| detailLotData.map((lot, index) => ( | |||
| <TableRow key={lot.lotId}> | |||
| <TableRow key={lot.stockOutLineId ?? lot.lotId ?? index}> | |||
| <TableCell> | |||
| <Typography variant="body2" fontWeight="bold"> | |||
| {index + 1} | |||
| @@ -494,13 +494,13 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| </TableCell> | |||
| <TableCell>{lot.itemCode}</TableCell> | |||
| <TableCell>{lot.itemName}</TableCell> | |||
| <TableCell>{lot.lotNo}</TableCell> | |||
| <TableCell>{lot.lotNo || '-'}</TableCell> | |||
| <TableCell align="right"> | |||
| {lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc}) | |||
| {lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc || ''}) | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {lot.actualPickQty?.toLocaleString() || 0} ({lot.uomShortDesc}) | |||
| {lot.actualPickQty?.toLocaleString() || 0} ({lot.uomShortDesc || ''}) | |||
| </TableCell> | |||
| {/* 修改:Processing Status 使用复选框 */} | |||
| <TableCell align="center"> | |||
| @@ -464,6 +464,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| // issue form 里填的 actualPickQty(用于 submit/batch submit 不补拣到 required) | |||
| const [issuePickedQtyBySolId, setIssuePickedQtyBySolId] = useState<Record<number, number>>({}); | |||
| // 防止同一行(以 stockOutLineId/solId 识别)被重复点击提交/完成 | |||
| const [actionBusyBySolId, setActionBusyBySolId] = useState<Record<number, boolean>>({}); | |||
| const [paginationController, setPaginationController] = useState({ | |||
| pageNum: 0, | |||
| @@ -553,6 +555,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||
| itemName: line.itemName, | |||
| uomCode: line.uomCode, | |||
| uomDesc: line.uomDesc, | |||
| itemTotalAvailableQty: line.totalAvailableQty ?? null, | |||
| pickOrderLineRequiredQty: line.requiredQty, | |||
| pickOrderLineStatus: line.status, | |||
| jobOrderId: data.pickOrder.jobOrder.id, | |||
| @@ -588,6 +591,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||
| itemName: line.itemName, | |||
| uomCode: line.uomCode, | |||
| uomDesc: line.uomDesc, | |||
| itemTotalAvailableQty: line.totalAvailableQty ?? null, | |||
| pickOrderLineRequiredQty: line.requiredQty, | |||
| pickOrderLineStatus: line.status, | |||
| jobOrderId: data.pickOrder.jobOrder.id, | |||
| @@ -2505,6 +2509,7 @@ const sortedData = [...sourceData].sort((a, b) => { | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell>{t("Lot No")}</TableCell> | |||
| <TableCell align="right">{t("Lot Required Pick Qty")}</TableCell> | |||
| <TableCell align="right">{t("Available Qty")}</TableCell> | |||
| <TableCell align="center">{t("Scan Result")}</TableCell> | |||
| <TableCell align="center">{t("Submit Required Pick Qty")}</TableCell> | |||
| </TableRow> | |||
| @@ -2512,7 +2517,7 @@ const sortedData = [...sourceData].sort((a, b) => { | |||
| <TableBody> | |||
| {paginatedData.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={8} align="center"> | |||
| <TableCell colSpan={9} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data available")} | |||
| </Typography> | |||
| @@ -2551,7 +2556,18 @@ const sortedData = [...sourceData].sort((a, b) => { | |||
| <TableCell align="right"> | |||
| {(() => { | |||
| const requiredQty = lot.requiredQty || 0; | |||
| return requiredQty.toLocaleString()+'('+lot.uomShortDesc+')'; | |||
| const unit = (lot.noLot === true || !lot.lotId) | |||
| ? (lot.uomDesc || "") | |||
| : ( lot.uomDesc || ""); | |||
| return `${requiredQty.toLocaleString()}(${unit})`; | |||
| })()} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {(() => { | |||
| const avail = lot.itemTotalAvailableQty; | |||
| if (avail == null) return "-"; | |||
| const unit = lot.uomDesc || ""; | |||
| return `${Number(avail).toLocaleString()}(${unit})`; | |||
| })()} | |||
| </TableCell> | |||
| @@ -2636,13 +2652,24 @@ const sortedData = [...sourceData].sort((a, b) => { | |||
| <Button | |||
| variant="contained" | |||
| onClick={async () => { | |||
| const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`; | |||
| const submitQty = lot.requiredQty || lot.pickOrderLineRequiredQty; | |||
| handlePickQtyChange(lotKey, submitQty); | |||
| await handleSubmitPickQtyWithQty(lot, submitQty); | |||
| const solId = Number(lot.stockOutLineId) || 0; | |||
| if (solId > 0) { | |||
| setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); | |||
| } | |||
| try { | |||
| const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`; | |||
| const submitQty = lot.requiredQty || lot.pickOrderLineRequiredQty; | |||
| handlePickQtyChange(lotKey, submitQty); | |||
| await handleSubmitPickQtyWithQty(lot, submitQty); | |||
| } finally { | |||
| if (solId > 0) { | |||
| setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | |||
| } | |||
| } | |||
| }} | |||
| disabled={ | |||
| (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) || | |||
| (lot.lotAvailability === 'expired' || | |||
| lot.lotAvailability === 'status_unavailable' || | |||
| lot.lotAvailability === 'rejected') || | |||
| @@ -2682,17 +2709,37 @@ const sortedData = [...sourceData].sort((a, b) => { | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={async () => { | |||
| // ✅ 更新 handler 后再提交 | |||
| if (currentUserId && lot.pickOrderId && lot.itemId) { | |||
| try { | |||
| await updateHandledBy(lot.pickOrderId, lot.itemId); | |||
| } catch (error) { | |||
| console.error("❌ Error updating handler (non-critical):", error); | |||
| const solId = Number(lot.stockOutLineId) || 0; | |||
| if (solId > 0) { | |||
| setActionBusyBySolId(prev => ({ ...prev, [solId]: true })); | |||
| } | |||
| try { | |||
| // ✅ 更新 handler 后再提交 | |||
| if (currentUserId && lot.pickOrderId && lot.itemId) { | |||
| try { | |||
| await updateHandledBy(lot.pickOrderId, lot.itemId); | |||
| } catch (error) { | |||
| console.error("❌ Error updating handler (non-critical):", error); | |||
| } | |||
| } | |||
| await handleSubmitPickQtyWithQty(lot, lot.requiredQty || lot.pickOrderLineRequiredQty || 0); | |||
| } finally { | |||
| if (solId > 0) { | |||
| setActionBusyBySolId(prev => ({ ...prev, [solId]: false })); | |||
| } | |||
| } | |||
| await handleSubmitPickQtyWithQty(lot, lot.requiredQty || lot.pickOrderLineRequiredQty || 0); | |||
| }} | |||
| disabled={lot.stockOutLineStatus === 'completed' || lot.noLot === true || !lot.lotId} | |||
| disabled={ | |||
| (Number(lot.stockOutLineId) > 0 && actionBusyBySolId[Number(lot.stockOutLineId)] === true) || | |||
| lot.stockOutLineStatus === 'completed' || | |||
| lot.noLot === true || | |||
| !lot.lotId || | |||
| (Number(lot.stockOutLineId) > 0 && | |||
| Object.prototype.hasOwnProperty.call( | |||
| issuePickedQtyBySolId, | |||
| Number(lot.stockOutLineId) | |||
| )) | |||
| } | |||
| sx={{ fontSize: '0.7rem', py: 0.5, minHeight: '28px', minWidth: '90px' }} | |||
| > | |||
| {t("Just Complete")} | |||
| @@ -139,6 +139,7 @@ | |||
| "Expiry Date": "有效期", | |||
| "Target Date": "需求日期", | |||
| "Lot Required Pick Qty": "批號需求數", | |||
| "Available Qty": "可用數量", | |||
| "Job Order Match": "工單對料", | |||
| "Lot No": "批號", | |||
| "Submit Required Pick Qty": "提交需求數", | |||