| @@ -587,6 +587,7 @@ export interface LotDetailResponse { | |||||
| pickOrderConsoCode: string | null; | pickOrderConsoCode: string | null; | ||||
| pickOrderLineId: number | null; | pickOrderLineId: number | null; | ||||
| stockOutLineId: number | null; | stockOutLineId: number | null; | ||||
| stockInLineId: number | null; | |||||
| suggestedPickLotId: number | null; | suggestedPickLotId: number | null; | ||||
| stockOutLineQty: number | null; | stockOutLineQty: number | null; | ||||
| stockOutLineStatus: string | null; | stockOutLineStatus: string | null; | ||||
| @@ -167,4 +167,60 @@ export async function submitMissItem(issueId: number, handler: number) { | |||||
| body: JSON.stringify({ lotLineIds, handler }), | body: JSON.stringify({ lotLineIds, handler }), | ||||
| }, | }, | ||||
| ); | ); | ||||
| } | |||||
| export interface LotIssueDetailResponse { | |||||
| lotId: number | null; | |||||
| lotNo: string | null; | |||||
| itemId: number; | |||||
| itemCode: string | null; | |||||
| itemDescription: string | null; | |||||
| storeLocation: string | null; | |||||
| issues: IssueDetailItem[]; | |||||
| } | |||||
| export interface IssueDetailItem { | |||||
| issueId: number; | |||||
| pickerName: string | null; | |||||
| missQty: number | null; | |||||
| issueQty: number | null; | |||||
| pickOrderCode: string; | |||||
| doOrderCode: string | null; | |||||
| joOrderCode: string | null; | |||||
| issueRemark: string | null; | |||||
| } | |||||
| export async function getLotIssueDetails( | |||||
| lotId: number, | |||||
| itemId: number, | |||||
| issueType: "miss" | "bad" | |||||
| ) { | |||||
| return serverFetchJson<LotIssueDetailResponse>( | |||||
| `${BASE_API_URL}/pickExecution/lotIssueDetails?lotId=${lotId}&itemId=${itemId}&issueType=${issueType}`, | |||||
| { | |||||
| method: "GET", | |||||
| headers: { | |||||
| "Content-Type": "application/json", | |||||
| }, | |||||
| } | |||||
| ); | |||||
| } | |||||
| export async function submitIssueWithQty( | |||||
| lotId: number, | |||||
| itemId: number, | |||||
| issueType: "miss" | "bad", | |||||
| submitQty: number, | |||||
| handler: number | |||||
| ){return serverFetchJson<MessageResponse>( | |||||
| `${BASE_API_URL}/pickExecution/submitIssueWithQty`, | |||||
| { | |||||
| method: "POST", | |||||
| headers: { | |||||
| "Content-Type": "application/json", | |||||
| }, | |||||
| body: JSON.stringify({ lotId, itemId, issueType, submitQty, handler }), | |||||
| } | |||||
| ); | |||||
| } | } | ||||
| @@ -19,7 +19,6 @@ import { | |||||
| TablePagination, | TablePagination, | ||||
| Modal, | Modal, | ||||
| Chip, | Chip, | ||||
| LinearProgress, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import dayjs from 'dayjs'; | import dayjs from 'dayjs'; | ||||
| import TestQrCodeProvider from '../QrCodeScannerProvider/TestQrCodeProvider'; | import TestQrCodeProvider from '../QrCodeScannerProvider/TestQrCodeProvider'; | ||||
| @@ -74,38 +73,13 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import { fetchStockInLineInfo } from "@/app/api/po/actions"; | import { fetchStockInLineInfo } from "@/app/api/po/actions"; | ||||
| import GoodPickExecutionForm from "./GoodPickExecutionForm"; | import GoodPickExecutionForm from "./GoodPickExecutionForm"; | ||||
| import FGPickOrderCard from "./FGPickOrderCard"; | import FGPickOrderCard from "./FGPickOrderCard"; | ||||
| import LinearProgressWithLabel from "../common/LinearProgressWithLabel"; | |||||
| import ScanStatusAlert from "../common/ScanStatusAlert"; | |||||
| interface Props { | interface Props { | ||||
| filterArgs: Record<string, any>; | filterArgs: Record<string, any>; | ||||
| onSwitchToRecordTab?: () => void; | onSwitchToRecordTab?: () => void; | ||||
| onRefreshReleasedOrderCount?: () => void; | onRefreshReleasedOrderCount?: () => void; | ||||
| } | } | ||||
| const LinearProgressWithLabel: React.FC<{ completed: number; total: number }> = ({ completed, total }) => { | |||||
| const { t } = useTranslation(["pickOrder", "do"]); | |||||
| const progress = total > 0 ? (completed / total) * 100 : 0; | |||||
| return ( | |||||
| <Box sx={{ width: '100%', mb: 2 }}> | |||||
| <Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}> | |||||
| <Box sx={{ width: '100%', mr: 1 }}> | |||||
| <LinearProgress | |||||
| variant="determinate" | |||||
| value={progress} | |||||
| sx={{ | |||||
| height: 30, // ✅ Increase height from default (4px) to 10px | |||||
| borderRadius: 5, // ✅ Add rounded corners | |||||
| }} | |||||
| /> | |||||
| </Box> | |||||
| <Box sx={{ minWidth: 80 }}> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| <strong>{t("Progress")}: {completed}/{total}</strong> | |||||
| </Typography> | |||||
| </Box> | |||||
| </Box> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| // QR Code Modal Component (from LotTable) | // QR Code Modal Component (from LotTable) | ||||
| const QrCodeModal: React.FC<{ | const QrCodeModal: React.FC<{ | ||||
| open: boolean; | open: boolean; | ||||
| @@ -542,6 +516,7 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||||
| const [qrScanInput, setQrScanInput] = useState<string>(''); | const [qrScanInput, setQrScanInput] = useState<string>(''); | ||||
| const [qrScanError, setQrScanError] = useState<boolean>(false); | const [qrScanError, setQrScanError] = useState<boolean>(false); | ||||
| const [qrScanErrorMsg, setQrScanErrorMsg] = useState<string>(''); | |||||
| const [qrScanSuccess, setQrScanSuccess] = useState<boolean>(false); | const [qrScanSuccess, setQrScanSuccess] = useState<boolean>(false); | ||||
| const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false); | const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false); | ||||
| const [pickQtyData, setPickQtyData] = useState<Record<string, number>>({}); | const [pickQtyData, setPickQtyData] = useState<Record<string, number>>({}); | ||||
| @@ -1550,15 +1525,86 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||||
| // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) | // ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed) | ||||
| const lookupStartTime = performance.now(); | const lookupStartTime = performance.now(); | ||||
| const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; | const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; | ||||
| // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected | |||||
| const allLotsForItem = indexes.byItemId.get(scannedItemId) || []; | |||||
| const lookupTime = performance.now() - lookupStartTime; | const lookupTime = performance.now() - lookupStartTime; | ||||
| console.log(`⏱️ [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots`); | |||||
| console.log(`⏱️ [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots, ${allLotsForItem.length} total lots`); | |||||
| // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots | |||||
| // This allows users to scan other lots even when all suggested lots are rejected | |||||
| const scannedLot = allLotsForItem.find( | |||||
| (lot: any) => lot.stockInLineId === scannedStockInLineId | |||||
| ); | |||||
| if (scannedLot) { | |||||
| const isRejected = | |||||
| scannedLot.stockOutLineStatus?.toLowerCase() === 'rejected' || | |||||
| scannedLot.lotAvailability === 'rejected' || | |||||
| scannedLot.lotAvailability === 'status_unavailable'; | |||||
| if (isRejected) { | |||||
| console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is rejected or unavailable`); | |||||
| startTransition(() => { | |||||
| setQrScanError(true); | |||||
| setQrScanSuccess(false); | |||||
| setQrScanErrorMsg( | |||||
| `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` | |||||
| ); | |||||
| }); | |||||
| // Mark as processed to prevent re-processing | |||||
| setProcessedQrCombinations(prev => { | |||||
| const newMap = new Map(prev); | |||||
| if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); | |||||
| newMap.get(scannedItemId)!.add(scannedStockInLineId); | |||||
| return newMap; | |||||
| }); | |||||
| return; | |||||
| } | |||||
| } | |||||
| // ✅ If no active suggested lots, but scanned lot is not rejected, allow lot switching | |||||
| if (activeSuggestedLots.length === 0) { | if (activeSuggestedLots.length === 0) { | ||||
| console.error("No active suggested lots found for this item"); | |||||
| startTransition(() => { | |||||
| setQrScanError(true); | |||||
| setQrScanSuccess(false); | |||||
| }); | |||||
| // Check if there are any lots for this item (even if all are rejected) | |||||
| if (allLotsForItem.length === 0) { | |||||
| console.error("No lots found for this item"); | |||||
| startTransition(() => { | |||||
| setQrScanError(true); | |||||
| setQrScanSuccess(false); | |||||
| setQrScanErrorMsg("当前订单中没有此物品的批次信息"); | |||||
| }); | |||||
| return; | |||||
| } | |||||
| // ✅ Allow lot switching: find a rejected lot as expected lot, or use first lot | |||||
| // This allows users to switch to a new lot even when all suggested lots are rejected | |||||
| console.log(`⚠️ [QR PROCESS] No active suggested lots, but allowing lot switching.`); | |||||
| // Find a rejected lot as expected lot (the one that was rejected) | |||||
| const rejectedLot = allLotsForItem.find((lot: any) => | |||||
| lot.stockOutLineStatus?.toLowerCase() === 'rejected' || | |||||
| lot.lotAvailability === 'rejected' || | |||||
| lot.lotAvailability === 'status_unavailable' | |||||
| ); | |||||
| const expectedLot = rejectedLot || allLotsForItem[0]; // Use rejected lot if exists, otherwise first lot | |||||
| // ✅ Always open confirmation modal when no active lots (user needs to confirm switching) | |||||
| // handleLotMismatch will fetch lotNo from backend using stockInLineId if needed | |||||
| console.log(`⚠️ [QR PROCESS] Opening confirmation modal for lot switch (no active lots)`); | |||||
| setSelectedLotForQr(expectedLot); | |||||
| handleLotMismatch( | |||||
| { | |||||
| lotNo: expectedLot.lotNo, | |||||
| itemCode: expectedLot.itemCode, | |||||
| itemName: expectedLot.itemName | |||||
| }, | |||||
| { | |||||
| lotNo: scannedLot?.lotNo || null, // Will be fetched by handleLotMismatch if null | |||||
| itemCode: expectedLot.itemCode, | |||||
| itemName: expectedLot.itemName, | |||||
| inventoryLotLineId: scannedLot?.lotId || null, | |||||
| stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | |||||
| } | |||||
| ); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -1577,6 +1623,37 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||||
| const matchTime = performance.now() - matchStartTime; | const matchTime = performance.now() - matchStartTime; | ||||
| console.log(`⏱️ [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${exactMatch ? 'yes' : 'no'}`); | console.log(`⏱️ [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${exactMatch ? 'yes' : 'no'}`); | ||||
| // ✅ Check if scanned lot exists in allLotsForItem but not in activeSuggestedLots | |||||
| // This handles the case where Lot A is rejected and user scans Lot B | |||||
| // Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined) | |||||
| if (!exactMatch) { | |||||
| // Scanned lot is not in active suggested lots, open confirmation modal | |||||
| const expectedLot = activeSuggestedLots[0] || allLotsForItem[0]; // Use first active lot or first lot as expected | |||||
| if (expectedLot) { | |||||
| // Check if scanned lot is different from expected, or if scannedLot is undefined (not in allLotsForItem) | |||||
| const shouldOpenModal = !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId); | |||||
| if (shouldOpenModal) { | |||||
| console.log(`⚠️ [QR PROCESS] Opening confirmation modal (scanned lot ${scannedLot?.lotNo || 'not in data'} is not in active suggested lots)`); | |||||
| setSelectedLotForQr(expectedLot); | |||||
| handleLotMismatch( | |||||
| { | |||||
| lotNo: expectedLot.lotNo, | |||||
| itemCode: expectedLot.itemCode, | |||||
| itemName: expectedLot.itemName | |||||
| }, | |||||
| { | |||||
| lotNo: scannedLot?.lotNo || null, // Will be fetched by handleLotMismatch if null | |||||
| itemCode: expectedLot.itemCode, | |||||
| itemName: expectedLot.itemName, | |||||
| inventoryLotLineId: scannedLot?.lotId || null, | |||||
| stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | |||||
| } | |||||
| ); | |||||
| return; | |||||
| } | |||||
| } | |||||
| } | |||||
| if (exactMatch) { | if (exactMatch) { | ||||
| // ✅ Case 1: stockInLineId 匹配 - 直接处理,不需要确认 | // ✅ Case 1: stockInLineId 匹配 - 直接处理,不需要确认 | ||||
| console.log(`✅ Exact stockInLineId match found for lot: ${exactMatch.lotNo}`); | console.log(`✅ Exact stockInLineId match found for lot: ${exactMatch.lotNo}`); | ||||
| @@ -1748,7 +1825,7 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); | |||||
| }); | }); | ||||
| return; | return; | ||||
| } | } | ||||
| }, [lotDataIndexes, handleLotMismatch, processedQrCombinations]); | |||||
| }, [lotDataIndexes, handleLotMismatch, processedQrCombinations, combinedLotData, fetchStockInLineInfoCached]); | |||||
| // Store processOutsideQrCode in ref for immediate access (update on every render) | // Store processOutsideQrCode in ref for immediate access (update on every render) | ||||
| processOutsideQrCodeRef.current = processOutsideQrCode; | processOutsideQrCodeRef.current = processOutsideQrCode; | ||||
| @@ -2797,23 +2874,33 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| <FormProvider {...formProps}> | <FormProvider {...formProps}> | ||||
| <Stack spacing={2}> | <Stack spacing={2}> | ||||
| <Box | <Box | ||||
| sx={{ | |||||
| position: 'fixed', | |||||
| top: 0, | |||||
| left: 0, | |||||
| right: 0, | |||||
| zIndex: 1100, // Higher than other elements | |||||
| backgroundColor: 'background.paper', | |||||
| pt: 2, | |||||
| pb: 1, | |||||
| px: 2, | |||||
| borderBottom: '1px solid', | |||||
| borderColor: 'divider', | |||||
| boxShadow: '0 2px 4px rgba(0,0,0,0.1)', | |||||
| }} | |||||
| > | |||||
| <LinearProgressWithLabel completed={progress.completed} total={progress.total} /> | |||||
| </Box> | |||||
| sx={{ | |||||
| position: 'fixed', | |||||
| top: 0, | |||||
| left: 0, | |||||
| right: 0, | |||||
| zIndex: 1100, // Higher than other elements | |||||
| backgroundColor: 'background.paper', | |||||
| pt: 2, | |||||
| pb: 1, | |||||
| px: 2, | |||||
| borderBottom: '1px solid', | |||||
| borderColor: 'divider', | |||||
| boxShadow: '0 2px 4px rgba(0,0,0,0.1)', | |||||
| }} | |||||
| > | |||||
| <LinearProgressWithLabel | |||||
| completed={progress.completed} | |||||
| total={progress.total} | |||||
| label={t("Progress")} | |||||
| /> | |||||
| <ScanStatusAlert | |||||
| error={qrScanError} | |||||
| success={qrScanSuccess} | |||||
| errorMessage={t("QR code does not match any item in current orders.")} | |||||
| successMessage={t("QR code verified.")} | |||||
| /> | |||||
| </Box> | |||||
| {/* DO Header */} | {/* DO Header */} | ||||
| @@ -2821,7 +2908,7 @@ const handleSubmitAllScanned = useCallback(async () => { | |||||
| {/* 保留:Combined Lot Table - 包含所有 QR 扫描功能 */} | {/* 保留:Combined Lot Table - 包含所有 QR 扫描功能 */} | ||||
| <Box> | <Box> | ||||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> | |||||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, mt: 10 }}> | |||||
| <Typography variant="h6" gutterBottom sx={{ mb: 0 }}> | <Typography variant="h6" gutterBottom sx={{ mb: 0 }}> | ||||
| {t("All Pick Order Lots")} | {t("All Pick Order Lots")} | ||||
| </Typography> | </Typography> | ||||
| @@ -339,11 +339,11 @@ const FInishedJobOrderRecord: React.FC<Props> = ({ filterArgs }) => { | |||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell>{t("Index")}</TableCell> | <TableCell>{t("Index")}</TableCell> | ||||
| <TableCell>{t("Route")}</TableCell> | |||||
| <TableCell>{t("Location")}</TableCell> | |||||
| <TableCell>{t("Item Code")}</TableCell> | <TableCell>{t("Item Code")}</TableCell> | ||||
| <TableCell>{t("Item Name")}</TableCell> | <TableCell>{t("Item Name")}</TableCell> | ||||
| <TableCell>{t("Lot No")}</TableCell> | <TableCell>{t("Lot No")}</TableCell> | ||||
| <TableCell>{t("Location")}</TableCell> | |||||
| <TableCell align="right">{t("Required Qty")}</TableCell> | <TableCell align="right">{t("Required Qty")}</TableCell> | ||||
| <TableCell align="right">{t("Actual Pick Qty")}</TableCell> | <TableCell align="right">{t("Actual Pick Qty")}</TableCell> | ||||
| <TableCell align="center">{t("Processing Status")}</TableCell> | <TableCell align="center">{t("Processing Status")}</TableCell> | ||||
| @@ -375,7 +375,7 @@ const FInishedJobOrderRecord: React.FC<Props> = ({ filterArgs }) => { | |||||
| <TableCell>{lot.itemCode}</TableCell> | <TableCell>{lot.itemCode}</TableCell> | ||||
| <TableCell>{lot.itemName}</TableCell> | <TableCell>{lot.itemName}</TableCell> | ||||
| <TableCell>{lot.lotNo}</TableCell> | <TableCell>{lot.lotNo}</TableCell> | ||||
| <TableCell>{lot.location}</TableCell> | |||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| {lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc}) | {lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc}) | ||||
| </TableCell> | </TableCell> | ||||
| @@ -463,7 +463,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| <TableCell>{t("Item Code")}</TableCell> | <TableCell>{t("Item Code")}</TableCell> | ||||
| <TableCell>{t("Item Name")}</TableCell> | <TableCell>{t("Item Name")}</TableCell> | ||||
| <TableCell>{t("Lot No")}</TableCell> | <TableCell>{t("Lot No")}</TableCell> | ||||
| <TableCell>{t("Location")}</TableCell> | |||||
| <TableCell align="right">{t("Required Qty")}</TableCell> | <TableCell align="right">{t("Required Qty")}</TableCell> | ||||
| <TableCell align="right">{t("Actual Pick Qty")}</TableCell> | <TableCell align="right">{t("Actual Pick Qty")}</TableCell> | ||||
| <TableCell align="center">{t("Processing Status")}</TableCell> | <TableCell align="center">{t("Processing Status")}</TableCell> | ||||
| @@ -495,7 +495,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| <TableCell>{lot.itemCode}</TableCell> | <TableCell>{lot.itemCode}</TableCell> | ||||
| <TableCell>{lot.itemName}</TableCell> | <TableCell>{lot.itemName}</TableCell> | ||||
| <TableCell>{lot.lotNo}</TableCell> | <TableCell>{lot.lotNo}</TableCell> | ||||
| <TableCell>{lot.location}</TableCell> | |||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| {lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc}) | {lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc}) | ||||
| </TableCell> | </TableCell> | ||||
| @@ -63,6 +63,8 @@ import { fetchStockInLineInfo } from "@/app/api/po/actions"; | |||||
| import GoodPickExecutionForm from "./JobPickExecutionForm"; | import GoodPickExecutionForm from "./JobPickExecutionForm"; | ||||
| import FGPickOrderCard from "./FGPickOrderCard"; | import FGPickOrderCard from "./FGPickOrderCard"; | ||||
| import LotConfirmationModal from "./LotConfirmationModal"; | import LotConfirmationModal from "./LotConfirmationModal"; | ||||
| import LinearProgressWithLabel from "../common/LinearProgressWithLabel"; | |||||
| import ScanStatusAlert from "../common/ScanStatusAlert"; | |||||
| interface Props { | interface Props { | ||||
| filterArgs: Record<string, any>; | filterArgs: Record<string, any>; | ||||
| //onSwitchToRecordTab: () => void; | //onSwitchToRecordTab: () => void; | ||||
| @@ -1113,11 +1115,84 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| const indexes = lotDataIndexes; | const indexes = lotDataIndexes; | ||||
| const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; | const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || []; | ||||
| // ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected | |||||
| const allLotsForItem = indexes.byItemId.get(scannedItemId) || []; | |||||
| // ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots | |||||
| // This allows users to scan other lots even when all suggested lots are rejected | |||||
| const scannedLot = allLotsForItem.find( | |||||
| (lot: any) => lot.stockInLineId === scannedStockInLineId | |||||
| ); | |||||
| if (scannedLot) { | |||||
| const isRejected = | |||||
| scannedLot.stockOutLineStatus?.toLowerCase() === 'rejected' || | |||||
| scannedLot.lotAvailability === 'rejected' || | |||||
| scannedLot.lotAvailability === 'status_unavailable'; | |||||
| if (isRejected) { | |||||
| console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is rejected or unavailable`); | |||||
| startTransition(() => { | |||||
| setQrScanError(true); | |||||
| setQrScanSuccess(false); | |||||
| setQrScanErrorMsg( | |||||
| `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` | |||||
| ); | |||||
| }); | |||||
| // Mark as processed to prevent re-processing | |||||
| setProcessedQrCombinations(prev => { | |||||
| const newMap = new Map(prev); | |||||
| if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); | |||||
| newMap.get(scannedItemId)!.add(scannedStockInLineId); | |||||
| return newMap; | |||||
| }); | |||||
| return; | |||||
| } | |||||
| } | |||||
| // ✅ If no active suggested lots, but scanned lot is not rejected, allow lot switching | |||||
| if (activeSuggestedLots.length === 0) { | if (activeSuggestedLots.length === 0) { | ||||
| startTransition(() => { | |||||
| setQrScanError(true); | |||||
| setQrScanSuccess(false); | |||||
| }); | |||||
| // Check if there are any lots for this item (even if all are rejected) | |||||
| if (allLotsForItem.length === 0) { | |||||
| console.error("No lots found for this item"); | |||||
| startTransition(() => { | |||||
| setQrScanError(true); | |||||
| setQrScanSuccess(false); | |||||
| setQrScanErrorMsg("当前订单中没有此物品的批次信息"); | |||||
| }); | |||||
| return; | |||||
| } | |||||
| // ✅ Allow lot switching: find a rejected lot as expected lot, or use first lot | |||||
| // This allows users to switch to a new lot even when all suggested lots are rejected | |||||
| console.log(`⚠️ [QR PROCESS] No active suggested lots, but allowing lot switching. Scanned lot is not rejected.`); | |||||
| // Find a rejected lot as expected lot (the one that was rejected) | |||||
| const rejectedLot = allLotsForItem.find((lot: any) => | |||||
| lot.stockOutLineStatus?.toLowerCase() === 'rejected' || | |||||
| lot.lotAvailability === 'rejected' || | |||||
| lot.lotAvailability === 'status_unavailable' | |||||
| ); | |||||
| const expectedLot = rejectedLot || allLotsForItem[0]; // Use rejected lot if exists, otherwise first lot | |||||
| // ✅ Always open confirmation modal when no active lots (user needs to confirm switching) | |||||
| // handleLotMismatch will fetch lotNo from backend using stockInLineId if needed | |||||
| console.log(`⚠️ [QR PROCESS] Opening confirmation modal for lot switch (no active lots)`); | |||||
| setSelectedLotForQr(expectedLot); | |||||
| handleLotMismatch( | |||||
| { | |||||
| lotNo: expectedLot.lotNo, | |||||
| itemCode: expectedLot.itemCode, | |||||
| itemName: expectedLot.itemName | |||||
| }, | |||||
| { | |||||
| lotNo: scannedLot?.lotNo || null, // Will be fetched by handleLotMismatch if null | |||||
| itemCode: expectedLot.itemCode, | |||||
| itemName: expectedLot.itemName, | |||||
| inventoryLotLineId: scannedLot?.lotId || null, | |||||
| stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo | |||||
| } | |||||
| ); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -1136,6 +1211,32 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| console.log(`🔍 [QR PROCESS] Found ${stockInLineLots.length} lots with stockInLineId ${scannedStockInLineId}`); | console.log(`🔍 [QR PROCESS] Found ${stockInLineLots.length} lots with stockInLineId ${scannedStockInLineId}`); | ||||
| console.log(`🔍 [QR PROCESS] Exact match found: ${exactMatch ? `YES (lotNo: ${exactMatch.lotNo}, stockOutLineId: ${exactMatch.stockOutLineId})` : 'NO'}`); | console.log(`🔍 [QR PROCESS] Exact match found: ${exactMatch ? `YES (lotNo: ${exactMatch.lotNo}, stockOutLineId: ${exactMatch.stockOutLineId})` : 'NO'}`); | ||||
| // ✅ Check if scanned lot exists in allLotsForItem but not in activeSuggestedLots | |||||
| // This handles the case where Lot A is rejected and user scans Lot B | |||||
| if (!exactMatch && scannedLot && !activeSuggestedLots.includes(scannedLot)) { | |||||
| // Scanned lot is not in active suggested lots, open confirmation modal | |||||
| const expectedLot = activeSuggestedLots[0] || allLotsForItem[0]; // Use first active lot or first lot as expected | |||||
| if (expectedLot && scannedLot.stockInLineId !== expectedLot.stockInLineId) { | |||||
| console.log(`⚠️ [QR PROCESS] Scanned lot ${scannedLot.lotNo} is not in active suggested lots, opening confirmation modal`); | |||||
| setSelectedLotForQr(expectedLot); | |||||
| handleLotMismatch( | |||||
| { | |||||
| lotNo: expectedLot.lotNo, | |||||
| itemCode: expectedLot.itemCode, | |||||
| itemName: expectedLot.itemName | |||||
| }, | |||||
| { | |||||
| lotNo: scannedLot.lotNo || null, | |||||
| itemCode: expectedLot.itemCode, | |||||
| itemName: expectedLot.itemName, | |||||
| inventoryLotLineId: scannedLot.lotId || null, | |||||
| stockInLineId: scannedStockInLineId | |||||
| } | |||||
| ); | |||||
| return; | |||||
| } | |||||
| } | |||||
| if (exactMatch) { | if (exactMatch) { | ||||
| if (!exactMatch.stockOutLineId) { | if (!exactMatch.stockOutLineId) { | ||||
| console.error(`❌ [QR PROCESS] Exact match found but no stockOutLineId`); | console.error(`❌ [QR PROCESS] Exact match found but no stockOutLineId`); | ||||
| @@ -1216,7 +1317,38 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| const stockInLineInfo = await fetchStockInLineInfoCached(scannedStockInLineId); | const stockInLineInfo = await fetchStockInLineInfoCached(scannedStockInLineId); | ||||
| console.log(`✅ [QR PROCESS] Scanned stockInLineId ${scannedStockInLineId} exists, lotNo: ${stockInLineInfo.lotNo}`); | console.log(`✅ [QR PROCESS] Scanned stockInLineId ${scannedStockInLineId} exists, lotNo: ${stockInLineInfo.lotNo}`); | ||||
| // ✅ stockInLineId exists, open confirmation modal | |||||
| // ✅ 检查扫描的批次是否已被拒绝 | |||||
| const scannedLot = combinedLotData.find( | |||||
| (lot: any) => lot.stockInLineId === scannedStockInLineId && lot.itemId === scannedItemId | |||||
| ); | |||||
| if (scannedLot) { | |||||
| const isRejected = | |||||
| scannedLot.stockOutLineStatus?.toLowerCase() === 'rejected' || | |||||
| scannedLot.lotAvailability === 'rejected' || | |||||
| scannedLot.lotAvailability === 'status_unavailable'; | |||||
| if (isRejected) { | |||||
| console.warn(`⚠️ [QR PROCESS] Scanned lot ${stockInLineInfo.lotNo} (stockInLineId: ${scannedStockInLineId}) is rejected or unavailable`); | |||||
| startTransition(() => { | |||||
| setQrScanError(true); | |||||
| setQrScanSuccess(false); | |||||
| setQrScanErrorMsg( | |||||
| `此批次(${stockInLineInfo.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` | |||||
| ); | |||||
| }); | |||||
| // Mark as processed to prevent re-processing | |||||
| setProcessedQrCombinations(prev => { | |||||
| const newMap = new Map(prev); | |||||
| if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set()); | |||||
| newMap.get(scannedItemId)!.add(scannedStockInLineId); | |||||
| return newMap; | |||||
| }); | |||||
| return; | |||||
| } | |||||
| } | |||||
| // ✅ stockInLineId exists and is not rejected, open confirmation modal | |||||
| console.log(`⚠️ [QR PROCESS] Opening confirmation modal - user must confirm before any lot is marked as scanned`); | console.log(`⚠️ [QR PROCESS] Opening confirmation modal - user must confirm before any lot is marked as scanned`); | ||||
| setSelectedLotForQr(expectedLot); | setSelectedLotForQr(expectedLot); | ||||
| handleLotMismatch( | handleLotMismatch( | ||||
| @@ -1251,7 +1383,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| return newMap; | return newMap; | ||||
| }); | }); | ||||
| } | } | ||||
| }, [filterArgs?.pickOrderId, fetchJobOrderData, handleLotMismatch, lotDataIndexes, processedQrCombinations]); | |||||
| }, [filterArgs?.pickOrderId, fetchJobOrderData, handleLotMismatch, lotDataIndexes, processedQrCombinations, combinedLotData, fetchStockInLineInfoCached]); | |||||
| // Store in refs for immediate access in qrValues effect | // Store in refs for immediate access in qrValues effect | ||||
| processOutsideQrCodeRef.current = processOutsideQrCode; | processOutsideQrCodeRef.current = processOutsideQrCode; | ||||
| @@ -1730,6 +1862,23 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| const scannedItemsCount = useMemo(() => { | const scannedItemsCount = useMemo(() => { | ||||
| return combinedLotData.filter(lot => lot.stockOutLineStatus === 'checked').length; | return combinedLotData.filter(lot => lot.stockOutLineStatus === 'checked').length; | ||||
| }, [combinedLotData]); | }, [combinedLotData]); | ||||
| // Progress bar data (align with Finished Good execution detail) | |||||
| const progress = useMemo(() => { | |||||
| if (combinedLotData.length === 0) { | |||||
| return { completed: 0, total: 0 }; | |||||
| } | |||||
| const nonPendingCount = combinedLotData.filter((lot) => { | |||||
| const status = lot.stockOutLineStatus?.toLowerCase(); | |||||
| return status !== 'pending'; | |||||
| }).length; | |||||
| return { | |||||
| completed: nonPendingCount, | |||||
| total: combinedLotData.length, | |||||
| }; | |||||
| }, [combinedLotData]); | |||||
| // Handle reject lot | // Handle reject lot | ||||
| const handleRejectLot = useCallback(async (lot: any) => { | const handleRejectLot = useCallback(async (lot: any) => { | ||||
| if (!lot.stockOutLineId) { | if (!lot.stockOutLineId) { | ||||
| @@ -1944,16 +2093,46 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| return ( | return ( | ||||
| <TestQrCodeProvider | <TestQrCodeProvider | ||||
| lotData={combinedLotData} | |||||
| onScanLot={handleQrCodeSubmit} | |||||
| filterActive={(lot) => ( | |||||
| lot.lotAvailability !== 'rejected' && | |||||
| lot.stockOutLineStatus !== 'rejected' && | |||||
| lot.stockOutLineStatus !== 'completed' | |||||
| )} | |||||
| > | |||||
| <FormProvider {...formProps}> | |||||
| <Stack spacing={2}> | |||||
| lotData={combinedLotData} | |||||
| onScanLot={handleQrCodeSubmit} | |||||
| filterActive={(lot) => ( | |||||
| lot.lotAvailability !== 'rejected' && | |||||
| lot.stockOutLineStatus !== 'rejected' && | |||||
| lot.stockOutLineStatus !== 'completed' | |||||
| )} | |||||
| > | |||||
| <FormProvider {...formProps}> | |||||
| <Stack spacing={2}> | |||||
| {/* Progress bar + scan status fixed at top */} | |||||
| <Box | |||||
| sx={{ | |||||
| position: 'fixed', | |||||
| top: 0, | |||||
| left: 0, | |||||
| right: 0, | |||||
| zIndex: 1100, | |||||
| backgroundColor: 'background.paper', | |||||
| pt: 2, | |||||
| pb: 1, | |||||
| px: 2, | |||||
| borderBottom: '1px solid', | |||||
| borderColor: 'divider', | |||||
| boxShadow: '0 2px 4px rgba(0,0,0,0.1)', | |||||
| }} | |||||
| > | |||||
| <LinearProgressWithLabel | |||||
| completed={progress.completed} | |||||
| total={progress.total} | |||||
| label={t("Progress")} | |||||
| /> | |||||
| <ScanStatusAlert | |||||
| error={qrScanError} | |||||
| success={qrScanSuccess} | |||||
| errorMessage={qrScanErrorMsg || t("QR code does not match any item in current orders.")} | |||||
| successMessage={t("QR code verified.")} | |||||
| /> | |||||
| </Box> | |||||
| {/* Job Order Header */} | {/* Job Order Header */} | ||||
| {jobOrderData && ( | {jobOrderData && ( | ||||
| <Paper sx={{ p: 2 }}> | <Paper sx={{ p: 2 }}> | ||||
| @@ -1974,7 +2153,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| {/* Combined Lot Table */} | {/* Combined Lot Table */} | ||||
| <Box> | |||||
| <Box sx={{ mt: 10 }}> | |||||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> | <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> | ||||
| @@ -2020,60 +2199,6 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| </Box> | </Box> | ||||
| </Box> | </Box> | ||||
| {qrScanError && !qrScanSuccess && ( | |||||
| <Alert | |||||
| severity="error" | |||||
| sx={{ | |||||
| mb: 2, | |||||
| display: "flex", | |||||
| justifyContent: "center", | |||||
| alignItems: "center", | |||||
| fontWeight: "bold", | |||||
| fontSize: "1rem", | |||||
| color: "error.main", // ✅ 整个 Alert 文字用错误红 | |||||
| "& .MuiAlert-message": { | |||||
| width: "100%", | |||||
| textAlign: "center", | |||||
| // color: "error.main", // ✅ 明确指定 message 文字颜色 | |||||
| }, | |||||
| "& .MuiSvgIcon-root": { | |||||
| color: "error.main", // 图标继续红色(可选) | |||||
| }, | |||||
| backgroundColor: "error.light", | |||||
| }} | |||||
| > | |||||
| {qrScanErrorMsg || t("QR code does not match any item in current orders.")} | |||||
| </Alert> | |||||
| )} | |||||
| {qrScanSuccess && ( | |||||
| <Alert | |||||
| severity="success" | |||||
| sx={{ | |||||
| mb: 2, | |||||
| display: "flex", | |||||
| justifyContent: "center", | |||||
| alignItems: "center", | |||||
| fontWeight: "bold", | |||||
| fontSize: "1rem", | |||||
| // 背景用很浅的绿色 | |||||
| bgcolor: "rgba(76, 175, 80, 0.08)", | |||||
| // 文字用主题 success 绿 | |||||
| color: "success.main", | |||||
| // 去掉默认强烈的色块感 | |||||
| "& .MuiAlert-icon": { | |||||
| color: "success.main", | |||||
| }, | |||||
| "& .MuiAlert-message": { | |||||
| width: "100%", | |||||
| textAlign: "center", | |||||
| color: "success.main", | |||||
| }, | |||||
| }} | |||||
| > | |||||
| {t("QR code verified.")} | |||||
| </Alert> | |||||
| )} | |||||
| <TableContainer component={Paper}> | <TableContainer component={Paper}> | ||||
| <Table> | <Table> | ||||
| @@ -38,7 +38,7 @@ interface BagConsumptionFormProps { | |||||
| jobOrderId: number; | jobOrderId: number; | ||||
| lineId: number; | lineId: number; | ||||
| bomDescription?: string; | bomDescription?: string; | ||||
| isLastLine: boolean; | |||||
| processName?: string; | |||||
| submitedBagRecord?: boolean; | submitedBagRecord?: boolean; | ||||
| onRefresh?: () => void; | onRefresh?: () => void; | ||||
| } | } | ||||
| @@ -47,7 +47,7 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({ | |||||
| jobOrderId, | jobOrderId, | ||||
| lineId, | lineId, | ||||
| bomDescription, | bomDescription, | ||||
| isLastLine, | |||||
| processName, | |||||
| submitedBagRecord, | submitedBagRecord, | ||||
| onRefresh, | onRefresh, | ||||
| }) => { | }) => { | ||||
| @@ -65,8 +65,8 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({ | |||||
| if (submitedBagRecord === true) { | if (submitedBagRecord === true) { | ||||
| return false; | return false; | ||||
| } | } | ||||
| return bomDescription === "FG" && isLastLine; | |||||
| }, [bomDescription, isLastLine, submitedBagRecord]); | |||||
| return processName === "包裝"; | |||||
| }, [processName, submitedBagRecord]); | |||||
| // 加载 Bag 列表 | // 加载 Bag 列表 | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -102,20 +102,10 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| const [pauseReason, setPauseReason] = useState(""); | const [pauseReason, setPauseReason] = useState(""); | ||||
| // ✅ 添加:判断是否显示 Bag 表单的条件 | // ✅ 添加:判断是否显示 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]); | |||||
| const isPackagingProcess = useMemo(() => { | |||||
| if (!lineDetail) return false; | |||||
| return lineDetail.name === "包裝"; | |||||
| }, [lineDetail]) | |||||
| // ✅ 添加:刷新 line detail 的函数 | // ✅ 添加:刷新 line detail 的函数 | ||||
| const handleRefreshLineDetail = useCallback(async () => { | const handleRefreshLineDetail = useCallback(async () => { | ||||
| @@ -981,12 +971,12 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| )} | )} | ||||
| {/* ========== Bag Consumption Form ========== */} | {/* ========== Bag Consumption Form ========== */} | ||||
| {((showOutputTable || isCompleted) && shouldShowBagForm && jobOrderId && lineId) && ( | |||||
| {((showOutputTable || isCompleted) && isPackagingProcess && jobOrderId && lineId) && ( | |||||
| <BagConsumptionForm | <BagConsumptionForm | ||||
| jobOrderId={jobOrderId} | jobOrderId={jobOrderId} | ||||
| lineId={lineId} | lineId={lineId} | ||||
| bomDescription={processData?.bomDescription} | bomDescription={processData?.bomDescription} | ||||
| isLastLine={shouldShowBagForm} | |||||
| processName={lineDetail?.name} | |||||
| submitedBagRecord={lineDetail?.submitedBagRecord} | submitedBagRecord={lineDetail?.submitedBagRecord} | ||||
| onRefresh={handleRefreshLineDetail} | onRefresh={handleRefreshLineDetail} | ||||
| /> | /> | ||||
| @@ -18,6 +18,7 @@ import { | |||||
| } from "@/app/api/stockIssue/actions"; | } from "@/app/api/stockIssue/actions"; | ||||
| import { Box, Button, Tab, Tabs } from "@mui/material"; | import { Box, Button, Tab, Tabs } from "@mui/material"; | ||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import SubmitIssueForm from "./SubmitIssueForm"; | |||||
| interface Props { | interface Props { | ||||
| dataList: StockIssueLists; | dataList: StockIssueLists; | ||||
| @@ -34,6 +35,11 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| const [search, setSearch] = useState<SearchQuery>({ lotNo: "" }); | const [search, setSearch] = useState<SearchQuery>({ lotNo: "" }); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const [formOpen, setFormOpen] = useState(false); | |||||
| const [selectedLotId, setSelectedLotId] = useState<number | null>(null); | |||||
| const [selectedItemId, setSelectedItemId] = useState<number>(0); | |||||
| const [selectedIssueType, setSelectedIssueType] = useState<"miss" | "bad">("miss"); | |||||
| const [missItems, setMissItems] = useState<StockIssueResult[]>( | const [missItems, setMissItems] = useState<StockIssueResult[]>( | ||||
| dataList.missItems, | dataList.missItems, | ||||
| ); | ); | ||||
| @@ -76,34 +82,47 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| return; | return; | ||||
| } | } | ||||
| setSubmittingIds((prev) => new Set(prev).add(id)); | |||||
| try { | |||||
| if (tab === "miss") { | |||||
| await submitMissItem(id, currentUserId); | |||||
| setMissItems((prev) => prev.filter((i) => i.id !== id)); | |||||
| } else if (tab === "bad") { | |||||
| await submitBadItem(id, currentUserId); | |||||
| setBadItems((prev) => prev.filter((i) => i.id !== id)); | |||||
| } else { | |||||
| await submitExpiryItem(id, currentUserId); | |||||
| setExpiryItems((prev) => prev.filter((i) => i.id !== id)); | |||||
| // Find the item to get lotId | |||||
| let lotId: number | null = null; | |||||
| let itemId = 0; | |||||
| if (tab === "miss") { | |||||
| const item = missItems.find((i) => i.id === id); | |||||
| if (item) { | |||||
| lotId = item.lotId; | |||||
| itemId = item.itemId; | |||||
| } | |||||
| } else if (tab === "bad") { | |||||
| const item = badItems.find((i) => i.id === id); | |||||
| if (item) { | |||||
| lotId = item.lotId; | |||||
| itemId = item.itemId; | |||||
| } | } | ||||
| // Remove from selectedIds if it was selected | |||||
| setSelectedIds((prev) => prev.filter((selectedId) => selectedId !== id)); | |||||
| } catch (error) { | |||||
| console.error("Failed to submit item:", error); | |||||
| alert(`Failed to submit: ${error instanceof Error ? error.message : "Unknown error"}`); | |||||
| } finally { | |||||
| setSubmittingIds((prev) => { | |||||
| const newSet = new Set(prev); | |||||
| newSet.delete(id); | |||||
| return newSet; | |||||
| }); | |||||
| } | |||||
| if (lotId && itemId) { | |||||
| setSelectedLotId(lotId); | |||||
| setSelectedItemId(itemId); | |||||
| setSelectedIssueType(tab === "miss" ? "miss" : "bad"); | |||||
| setFormOpen(true); | |||||
| } else { | |||||
| alert(t("Item not found")); | |||||
| } | } | ||||
| }, | }, | ||||
| [tab, currentUserId, t], | |||||
| [tab, currentUserId, t, missItems, badItems] | |||||
| ); | ); | ||||
| const handleFormSuccess = useCallback(() => { | |||||
| // Refresh the lists | |||||
| if (tab === "miss") { | |||||
| // Reload miss items - you may need to add a refresh function | |||||
| window.location.reload(); // Or use a proper refresh mechanism | |||||
| } else if (tab === "bad") { | |||||
| // Reload bad items | |||||
| window.location.reload(); // Or use a proper refresh mechanism | |||||
| } | |||||
| }, [tab]); | |||||
| const handleSubmitSelected = useCallback(async () => { | const handleSubmitSelected = useCallback(async () => { | ||||
| if (!currentUserId) return; | if (!currentUserId) return; | ||||
| @@ -299,6 +318,15 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| </Box> | </Box> | ||||
| {renderCurrentTab()} | {renderCurrentTab()} | ||||
| <SubmitIssueForm | |||||
| open={formOpen} | |||||
| onClose={() => setFormOpen(false)} | |||||
| lotId={selectedLotId} | |||||
| itemId={selectedItemId} | |||||
| issueType={selectedIssueType} | |||||
| currentUserId={currentUserId || 0} | |||||
| onSuccess={handleFormSuccess} | |||||
| /> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -0,0 +1,187 @@ | |||||
| "use client"; | |||||
| import { useState, useEffect } from "react"; | |||||
| import { | |||||
| Dialog, | |||||
| DialogTitle, | |||||
| DialogContent, | |||||
| DialogActions, | |||||
| Button, | |||||
| TextField, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Paper, | |||||
| Box, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import { | |||||
| getLotIssueDetails, | |||||
| submitIssueWithQty, | |||||
| LotIssueDetailResponse, | |||||
| } from "@/app/api/stockIssue/actions"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| interface Props { | |||||
| open: boolean; | |||||
| onClose: () => void; | |||||
| lotId: number | null; | |||||
| itemId: number; | |||||
| issueType: "miss" | "bad"; | |||||
| currentUserId: number; | |||||
| onSuccess: () => void; | |||||
| } | |||||
| const SubmitIssueForm: React.FC<Props> = ({ | |||||
| open, | |||||
| onClose, | |||||
| lotId, | |||||
| itemId, | |||||
| issueType, | |||||
| currentUserId, | |||||
| onSuccess, | |||||
| }) => { | |||||
| const { t } = useTranslation("inventory"); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [submitting, setSubmitting] = useState(false); | |||||
| const [details, setDetails] = useState<LotIssueDetailResponse | null>(null); | |||||
| const [submitQty, setSubmitQty] = useState<string>(""); | |||||
| useEffect(() => { | |||||
| if (open && lotId) { | |||||
| loadDetails(); | |||||
| } | |||||
| }, [open, lotId, itemId, issueType]); | |||||
| const loadDetails = async () => { | |||||
| if (!lotId) return; | |||||
| setLoading(true); | |||||
| try { | |||||
| const data = await getLotIssueDetails(lotId, itemId, issueType); | |||||
| setDetails(data); | |||||
| // Set default qty to sum of issueQty (for bad) or missQty (for miss) | |||||
| const defaultQty = issueType === "bad" | |||||
| ? data.issues.reduce((sum, issue) => sum + (issue.issueQty || 0), 0) | |||||
| : data.issues.reduce((sum, issue) => sum + (issue.missQty || 0), 0); | |||||
| setSubmitQty(defaultQty.toString()); | |||||
| } catch (error) { | |||||
| console.error("Failed to load details:", error); | |||||
| alert("Failed to load issue details"); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }; | |||||
| const handleSubmit = async () => { | |||||
| if (!lotId || !submitQty || parseFloat(submitQty) <= 0) { | |||||
| alert(t("Please enter a valid quantity")); | |||||
| return; | |||||
| } | |||||
| setSubmitting(true); | |||||
| try { | |||||
| await submitIssueWithQty( | |||||
| lotId, | |||||
| itemId, | |||||
| issueType, | |||||
| parseFloat(submitQty), | |||||
| currentUserId | |||||
| ); | |||||
| onSuccess(); | |||||
| onClose(); | |||||
| } catch (error) { | |||||
| console.error("Failed to submit:", error); | |||||
| alert(`Failed to submit: ${error instanceof Error ? error.message : "Unknown error"}`); | |||||
| } finally { | |||||
| setSubmitting(false); | |||||
| } | |||||
| }; | |||||
| if (!details) { | |||||
| return null; | |||||
| } | |||||
| return ( | |||||
| <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth> | |||||
| <DialogTitle> | |||||
| {issueType === "miss" ? t("Submit Miss Item") : t("Submit Bad Item")} | |||||
| </DialogTitle> | |||||
| <DialogContent> | |||||
| <Box sx={{ mb: 2 }}> | |||||
| <Typography variant="body2" sx={{ mb: 1 }}> | |||||
| <strong>{t("Item Code")}:</strong> {details.itemCode} | |||||
| </Typography> | |||||
| <Typography variant="body2" sx={{ mb: 1 }}> | |||||
| <strong>{t("Item")}:</strong> {details.itemDescription} | |||||
| </Typography> | |||||
| <Typography variant="body2" sx={{ mb: 1 }}> | |||||
| <strong>{t("Lot No.")}:</strong> {details.lotNo} | |||||
| </Typography> | |||||
| <Typography variant="body2" sx={{ mb: 2 }}> | |||||
| <strong>{t("Location")}:</strong> {details.storeLocation} | |||||
| </Typography> | |||||
| </Box> | |||||
| <TableContainer component={Paper} sx={{ mb: 2 }}> | |||||
| <Table size="small"> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Picker Name")}</TableCell> | |||||
| <TableCell align="right"> | |||||
| {issueType === "miss" ? t("Miss Qty") : t("Issue Qty")} | |||||
| </TableCell> | |||||
| <TableCell>{t("Pick Order Code")}</TableCell> | |||||
| <TableCell>{t("DO Order Code")}</TableCell> | |||||
| <TableCell>{t("JO Order Code")}</TableCell> | |||||
| <TableCell>{t("Remark")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {details.issues.map((issue) => ( | |||||
| <TableRow key={issue.issueId}> | |||||
| <TableCell>{issue.pickerName || "-"}</TableCell> | |||||
| <TableCell align="right"> | |||||
| {issueType === "miss" | |||||
| ? issue.missQty?.toFixed(2) || "0" | |||||
| : issue.issueQty?.toFixed(2) || "0"} | |||||
| </TableCell> | |||||
| <TableCell>{issue.pickOrderCode}</TableCell> | |||||
| <TableCell>{issue.doOrderCode || "-"}</TableCell> | |||||
| <TableCell>{issue.joOrderCode || "-"}</TableCell> | |||||
| <TableCell>{issue.issueRemark || "-"}</TableCell> | |||||
| </TableRow> | |||||
| ))} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t("Submit Quantity")} | |||||
| type="number" | |||||
| value={submitQty} | |||||
| onChange={(e) => setSubmitQty(e.target.value)} | |||||
| inputProps={{ min: 0, step: 0.01 }} | |||||
| sx={{ mt: 2 }} | |||||
| /> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={onClose} disabled={submitting}> | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button | |||||
| onClick={handleSubmit} | |||||
| variant="contained" | |||||
| disabled={submitting || !submitQty || parseFloat(submitQty) <= 0} | |||||
| > | |||||
| {submitting ? t("Submitting...") : t("Submit")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| ); | |||||
| }; | |||||
| export default SubmitIssueForm; | |||||
| @@ -0,0 +1,49 @@ | |||||
| "use client"; | |||||
| import { Box, LinearProgress, Typography } from "@mui/material"; | |||||
| import React from "react"; | |||||
| interface LinearProgressWithLabelProps { | |||||
| completed: number; | |||||
| total: number; | |||||
| label: string; | |||||
| } | |||||
| const LinearProgressWithLabel: React.FC<LinearProgressWithLabelProps> = ({ | |||||
| completed, | |||||
| total, | |||||
| label, | |||||
| }) => { | |||||
| const progress = total > 0 ? (completed / total) * 100 : 0; | |||||
| return ( | |||||
| <Box sx={{ width: "100%", mb: 2 }}> | |||||
| <Box sx={{ display: "flex", alignItems: "center", mb: 1 }}> | |||||
| <Box sx={{ width: "100%", mr: 1 }}> | |||||
| <LinearProgress | |||||
| variant="determinate" | |||||
| value={progress} | |||||
| sx={{ | |||||
| height: 30, | |||||
| borderRadius: 5, | |||||
| }} | |||||
| /> | |||||
| </Box> | |||||
| <Box sx={{ minWidth: 80 }}> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| <strong> | |||||
| {label}: {completed}/{total} | |||||
| </strong> | |||||
| </Typography> | |||||
| </Box> | |||||
| </Box> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default LinearProgressWithLabel; | |||||
| @@ -0,0 +1,82 @@ | |||||
| "use client"; | |||||
| import { Alert } from "@mui/material"; | |||||
| import React, { ReactNode } from "react"; | |||||
| interface ScanStatusAlertProps { | |||||
| error: boolean; | |||||
| success: boolean; | |||||
| errorMessage?: ReactNode; | |||||
| successMessage?: ReactNode; | |||||
| } | |||||
| const ScanStatusAlert: React.FC<ScanStatusAlertProps> = ({ | |||||
| error, | |||||
| success, | |||||
| errorMessage, | |||||
| successMessage, | |||||
| }) => { | |||||
| if (error && !success) { | |||||
| return ( | |||||
| <Alert | |||||
| severity="error" | |||||
| sx={{ | |||||
| mb: 2, | |||||
| display: "flex", | |||||
| justifyContent: "center", | |||||
| alignItems: "center", | |||||
| fontWeight: "bold", | |||||
| fontSize: "1rem", | |||||
| color: "error.main", | |||||
| "& .MuiAlert-message": { | |||||
| width: "100%", | |||||
| textAlign: "center", | |||||
| }, | |||||
| "& .MuiSvgIcon-root": { | |||||
| color: "error.main", | |||||
| }, | |||||
| backgroundColor: "error.light", | |||||
| }} | |||||
| > | |||||
| {errorMessage} | |||||
| </Alert> | |||||
| ); | |||||
| } | |||||
| if (success) { | |||||
| return ( | |||||
| <Alert | |||||
| severity="success" | |||||
| sx={{ | |||||
| mb: 2, | |||||
| display: "flex", | |||||
| justifyContent: "center", | |||||
| alignItems: "center", | |||||
| fontWeight: "bold", | |||||
| fontSize: "1rem", | |||||
| bgcolor: "rgba(76, 175, 80, 0.08)", | |||||
| color: "success.main", | |||||
| "& .MuiAlert-icon": { | |||||
| color: "success.main", | |||||
| }, | |||||
| "& .MuiAlert-message": { | |||||
| width: "100%", | |||||
| textAlign: "center", | |||||
| color: "success.main", | |||||
| }, | |||||
| }} | |||||
| > | |||||
| {successMessage} | |||||
| </Alert> | |||||
| ); | |||||
| } | |||||
| return null; | |||||
| }; | |||||
| export default ScanStatusAlert; | |||||