@@ -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; |