| @@ -15,11 +15,13 @@ import { | |||
| TextField, | |||
| Typography, | |||
| TablePagination, | |||
| Modal, | |||
| } from "@mui/material"; | |||
| import { useCallback, useMemo, useState } from "react"; | |||
| import { useCallback, useMemo, useState, useEffect } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import QrCodeIcon from '@mui/icons-material/QrCode'; | |||
| import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | |||
| import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider'; | |||
| interface LotPickData { | |||
| id: number; | |||
| @@ -63,6 +65,164 @@ interface LotTableProps { | |||
| generateInputBody: () => any; | |||
| } | |||
| // ✅ QR Code Modal Component | |||
| const QrCodeModal: React.FC<{ | |||
| open: boolean; | |||
| onClose: () => void; | |||
| lot: LotPickData | null; | |||
| onQrCodeSubmit: (lotNo: string) => void; | |||
| }> = ({ open, onClose, lot, onQrCodeSubmit }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||
| const [manualInput, setManualInput] = useState<string>(''); | |||
| // ✅ Add state to track manual input submission | |||
| const [manualInputSubmitted, setManualInputSubmitted] = useState<boolean>(false); | |||
| const [manualInputError, setManualInputError] = useState<boolean>(false); | |||
| // ✅ Process scanned QR codes | |||
| useEffect(() => { | |||
| if (qrValues.length > 0 && lot) { | |||
| const latestQr = qrValues[qrValues.length - 1]; | |||
| const qrContent = latestQr.replace(/[{}]/g, ''); | |||
| if (qrContent === lot.lotNo) { | |||
| onQrCodeSubmit(lot.lotNo); | |||
| onClose(); | |||
| resetScan(); | |||
| } else { | |||
| // ✅ Set error state for helper text | |||
| setManualInputError(true); | |||
| setManualInputSubmitted(true); | |||
| } | |||
| } | |||
| }, [qrValues, lot, onQrCodeSubmit, onClose, resetScan]); | |||
| // ✅ Clear states when modal opens or lot changes | |||
| useEffect(() => { | |||
| if (open) { | |||
| setManualInput(''); | |||
| setManualInputSubmitted(false); | |||
| setManualInputError(false); | |||
| } | |||
| }, [open]); | |||
| useEffect(() => { | |||
| if (lot) { | |||
| setManualInput(''); | |||
| setManualInputSubmitted(false); | |||
| setManualInputError(false); | |||
| } | |||
| }, [lot]); | |||
| const handleManualSubmit = () => { | |||
| if (manualInput.trim() === lot?.lotNo) { | |||
| // ✅ Success - no error helper text needed | |||
| onQrCodeSubmit(lot.lotNo); | |||
| onClose(); | |||
| setManualInput(''); | |||
| } else { | |||
| // ✅ Show error helper text after submit | |||
| setManualInputError(true); | |||
| setManualInputSubmitted(true); | |||
| // Don't clear input - let user see what they typed | |||
| } | |||
| }; | |||
| return ( | |||
| <Modal open={open} onClose={onClose}> | |||
| <Box sx={{ | |||
| position: 'absolute', | |||
| top: '50%', | |||
| left: '50%', | |||
| transform: 'translate(-50%, -50%)', | |||
| bgcolor: 'background.paper', | |||
| p: 3, | |||
| borderRadius: 2, | |||
| minWidth: 400, | |||
| }}> | |||
| <Typography variant="h6" gutterBottom> | |||
| {t("QR Code Scan for Lot")}: {lot?.lotNo} | |||
| </Typography> | |||
| {/* QR Scanner Status */} | |||
| <Box sx={{ mb: 2, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}> | |||
| <Typography variant="body2" gutterBottom> | |||
| <strong>Scanner Status:</strong> {isScanning ? 'Scanning...' : 'Ready'} | |||
| </Typography> | |||
| <Stack direction="row" spacing={1}> | |||
| <Button | |||
| variant="contained" | |||
| onClick={isScanning ? stopScan : startScan} | |||
| size="small" | |||
| > | |||
| {isScanning ? 'Stop Scan' : 'Start Scan'} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| onClick={resetScan} | |||
| size="small" | |||
| > | |||
| Reset | |||
| </Button> | |||
| </Stack> | |||
| </Box> | |||
| {/* Manual Input with Submit-Triggered Helper Text */} | |||
| <Box sx={{ mb: 2 }}> | |||
| <Typography variant="body2" gutterBottom> | |||
| <strong>Manual Input:</strong> | |||
| </Typography> | |||
| <TextField | |||
| fullWidth | |||
| size="small" | |||
| value={manualInput} | |||
| onChange={(e) => setManualInput(e.target.value)} | |||
| sx={{ mb: 1 }} | |||
| // ✅ Only show error after submit button is clicked | |||
| error={manualInputSubmitted && manualInputError} | |||
| helperText={ | |||
| // ✅ Show helper text only after submit with error | |||
| manualInputSubmitted && manualInputError | |||
| ? `The input is not the same as the expected lot number. Expected: ${lot?.lotNo}` | |||
| : '' | |||
| } | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| onClick={handleManualSubmit} | |||
| disabled={!manualInput.trim()} | |||
| size="small" | |||
| color="primary" | |||
| > | |||
| Submit Manual Input | |||
| </Button> | |||
| </Box> | |||
| {/* ✅ Show QR Scan Status */} | |||
| {qrValues.length > 0 && ( | |||
| <Box sx={{ mb: 2, p: 2, backgroundColor: manualInputError ? '#ffebee' : '#e8f5e8', borderRadius: 1 }}> | |||
| <Typography variant="body2" color={manualInputError ? 'error' : 'success'}> | |||
| <strong>QR Scan Result:</strong> {qrValues[qrValues.length - 1]} | |||
| </Typography> | |||
| {manualInputError && ( | |||
| <Typography variant="caption" color="error" display="block"> | |||
| ❌ Mismatch! Expected: {lot?.lotNo} | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| )} | |||
| <Box sx={{ mt: 2, textAlign: 'right' }}> | |||
| <Button onClick={onClose} variant="outlined"> | |||
| Cancel | |||
| </Button> | |||
| </Box> | |||
| </Box> | |||
| </Modal> | |||
| ); | |||
| }; | |||
| const LotTable: React.FC<LotTableProps> = ({ | |||
| lotData, | |||
| selectedRowId, | |||
| @@ -83,6 +243,14 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| // ✅ Add QR scanner context | |||
| const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||
| // ✅ Add state for QR input modal | |||
| const [qrModalOpen, setQrModalOpen] = useState(false); | |||
| const [selectedLotForQr, setSelectedLotForQr] = useState<LotPickData | null>(null); | |||
| const [manualQrInput, setManualQrInput] = useState<string>(''); | |||
| // 分页控制器 | |||
| const [lotTablePagingController, setLotTablePagingController] = useState({ | |||
| pageNum: 0, | |||
| @@ -95,10 +263,10 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| return "Please finish QR code scan, QC check and pick order."; | |||
| } | |||
| switch (lot.stockOutLineStatus?.toUpperCase()) { | |||
| case 'PENDING': | |||
| switch (lot.stockOutLineStatus?.toLowerCase()) { | |||
| case 'pending': | |||
| return "Please finish QC check and pick order."; | |||
| case 'COMPLETE': | |||
| case 'completed': | |||
| return "Please submit the pick order."; | |||
| case 'unavailable': | |||
| return "This order is insufficient, please pick another lot."; | |||
| @@ -137,6 +305,23 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| }); | |||
| }, []); | |||
| // ✅ Handle QR code submission | |||
| const handleQrCodeSubmit = useCallback((lotNo: string) => { | |||
| if (selectedLotForQr && selectedLotForQr.lotNo === lotNo) { | |||
| console.log(`✅ QR Code verified for lot: ${lotNo}`); | |||
| // ✅ Create stock out line | |||
| onCreateStockOutLine(selectedLotForQr.lotId); | |||
| // ✅ Show success message | |||
| console.log("Stock out line created successfully!"); | |||
| // ✅ Close modal | |||
| setQrModalOpen(false); | |||
| setSelectedLotForQr(null); | |||
| } | |||
| }, [selectedLotForQr, onCreateStockOutLine]); | |||
| return ( | |||
| <> | |||
| <TableContainer component={Paper}> | |||
| @@ -198,26 +383,47 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| {/* QR Code Scan Button */} | |||
| <TableCell align="center"> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={() => { | |||
| onCreateStockOutLine(lot.lotId); | |||
| onLotSelectForInput(lot); // Show input body when button is clicked | |||
| }} | |||
| // ✅ Allow creation for available AND insufficient_stock lots | |||
| disabled={(lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') || Boolean(lot.stockOutLineId)} | |||
| sx={{ | |||
| fontSize: '0.7rem', | |||
| py: 0.5, | |||
| minHeight: '28px', | |||
| whiteSpace: 'nowrap', | |||
| minWidth: '40px' | |||
| }} | |||
| startIcon={<QrCodeIcon />} // ✅ Add QR code icon | |||
| > | |||
| {lot.stockOutLineId ? t("Scanned") : t("Scan")} | |||
| </Button> | |||
| <Box sx={{ textAlign: 'center' }}> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={() => { | |||
| setSelectedLotForQr(lot); | |||
| setQrModalOpen(true); | |||
| resetScan(); | |||
| }} | |||
| // ✅ Disable when: | |||
| // 1. Lot is expired or unavailable | |||
| // 2. Already scanned (has stockOutLineId) | |||
| // 3. Not selected (selectedLotRowId doesn't match) | |||
| disabled={ | |||
| (lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') || | |||
| Boolean(lot.stockOutLineId) || | |||
| selectedLotRowId !== `row_${index}` | |||
| } | |||
| sx={{ | |||
| fontSize: '0.7rem', | |||
| py: 0.5, | |||
| minHeight: '28px', | |||
| whiteSpace: 'nowrap', | |||
| minWidth: '40px', | |||
| // ✅ Visual feedback | |||
| opacity: selectedLotRowId === `row_${index}` ? 1 : 0.5 | |||
| }} | |||
| startIcon={<QrCodeIcon />} | |||
| title={ | |||
| selectedLotRowId !== `row_${index}` | |||
| ? "Please select this lot first to enable QR scanning" | |||
| : lot.stockOutLineId | |||
| ? "Already scanned" | |||
| : "Click to scan QR code" | |||
| } | |||
| > | |||
| {lot.stockOutLineId ? t("Scanned") : t("Scan")} | |||
| </Button> | |||
| </Box> | |||
| </TableCell> | |||
| {/* QC Check Button */} | |||
| @@ -320,6 +526,19 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||
| } | |||
| /> | |||
| {/* ✅ QR Code Modal */} | |||
| <QrCodeModal | |||
| open={qrModalOpen} | |||
| onClose={() => { | |||
| setQrModalOpen(false); | |||
| setSelectedLotForQr(null); | |||
| stopScan(); | |||
| resetScan(); | |||
| }} | |||
| lot={selectedLotForQr} | |||
| onQrCodeSubmit={handleQrCodeSubmit} | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| @@ -490,20 +490,22 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| }, [selectedLotRowId]); | |||
| // ✅ Add function to handle row selection that resets lot selection | |||
| const handleRowSelect = useCallback(async (lineId: number) => { | |||
| const handleRowSelect = useCallback(async (lineId: number, preserveLotSelection: boolean = false) => { | |||
| setSelectedRowId(lineId); | |||
| // ✅ Reset lot selection when changing pick order line | |||
| setSelectedLotRowId(null); | |||
| setSelectedLotId(null); | |||
| // ✅ Only reset lot selection if not preserving | |||
| if (!preserveLotSelection) { | |||
| setSelectedLotRowId(null); | |||
| setSelectedLotId(null); | |||
| } | |||
| try { | |||
| const lotDetails = await fetchPickOrderLineLotDetails(lineId); | |||
| console.log("Lot details from API:", lotDetails); | |||
| const realLotData: LotPickData[] = lotDetails.map((lot: any) => ({ | |||
| id: lot.lotId, // This is actually ill.id (inventory lot line ID) | |||
| lotId: lot.lotId, // This should be the unique inventory lot line ID | |||
| id: lot.id, // This should be the unique row ID for the table | |||
| lotId: lot.lotId, // This is the inventory lot line ID | |||
| lotNo: lot.lotNo, | |||
| expiryDate: lot.expiryDate ? new Date(lot.expiryDate).toLocaleDateString() : 'N/A', | |||
| location: lot.location, | |||
| @@ -513,7 +515,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| actualPickQty: lot.actualPickQty || 0, | |||
| lotStatus: lot.lotStatus, | |||
| lotAvailability: lot.lotAvailability, | |||
| // ✅ Add StockOutLine fields | |||
| stockOutLineId: lot.stockOutLineId, | |||
| stockOutLineStatus: lot.stockOutLineStatus, | |||
| stockOutLineQty: lot.stockOutLineQty | |||
| @@ -545,6 +546,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| pickOrderCode: pickOrder.code, | |||
| targetDate: formattedTargetDate, // ✅ 使用 dayjs 格式化的日期 | |||
| balanceToPick: balanceToPick, | |||
| pickedQty: line.pickedQty, | |||
| // 确保 availableQty 不为 null | |||
| availableQty: availableQty, | |||
| }; | |||
| @@ -675,6 +677,10 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| } | |||
| try { | |||
| // ✅ Store current lot selection before refresh | |||
| const currentSelectedLotRowId = selectedLotRowId; | |||
| const currentSelectedLotId = selectedLotId; | |||
| const stockOutLineData: CreateStockOutLine = { | |||
| consoCode: pickOrderDetails.consoCode, | |||
| pickOrderLineId: selectedRowId, | |||
| @@ -692,19 +698,57 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| if (result) { | |||
| console.log("Stock out line created successfully:", result); | |||
| //alert(`Stock out line created successfully! ID: ${result.id}`); | |||
| // ✅ Don't refresh immediately - let user see the result first | |||
| // ✅ Auto-refresh data after successful creation | |||
| console.log("🔄 Refreshing data after stock out line creation..."); | |||
| try { | |||
| // ✅ Refresh lot data for the selected row (maintains selection) | |||
| if (selectedRowId) { | |||
| await handleRowSelect(selectedRowId, true); // ✅ Preserve lot selection | |||
| } | |||
| // ✅ Refresh main pick order details | |||
| await handleFetchAllPickOrderDetails(); | |||
| console.log("✅ Data refresh completed - lot selection maintained!"); | |||
| } catch (refreshError) { | |||
| console.error("❌ Error refreshing data:", refreshError); | |||
| } | |||
| setShowInputBody(false); // Hide preview after successful creation | |||
| } else { | |||
| console.error("Failed to create stock out line: No response"); | |||
| //alert("Failed to create stock out line: No response"); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error creating stock out line:", error); | |||
| //alert("Error creating stock out line. Please try again."); | |||
| } | |||
| }, [selectedRowId, pickOrderDetails?.consoCode]); | |||
| }, [selectedRowId, pickOrderDetails?.consoCode, handleRowSelect, handleFetchAllPickOrderDetails, selectedLotRowId, selectedLotId]); | |||
| // ✅ New function to refresh data while preserving lot selection | |||
| const handleRefreshDataPreserveSelection = useCallback(async () => { | |||
| if (!selectedRowId) return; | |||
| // ✅ Store current lot selection | |||
| const currentSelectedLotRowId = selectedLotRowId; | |||
| const currentSelectedLotId = selectedLotId; | |||
| try { | |||
| // ✅ Refresh lot data | |||
| await handleRowSelect(selectedRowId, true); // ✅ Preserve selection | |||
| // ✅ Refresh main pick order details | |||
| await handleFetchAllPickOrderDetails(); | |||
| // ✅ Restore lot selection | |||
| setSelectedLotRowId(currentSelectedLotRowId); | |||
| setSelectedLotId(currentSelectedLotId); | |||
| console.log("✅ Data refreshed with selection preserved"); | |||
| } catch (error) { | |||
| console.error("❌ Error refreshing data:", error); | |||
| } | |||
| }, [selectedRowId, selectedLotRowId, selectedLotId, handleRowSelect, handleFetchAllPickOrderDetails]); | |||
| // 自定义主表格组件 | |||
| const CustomMainTable = () => { | |||
| @@ -740,7 +784,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const availableQty = line.availableQty ?? 0; | |||
| const balanceToPick = Math.max(0, availableQty - line.requiredQty); // 确保不为负数 | |||
| const totalPickedQty = getTotalPickedQty(line.id); | |||
| const actualPickedQty = line.pickedQty ?? 0; | |||
| return ( | |||
| <TableRow | |||
| key={line.id} | |||
| @@ -777,7 +821,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| }}> | |||
| {availableQty.toLocaleString()} {/* 添加千位分隔符 */} | |||
| </TableCell> | |||
| <TableCell align="right">{totalPickedQty}</TableCell> | |||
| <TableCell align="right">{actualPickedQty}</TableCell> | |||
| <TableCell align="right">{line.uomDesc}</TableCell> | |||
| <TableCell align="right">{line.targetDate}</TableCell> | |||
| </TableRow> | |||
| @@ -40,6 +40,8 @@ interface ExtendedQcItem extends QcItemWithChecks { | |||
| qcPassed?: boolean; | |||
| failQty?: number; | |||
| remarks?: string; | |||
| order?: number; // ✅ Add order property | |||
| stableId?: string; // ✅ Also add stableId for better row identification | |||
| } | |||
| const style = { | |||
| @@ -195,15 +197,22 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| // ✅ 修改:在组件开始时自动设置失败数量 | |||
| useEffect(() => { | |||
| if (itemDetail && qcItems.length > 0) { | |||
| // ✅ 自动将 Lot Required Pick Qty 设置为所有失败项目的 failQty | |||
| const updatedQcItems = qcItems.map(item => ({ | |||
| ...item, | |||
| failQty: itemDetail.requiredQty || 0 // 使用 Lot Required Pick Qty | |||
| })); | |||
| setQcItems(updatedQcItems); | |||
| if (itemDetail && qcItems.length > 0 && selectedLotId) { | |||
| // ✅ 获取选中的批次数据 | |||
| const selectedLot = lotData.find(lot => lot.stockOutLineId === selectedLotId); | |||
| if (selectedLot) { | |||
| // ✅ 自动将 Lot Required Pick Qty 设置为所有失败项目的 failQty | |||
| const updatedQcItems = qcItems.map((item, index) => ({ | |||
| ...item, | |||
| failQty: selectedLot.requiredQty || 0, // 使用 Lot Required Pick Qty | |||
| // ✅ Add stable order and ID fields | |||
| order: index, | |||
| stableId: `qc-${item.id}-${index}` | |||
| })); | |||
| setQcItems(updatedQcItems); | |||
| } | |||
| } | |||
| }, [itemDetail, qcItems.length]); | |||
| }, [itemDetail, qcItems.length, selectedLotId, lotData]); | |||
| // ✅ 修改:移除 alert 弹窗,改为控制台日志 | |||
| const onSubmitQc = useCallback<SubmitHandler<any>>( | |||
| @@ -215,7 +224,8 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| const acceptQty = Number(accQty) || null; | |||
| const validationErrors : string[] = []; | |||
| const selectedLot = lotData.find(lot => lot.stockOutLineId === selectedLotId); | |||
| const itemsWithoutResult = qcItems.filter(item => item.qcPassed === undefined); | |||
| if (itemsWithoutResult.length > 0) { | |||
| validationErrors.push(`${t("QC items without result")}: ${itemsWithoutResult.map(item => item.code).join(", ")}`); | |||
| @@ -234,7 +244,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| qcItem: item.code, | |||
| qcDescription: item.description || "", | |||
| isPassed: item.qcPassed, | |||
| failQty: item.qcPassed ? 0 : (itemDetail?.requiredQty || 0), | |||
| failQty: item.qcPassed ? 0 : (selectedLot?.requiredQty || 0), | |||
| remarks: item.remarks || "", | |||
| })), | |||
| }; | |||
| @@ -248,7 +258,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| } | |||
| // ✅ Fix: Update stock out line status based on QC decision | |||
| if (selectedLotId && qcData.qcAccept) { | |||
| if (selectedLotId) { // ✅ Remove qcData.qcAccept condition | |||
| try { | |||
| const allPassed = qcData.qcItems.every(item => item.isPassed); | |||
| @@ -261,17 +271,19 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| }); | |||
| // ✅ Fix: 1. Update stock out line status with required qty field | |||
| await updateStockOutLineStatus({ | |||
| id: selectedLotId, | |||
| status: newStockOutLineStatus, | |||
| qty: itemDetail?.requiredQty || 0 // ✅ Add required qty field | |||
| }); | |||
| if (selectedLot) { | |||
| await updateStockOutLineStatus({ | |||
| id: selectedLotId, | |||
| status: newStockOutLineStatus, | |||
| qty: selectedLot.requiredQty || 0 | |||
| }); | |||
| } else { | |||
| console.warn("Selected lot not found for stock out line status update"); | |||
| } | |||
| // ✅ Fix: 2. If QC failed, also update inventory lot line status | |||
| if (!allPassed) { | |||
| try { | |||
| // ✅ Fix: Get the correct lot data | |||
| const selectedLot = lotData.find(lot => lot.stockOutLineId === selectedLotId); | |||
| if (selectedLot) { | |||
| console.log("Updating inventory lot line status for failed QC:", { | |||
| inventoryLotLineId: selectedLot.lotId, | |||
| @@ -280,7 +292,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| await updateInventoryLotLineStatus({ | |||
| inventoryLotLineId: selectedLot.lotId, | |||
| status: 'unavailable' // ✅ Use correct backend enum value | |||
| status: 'unavailable' | |||
| }); | |||
| console.log("Inventory lot line status updated to unavailable"); | |||
| } else { | |||
| @@ -288,7 +300,6 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| } | |||
| } catch (error) { | |||
| console.error("Failed to update inventory lot line status:", error); | |||
| // ✅ Don't fail the entire operation, just log the error | |||
| } | |||
| } | |||
| @@ -300,12 +311,6 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| } | |||
| } catch (error) { | |||
| console.error("Error updating stock out line status after QC:", error); | |||
| // ✅ Log detailed error information | |||
| if (error instanceof Error) { | |||
| console.error("Error details:", error.message); | |||
| console.error("Error stack:", error.stack); | |||
| } | |||
| // ✅ Don't fail the entire QC submission, just log the error | |||
| } | |||
| } | |||
| @@ -362,15 +367,20 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| value={current.qcPassed === undefined ? "" : (current.qcPassed ? "true" : "false")} | |||
| onChange={(e) => { | |||
| const value = e.target.value === "true"; | |||
| setQcItems((prev) => | |||
| prev.map((r): ExtendedQcItem => (r.id === params.id ? { ...r, qcPassed: value } : r)) | |||
| // ✅ Simple state update | |||
| setQcItems(prev => | |||
| prev.map(item => | |||
| item.id === params.id | |||
| ? { ...item, qcPassed: value } | |||
| : item | |||
| ) | |||
| ); | |||
| }} | |||
| name={`qcPassed-${params.id}`} | |||
| > | |||
| <FormControlLabel | |||
| value="true" | |||
| control={<Radio size="small" />} | |||
| control={<Radio />} | |||
| label="合格" | |||
| sx={{ | |||
| color: current.qcPassed === true ? "green" : "inherit", | |||
| @@ -379,7 +389,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| /> | |||
| <FormControlLabel | |||
| value="false" | |||
| control={<Radio size="small" />} | |||
| control={<Radio />} | |||
| label="不合格" | |||
| sx={{ | |||
| color: current.qcPassed === false ? "red" : "inherit", | |||
| @@ -400,7 +410,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| type="number" | |||
| size="small" | |||
| // ✅ 修改:失败项目自动显示 Lot Required Pick Qty | |||
| value={!params.row.qcPassed ? (itemDetail?.requiredQty || 0) : 0} | |||
| value={!params.row.qcPassed ? (0) : 0} | |||
| disabled={params.row.qcPassed} | |||
| // ✅ 移除 onChange,因为数量是固定的 | |||
| // onChange={(e) => { | |||
| @@ -444,6 +454,27 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| [t], | |||
| ); | |||
| // ✅ Add stable update function | |||
| const handleQcResultChange = useCallback((itemId: number, qcPassed: boolean) => { | |||
| setQcItems(prevItems => | |||
| prevItems.map(item => | |||
| item.id === itemId | |||
| ? { ...item, qcPassed } | |||
| : item | |||
| ) | |||
| ); | |||
| }, []); | |||
| // ✅ Remove duplicate functions | |||
| const getRowId = useCallback((row: any) => { | |||
| return row.id; // Just use the original ID | |||
| }, []); | |||
| // ✅ Remove complex sorting logic | |||
| // const stableQcItems = useMemo(() => { ... }); // Remove | |||
| // const sortedQcItems = useMemo(() => { ... }); // Remove | |||
| // ✅ Use qcItems directly in DataGrid | |||
| return ( | |||
| <> | |||
| <FormProvider {...formProps}> | |||
| @@ -481,8 +512,9 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| <StyledDataGrid | |||
| columns={qcColumns} | |||
| rows={qcItems} | |||
| rows={qcItems} // ✅ Use qcItems directly | |||
| autoHeight | |||
| getRowId={getRowId} // ✅ Simple row ID function | |||
| /> | |||
| </Grid> | |||
| </> | |||
| @@ -0,0 +1,511 @@ | |||
| "use client"; | |||
| import { | |||
| Autocomplete, | |||
| Box, | |||
| Button, | |||
| CircularProgress, | |||
| FormControl, | |||
| Grid, | |||
| Modal, | |||
| TextField, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| Checkbox, | |||
| TablePagination, | |||
| } from "@mui/material"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| newassignPickOrder, | |||
| AssignPickOrderInputs, | |||
| releaseAssignedPickOrders, | |||
| fetchPickOrderWithStockClient, // Add this import | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { fetchNameList, NameList } from "@/app/api/user/actions"; | |||
| import { | |||
| FormProvider, | |||
| useForm, | |||
| } from "react-hook-form"; | |||
| import { isEmpty, upperFirst, groupBy } from "lodash"; | |||
| import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil"; | |||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||
| import dayjs from "dayjs"; | |||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { sortBy, uniqBy } from "lodash"; | |||
| import { createStockOutLine, CreateStockOutLine, fetchPickOrderDetails } from "@/app/api/pickOrder/actions"; | |||
| dayjs.extend(arraySupport); | |||
| interface Props { | |||
| filterArgs: Record<string, any>; | |||
| } | |||
| // Update the interface to match the new API response structure | |||
| interface PickOrderRow { | |||
| id: string; | |||
| code: string; | |||
| targetDate: string; | |||
| type: string; | |||
| status: string; | |||
| assignTo: number; | |||
| groupName: string; | |||
| consoCode?: string; | |||
| pickOrderLines: PickOrderLineRow[]; | |||
| } | |||
| interface PickOrderLineRow { | |||
| id: number; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| availableQty: number | null; | |||
| requiredQty: number; | |||
| uomCode: string; | |||
| uomDesc: string; | |||
| suggestedList: any[]; | |||
| } | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| bgcolor: "background.paper", | |||
| pt: 5, | |||
| px: 5, | |||
| pb: 10, | |||
| width: { xs: "100%", sm: "100%", md: "100%" }, | |||
| }; | |||
| const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { setIsUploading } = useUploadContext(); | |||
| const [isUploading, setIsUploadingLocal] = useState(false); | |||
| // Update state to use pick order data directly | |||
| const [selectedPickOrderIds, setSelectedPickOrderIds] = useState<string[]>([]); | |||
| const [filteredPickOrders, setFilteredPickOrders] = useState<PickOrderRow[]>([]); | |||
| const [isLoadingItems, setIsLoadingItems] = useState(false); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 1, | |||
| pageSize: 10, | |||
| }); | |||
| const [totalCountItems, setTotalCountItems] = useState<number>(); | |||
| const [modalOpen, setModalOpen] = useState(false); | |||
| const [usernameList, setUsernameList] = useState<NameList[]>([]); | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| const [originalPickOrderData, setOriginalPickOrderData] = useState<PickOrderRow[]>([]); | |||
| const formProps = useForm<AssignPickOrderInputs>(); | |||
| const errors = formProps.formState.errors; | |||
| // Update the handler functions to work with string IDs | |||
| const handlePickOrderSelect = useCallback((pickOrderId: string, checked: boolean) => { | |||
| if (checked) { | |||
| setSelectedPickOrderIds(prev => [...prev, pickOrderId]); | |||
| } else { | |||
| setSelectedPickOrderIds(prev => prev.filter(id => id !== pickOrderId)); | |||
| } | |||
| }, []); | |||
| const isPickOrderSelected = useCallback((pickOrderId: string) => { | |||
| return selectedPickOrderIds.includes(pickOrderId); | |||
| }, [selectedPickOrderIds]); | |||
| // Update the fetch function to use the correct endpoint | |||
| const fetchNewPageItems = useCallback( | |||
| async (pagingController: Record<string, number>, filterArgs: Record<string, any>) => { | |||
| setIsLoadingItems(true); | |||
| try { | |||
| const params = { | |||
| ...pagingController, | |||
| ...filterArgs, | |||
| pageNum: (pagingController.pageNum || 1) - 1, | |||
| pageSize: pagingController.pageSize || 10, | |||
| // Filter for assigned status only | |||
| status: "assigned" | |||
| }; | |||
| const res = await fetchPickOrderWithStockClient(params); | |||
| if (res && res.records) { | |||
| // Convert pick order data to the expected format | |||
| const pickOrderRows: PickOrderRow[] = res.records.map((pickOrder: any) => ({ | |||
| id: pickOrder.id, | |||
| code: pickOrder.code, | |||
| targetDate: pickOrder.targetDate, | |||
| type: pickOrder.type, | |||
| status: pickOrder.status, | |||
| assignTo: pickOrder.assignTo, | |||
| groupName: pickOrder.groupName || "No Group", | |||
| consoCode: pickOrder.consoCode, | |||
| pickOrderLines: pickOrder.pickOrderLines || [] | |||
| })); | |||
| setOriginalPickOrderData(pickOrderRows); | |||
| setFilteredPickOrders(pickOrderRows); | |||
| setTotalCountItems(res.total); | |||
| } else { | |||
| setFilteredPickOrders([]); | |||
| setTotalCountItems(0); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error fetching pick orders:", error); | |||
| setFilteredPickOrders([]); | |||
| setTotalCountItems(0); | |||
| } finally { | |||
| setIsLoadingItems(false); | |||
| } | |||
| }, | |||
| [], | |||
| ); | |||
| // Handle Release operation | |||
| // Handle Release operation | |||
| const handleRelease = useCallback(async () => { | |||
| if (selectedPickOrderIds.length === 0) return; | |||
| setIsUploading(true); | |||
| try { | |||
| // Get the assigned user from the selected pick orders | |||
| const selectedPickOrders = filteredPickOrders.filter(pickOrder => | |||
| selectedPickOrderIds.includes(pickOrder.id) | |||
| ); | |||
| // Check if all selected pick orders have the same assigned user | |||
| const assignedUsers = selectedPickOrders.map(po => po.assignTo).filter(Boolean); | |||
| if (assignedUsers.length === 0) { | |||
| alert("Selected pick orders are not assigned to any user."); | |||
| return; | |||
| } | |||
| const assignToValue = assignedUsers[0]; | |||
| // Validate that all pick orders are assigned to the same user | |||
| const allSameUser = assignedUsers.every(userId => userId === assignToValue); | |||
| if (!allSameUser) { | |||
| alert("All selected pick orders must be assigned to the same user."); | |||
| return; | |||
| } | |||
| console.log("Using assigned user:", assignToValue); | |||
| console.log("selectedPickOrderIds:", selectedPickOrderIds); | |||
| const releaseRes = await releaseAssignedPickOrders({ | |||
| pickOrderIds: selectedPickOrderIds.map(id => parseInt(id)), | |||
| assignTo: assignToValue | |||
| }); | |||
| if (releaseRes.code === "SUCCESS") { | |||
| console.log("Pick orders released successfully"); | |||
| // Get the consoCode from the response | |||
| const consoCode = (releaseRes.entity as any)?.consoCode; | |||
| if (consoCode) { | |||
| // Create StockOutLine records for each pick order line | |||
| for (const pickOrder of selectedPickOrders) { | |||
| for (const line of pickOrder.pickOrderLines) { | |||
| try { | |||
| const stockOutLineData = { | |||
| consoCode: consoCode, | |||
| pickOrderLineId: line.id, | |||
| inventoryLotLineId: 0, // This will be set when user scans QR code | |||
| qty: line.requiredQty, | |||
| }; | |||
| console.log("Creating stock out line:", stockOutLineData); | |||
| await createStockOutLine(stockOutLineData); | |||
| } catch (error) { | |||
| console.error("Error creating stock out line for line", line.id, error); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| fetchNewPageItems(pagingController, filterArgs); | |||
| } else { | |||
| console.error("Release failed:", releaseRes.message); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error releasing pick orders:", error); | |||
| } finally { | |||
| setIsUploading(false); | |||
| } | |||
| }, [selectedPickOrderIds, filteredPickOrders, setIsUploading, fetchNewPageItems, pagingController, filterArgs]); | |||
| // Update search criteria to match the new data structure | |||
| const searchCriteria: Criterion<any>[] = useMemo( | |||
| () => [ | |||
| { | |||
| label: t("Pick Order Code"), | |||
| paramName: "code", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Group Code"), | |||
| paramName: "groupName", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Target Date From"), | |||
| label2: t("Target Date To"), | |||
| paramName: "targetDate", | |||
| type: "dateRange", | |||
| }, | |||
| ], | |||
| [t], | |||
| ); | |||
| // Update search function to work with pick order data | |||
| const handleSearch = useCallback((query: Record<string, any>) => { | |||
| setSearchQuery({ ...query }); | |||
| const filtered = originalPickOrderData.filter((pickOrder) => { | |||
| const pickOrderTargetDateStr = arrayToDayjs(pickOrder.targetDate); | |||
| const codeMatch = !query.code || | |||
| pickOrder.code?.toLowerCase().includes((query.code || "").toLowerCase()); | |||
| const groupNameMatch = !query.groupName || | |||
| pickOrder.groupName?.toLowerCase().includes((query.groupName || "").toLowerCase()); | |||
| // Date range search | |||
| let dateMatch = true; | |||
| if (query.targetDate || query.targetDateTo) { | |||
| try { | |||
| if (query.targetDate && !query.targetDateTo) { | |||
| const fromDate = dayjs(query.targetDate); | |||
| dateMatch = pickOrderTargetDateStr.isSame(fromDate, 'day') || | |||
| pickOrderTargetDateStr.isAfter(fromDate, 'day'); | |||
| } else if (!query.targetDate && query.targetDateTo) { | |||
| const toDate = dayjs(query.targetDateTo); | |||
| dateMatch = pickOrderTargetDateStr.isSame(toDate, 'day') || | |||
| pickOrderTargetDateStr.isBefore(toDate, 'day'); | |||
| } else if (query.targetDate && query.targetDateTo) { | |||
| const fromDate = dayjs(query.targetDate); | |||
| const toDate = dayjs(query.targetDateTo); | |||
| dateMatch = (pickOrderTargetDateStr.isSame(fromDate, 'day') || | |||
| pickOrderTargetDateStr.isAfter(fromDate, 'day')) && | |||
| (pickOrderTargetDateStr.isSame(toDate, 'day') || | |||
| pickOrderTargetDateStr.isBefore(toDate, 'day')); | |||
| } | |||
| } catch (error) { | |||
| console.error("Date parsing error:", error); | |||
| dateMatch = true; | |||
| } | |||
| } | |||
| return codeMatch && groupNameMatch && dateMatch; | |||
| }); | |||
| setFilteredPickOrders(filtered); | |||
| }, [originalPickOrderData]); | |||
| const handleReset = useCallback(() => { | |||
| setSearchQuery({}); | |||
| setFilteredPickOrders(originalPickOrderData); | |||
| setTimeout(() => { | |||
| setSearchQuery({}); | |||
| }, 0); | |||
| }, [originalPickOrderData]); | |||
| // Pagination handlers | |||
| const handlePageChange = useCallback((event: unknown, newPage: number) => { | |||
| const newPagingController = { | |||
| ...pagingController, | |||
| pageNum: newPage + 1, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| }, [pagingController]); | |||
| const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||
| const newPageSize = parseInt(event.target.value, 10); | |||
| const newPagingController = { | |||
| pageNum: 1, | |||
| pageSize: newPageSize, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| }, []); | |||
| // Component mount effect | |||
| useEffect(() => { | |||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||
| }, []); | |||
| // Dependencies change effect | |||
| useEffect(() => { | |||
| if (pagingController && (filterArgs || {})) { | |||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||
| } | |||
| }, [pagingController, filterArgs, fetchNewPageItems]); | |||
| useEffect(() => { | |||
| const loadUsernameList = async () => { | |||
| try { | |||
| const res = await fetchNameList(); | |||
| if (res) { | |||
| setUsernameList(res); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error loading username list:", error); | |||
| } | |||
| }; | |||
| loadUsernameList(); | |||
| }, []); | |||
| // Update the table component to work with pick order data directly | |||
| const CustomPickOrderTable = () => { | |||
| // Helper function to get user name | |||
| const getUserName = useCallback((assignToId: number | null | undefined) => { | |||
| if (!assignToId) return '-'; | |||
| const user = usernameList.find(u => u.id === assignToId); | |||
| return user ? user.name : `User ${assignToId}`; | |||
| }, [usernameList]); | |||
| return ( | |||
| <> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Selected")}</TableCell> | |||
| <TableCell>{t("Pick Order Code")}</TableCell> | |||
| <TableCell>{t("Group Code")}</TableCell> | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell align="right">{t("Order Quantity")}</TableCell> | |||
| <TableCell align="right">{t("Current Stock")}</TableCell> | |||
| <TableCell align="right">{t("Stock Unit")}</TableCell> | |||
| <TableCell>{t("Target Date")}</TableCell> | |||
| <TableCell>{t("Assigned To")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {filteredPickOrders.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={10} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data available")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| filteredPickOrders.map((pickOrder) => ( | |||
| pickOrder.pickOrderLines.map((line: PickOrderLineRow, index: number) => ( | |||
| <TableRow key={`${pickOrder.id}-${line.id}`}> | |||
| {/* Checkbox - only show for first line of each pick order */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| <Checkbox | |||
| checked={isPickOrderSelected(pickOrder.id)} | |||
| onChange={(e) => handlePickOrderSelect(pickOrder.id, e.target.checked)} | |||
| disabled={!isEmpty(pickOrder.consoCode)} | |||
| /> | |||
| ) : null} | |||
| </TableCell> | |||
| {/* Pick Order Code - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? pickOrder.code : null} | |||
| </TableCell> | |||
| {/* Group Name - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? pickOrder.groupName : null} | |||
| </TableCell> | |||
| {/* Item Code */} | |||
| <TableCell>{line.itemCode}</TableCell> | |||
| {/* Item Name */} | |||
| <TableCell>{line.itemName}</TableCell> | |||
| {/* Order Quantity */} | |||
| <TableCell align="right">{line.requiredQty}</TableCell> | |||
| {/* Current Stock */} | |||
| <TableCell align="right"> | |||
| <Typography | |||
| variant="body2" | |||
| color={line.availableQty && line.availableQty > 0 ? "success.main" : "error.main"} | |||
| sx={{ fontWeight: line.availableQty && line.availableQty > 0 ? 'bold' : 'normal' }} | |||
| > | |||
| {(line.availableQty || 0).toLocaleString()} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Unit */} | |||
| <TableCell align="right">{line.uomDesc}</TableCell> | |||
| {/* Target Date - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| arrayToDayjs(pickOrder.targetDate) | |||
| .add(-1, "month") | |||
| .format(OUTPUT_DATE_FORMAT) | |||
| ) : null} | |||
| </TableCell> | |||
| {/* Assigned To - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| <Typography variant="body2"> | |||
| {getUserName(pickOrder.assignTo)} | |||
| </Typography> | |||
| ) : null} | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <TablePagination | |||
| component="div" | |||
| count={totalCountItems || 0} | |||
| page={(pagingController.pageNum - 1)} | |||
| rowsPerPage={pagingController.pageSize} | |||
| onPageChange={handlePageChange} | |||
| onRowsPerPageChange={handlePageSizeChange} | |||
| rowsPerPageOptions={[10, 25, 50, 100]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| labelDisplayedRows={({ from, to, count }) => | |||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||
| } | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| return ( | |||
| <> | |||
| <SearchBox criteria={searchCriteria} onSearch={handleSearch} onReset={handleReset} /> | |||
| <Grid container rowGap={1}> | |||
| <Grid item xs={12}> | |||
| {isLoadingItems ? ( | |||
| <CircularProgress size={40} /> | |||
| ) : ( | |||
| <CustomPickOrderTable /> | |||
| )} | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Box sx={{ display: "flex", justifyContent: "flex-start", mt: 2 }}> | |||
| <Button | |||
| disabled={selectedPickOrderIds.length < 1} | |||
| variant="outlined" | |||
| onClick={handleRelease} | |||
| > | |||
| {t("Release")} | |||
| </Button> | |||
| </Box> | |||
| </Grid> | |||
| </Grid> | |||
| </> | |||
| ); | |||
| }; | |||
| export default AssignTo; | |||