| @@ -354,8 +354,34 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||
| const [lastProcessedQr, setLastProcessedQr] = useState<string>(''); | |||
| const [isRefreshingData, setIsRefreshingData] = useState<boolean>(false); | |||
| const [currentPickOrderId, setCurrentPickOrderId] = useState<number | null>(null); | |||
| const getItemKey = useCallback((lot: any) => { | |||
| return `${lot.pickOrderId}-${lot.pickOrderLineId}-${lot.itemId}`; | |||
| }, []); | |||
| // 添加:unassign 函数 | |||
| const itemMatchSummaryMap = useMemo(() => { | |||
| return combinedLotData.reduce< | |||
| Record<string, { hasCompleted: boolean; completedMatchQty: number }> | |||
| >((acc, lot) => { | |||
| const key = getItemKey(lot); | |||
| const matchStatus = String(lot.matchStatus || "").toLowerCase(); | |||
| const isCompleted = matchStatus === "completed"; | |||
| // 來源優先:matchQty -> match_qty -> 0 | |||
| const qty = Number(lot.matchQty ?? lot.match_qty ?? 0); | |||
| if (!acc[key]) { | |||
| acc[key] = { hasCompleted: false, completedMatchQty: 0 }; | |||
| } | |||
| if (isCompleted) { | |||
| acc[key].hasCompleted = true; | |||
| // 同 item 若有 completed,取最大的 matchQty(避免被 0 覆蓋) | |||
| acc[key].completedMatchQty = Math.max(acc[key].completedMatchQty, qty); | |||
| } | |||
| return acc; | |||
| }, {}); | |||
| }, [combinedLotData, getItemKey]); | |||
| const handleUnassign = useCallback(async (pickOrderId: number | null) => { | |||
| if (!pickOrderId || !currentUserId) { | |||
| console.log("No pickOrderId or userId to unassign"); | |||
| @@ -482,7 +508,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||
| stockOutLineId: lot.stockOutLineId, | |||
| stockOutLineStatus: lot.stockOutLineStatus, | |||
| stockOutLineQty: lot.stockOutLineQty, | |||
| matchQty: lot.matchQty ?? lot.match_qty ?? null, | |||
| // Router info | |||
| routerIndex: lot.routerIndex, | |||
| matchStatus: lot.matchStatus, | |||
| @@ -1015,7 +1041,14 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||
| pageSize: newPageSize, | |||
| }); | |||
| }, []); | |||
| const itemActualPickQtyMap = useMemo(() => { | |||
| return combinedLotData.reduce<Record<string, number>>((acc, lot) => { | |||
| const key = getItemKey(lot); // 一定要跟下方讀取同一套 | |||
| const qty = Number(lot.actualPickQty ?? 0); | |||
| acc[key] = (acc[key] ?? 0) + (Number.isFinite(qty) ? qty : 0); | |||
| return acc; | |||
| }, {}); | |||
| }, [combinedLotData, getItemKey]); | |||
| const paginatedData = useMemo(() => { | |||
| const sortedData = [...combinedLotData].sort((a, b) => { | |||
| const aIndex = a.routerIndex || 0; | |||
| @@ -1081,7 +1114,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||
| return t("Please finish QR code scan and pick order."); | |||
| } | |||
| }, [t]); | |||
| const [submitQtyFieldEnabledByLotKey, setSubmitQtyFieldEnabledByLotKey] =useState<Record<string, boolean>>({}); | |||
| return ( | |||
| <TestQrCodeProvider | |||
| lotData={combinedLotData} | |||
| @@ -1142,61 +1175,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||
| t("Confirm All") | |||
| )} | |||
| </Button> | |||
| {/* | |||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> | |||
| <Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}> | |||
| {!isManualScanning ? ( | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<QrCodeIcon />} | |||
| onClick={handleStartScan} | |||
| color="primary" | |||
| sx={{ minWidth: '120px' }} | |||
| > | |||
| {t("Start QR Scan")} | |||
| </Button> | |||
| ) : ( | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<QrCodeIcon />} | |||
| onClick={handleStopScan} | |||
| color="secondary" | |||
| sx={{ minWidth: '120px' }} | |||
| > | |||
| {t("Stop QR Scan")} | |||
| </Button> | |||
| )} | |||
| <Button | |||
| variant="contained" | |||
| color="success" | |||
| onClick={handleSubmitAllScanned} | |||
| disabled={scannedItemsCount === 0 || isSubmittingAll} | |||
| sx={{ minWidth: '160px' }} | |||
| > | |||
| {isSubmittingAll ? ( | |||
| <> | |||
| <CircularProgress size={16} sx={{ mr: 1 }} /> | |||
| {t("Submitting...")} | |||
| </> | |||
| ) : ( | |||
| `${t("Submit All Scanned")} (${scannedItemsCount})` | |||
| )} | |||
| </Button> | |||
| </Box> | |||
| </Box> | |||
| {qrScanError && !qrScanSuccess && ( | |||
| <Alert severity="error" sx={{ mb: 2 }}> | |||
| {t("QR code does not match any item in current orders.")} | |||
| </Alert> | |||
| )} | |||
| {qrScanSuccess && ( | |||
| <Alert severity="success" sx={{ mb: 2 }}> | |||
| {t("QR code verified.")} | |||
| </Alert> | |||
| )} | |||
| */} | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| @@ -1207,8 +1186,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell>{t("Lot No")}</TableCell> | |||
| <TableCell align="right">{t("Lot Required Pick Qty")}</TableCell> | |||
| {/* <TableCell align="center">{t("Scan Result")}</TableCell> */} | |||
| <TableCell align="right">{t("Actual Pick Qty")}</TableCell> | |||
| <TableCell align="center">{t("Scan Result")}</TableCell> | |||
| <TableCell align="center">{t("Submit Required Pick Qty")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| @@ -1222,108 +1201,174 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedData.map((lot, index) => ( | |||
| <TableRow | |||
| key={`${lot.pickOrderLineId}-${lot.lotId}`} | |||
| sx={{ | |||
| backgroundColor: lot.lotAvailability === 'rejected' ? 'grey.100' : 'inherit', | |||
| opacity: lot.lotAvailability === 'rejected' ? 0.6 : 1, | |||
| '& .MuiTableCell-root': { | |||
| color: lot.lotAvailability === 'rejected' ? 'text.disabled' : 'inherit' | |||
| } | |||
| }} | |||
| > | |||
| <TableCell> | |||
| <Typography variant="body2" fontWeight="bold"> | |||
| {index + 1} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {lot.routerRoute || '-'} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell>{lot.handler || '-'}</TableCell> | |||
| <TableCell>{lot.itemCode}</TableCell> | |||
| <TableCell>{lot.itemName+'('+lot.uomDesc+')'}</TableCell> | |||
| <TableCell> | |||
| <Box> | |||
| <Typography | |||
| sx={{ | |||
| color: lot.lotAvailability === 'rejected' ? 'text.disabled' : 'inherit', | |||
| opacity: lot.lotAvailability === 'rejected' ? 0.6 : 1 | |||
| }} | |||
| > | |||
| {lot.lotNo} | |||
| </Typography> | |||
| </Box> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {(() => { | |||
| const requiredQty = lot.requiredQty || 0; | |||
| return requiredQty.toLocaleString()+'('+lot.uomShortDesc+')'; | |||
| })()} | |||
| </TableCell> | |||
| paginatedData.map((lot, index) => { | |||
| const itemKey = getItemKey(lot); | |||
| const itemSummary = itemMatchSummaryMap[itemKey] ?? { | |||
| hasCompleted: false, | |||
| completedMatchQty: 0, | |||
| }; | |||
| const itemCompleted = itemSummary.hasCompleted; | |||
| const itemCompletedQty = itemSummary.completedMatchQty; | |||
| const isFirstRowOfItem = | |||
| index === 0 || getItemKey(paginatedData[index - 1]) !== itemKey; | |||
| const itemTotalActualPickQty = Number(itemActualPickQtyMap[itemKey] ?? 0); | |||
| <TableCell align="center"> | |||
| <Box sx={{ display: 'flex', justifyContent: 'center' }}> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Button | |||
| variant="contained" | |||
| onClick={async () => { | |||
| const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`; | |||
| const submitQty = lot.requiredQty || lot.pickOrderLineRequiredQty; | |||
| handlePickQtyChange(lotKey, submitQty); | |||
| // 先更新 matching 狀態(可選,依你後端流程) | |||
| await updateSecondQrScanStatus(lot.pickOrderId, lot.itemId, currentUserId || 0, submitQty); | |||
| // 再提交數量並 await refetch,表格會即時更新提料員 | |||
| await handleSubmitPickQtyWithQty(lot, submitQty); | |||
| }} | |||
| disabled={ | |||
| lot.matchStatus === 'completed' || | |||
| lot.matchStatus == 'scanned' || | |||
| lot.lotAvailability === 'expired' || | |||
| lot.lotAvailability === 'status_unavailable' || | |||
| lot.lotAvailability === 'rejected' | |||
| } | |||
| sx={{ | |||
| fontSize: '0.75rem', | |||
| py: 0.5, | |||
| minHeight: '28px', | |||
| minWidth: '70px' | |||
| }} | |||
| > | |||
| {t("Confirm")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={() => handlePickExecutionForm(lot)} | |||
| disabled={ | |||
| lot.matchStatus === 'completed' || | |||
| lot.matchStatus == 'scanned' || | |||
| lot.lotAvailability === 'expired' || | |||
| lot.lotAvailability === 'status_unavailable' || | |||
| lot.lotAvailability === 'rejected' | |||
| } | |||
| sx={{ | |||
| fontSize: '0.7rem', | |||
| py: 0.5, | |||
| minHeight: '28px', | |||
| minWidth: '60px', | |||
| borderColor: 'warning.main', | |||
| color: 'warning.main' | |||
| const status = String(lot.stockOutLineStatus || "").toLowerCase(); | |||
| const matchStatus = String(lot.matchStatus || "").toLowerCase(); | |||
| const completedMatchQty = Number(lot.matchQty ?? 0); | |||
| const isSubmitted = lot.matchQty > 0; | |||
| const isUnavailable = | |||
| lot.lotAvailability === "expired" || | |||
| lot.lotAvailability === "status_unavailable" || | |||
| lot.lotAvailability === "rejected"; | |||
| const rowLocked = itemCompleted || isUnavailable; | |||
| return ( | |||
| <TableRow | |||
| key={`${lot.pickOrderLineId}-${lot.lotId}`} | |||
| sx={{ | |||
| backgroundColor: lot.lotAvailability === "rejected" ? "grey.100" : "inherit", | |||
| opacity: lot.lotAvailability === "rejected" ? 0.6 : 1, | |||
| "& .MuiTableCell-root": { | |||
| color: lot.lotAvailability === "rejected" ? "text.disabled" : "inherit", | |||
| }, | |||
| }} | |||
| > | |||
| <TableCell> | |||
| <Typography variant="body2" fontWeight="bold"> | |||
| {index + 1} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2">{lot.routerRoute || "-"}</Typography> | |||
| </TableCell> | |||
| <TableCell>{lot.handler || "-"}</TableCell> | |||
| <TableCell>{lot.itemCode}</TableCell> | |||
| <TableCell>{`${lot.itemName}(${lot.uomDesc})`}</TableCell> | |||
| <TableCell> | |||
| <Box> | |||
| <Typography | |||
| sx={{ | |||
| color: lot.lotAvailability === "rejected" ? "text.disabled" : "inherit", | |||
| opacity: lot.lotAvailability === "rejected" ? 0.6 : 1, | |||
| }} | |||
| title="Report missing or bad items" | |||
| > | |||
| {t("Issue")} | |||
| </Button> | |||
| </Stack> | |||
| </Box> | |||
| {lot.lotNo} | |||
| </Typography> | |||
| </Box> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {`${itemTotalActualPickQty.toLocaleString()}(${lot.uomShortDesc || ""})`} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| {(() => { | |||
| const matchStatus = String(lot.matchStatus || "").toLowerCase(); | |||
| const isRejected = lot.lotAvailability === "rejected"; | |||
| const isCompleted = matchStatus === "completed"; | |||
| if (isRejected) { | |||
| return <Checkbox checked disabled readOnly size="small" sx={{ color: "error.main", "&.Mui-checked": { color: "error.main" } }} />; | |||
| } | |||
| if (isCompleted) { | |||
| return <Checkbox checked disabled readOnly size="small" sx={{ color: "success.main", "&.Mui-checked": { color: "success.main" } }} />; | |||
| } | |||
| return <Checkbox checked={false} disabled readOnly size="small" />; | |||
| })()} | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| <TableCell align="center"> | |||
| <Box sx={{ display: "flex", justifyContent: "center" }}> | |||
| {!isFirstRowOfItem ? ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| - | |||
| </Typography> | |||
| ) : ( | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| disabled={rowLocked || !submitQtyFieldEnabledByLotKey[itemKey]} | |||
| value={String( | |||
| matchStatus === "completed" | |||
| ? completedMatchQty | |||
| : Object.prototype.hasOwnProperty.call(pickQtyData, itemKey) | |||
| ? pickQtyData[itemKey] | |||
| : itemTotalActualPickQty | |||
| )} | |||
| onChange={(e) => handlePickQtyChange(itemKey, e.target.value)} | |||
| inputProps={{ min: 0, step: 1 }} | |||
| sx={{ | |||
| width: 96, | |||
| "& .MuiInputBase-input": { | |||
| fontSize: "0.75rem", | |||
| py: 0.5, | |||
| textAlign: "center", | |||
| }, | |||
| }} | |||
| /> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={() => | |||
| setSubmitQtyFieldEnabledByLotKey((prev) => ({ | |||
| ...prev, | |||
| [itemKey]: !(prev[itemKey] === true), | |||
| })) | |||
| } | |||
| disabled={rowLocked} | |||
| sx={{ | |||
| fontSize: "0.7rem", | |||
| py: 0.5, | |||
| minHeight: "28px", | |||
| minWidth: "60px", | |||
| borderColor: "warning.main", | |||
| color: "warning.main", | |||
| }} | |||
| > | |||
| {t("Edit")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| onClick={async () => { | |||
| const submitQty = Number( | |||
| Object.prototype.hasOwnProperty.call(pickQtyData, itemKey) | |||
| ? pickQtyData[itemKey] | |||
| : itemTotalActualPickQty | |||
| ); | |||
| await updateSecondQrScanStatus( | |||
| lot.pickOrderId, | |||
| lot.itemId, | |||
| currentUserId || 0, | |||
| submitQty | |||
| ); | |||
| await handleSubmitPickQtyWithQty(lot, submitQty); | |||
| }} | |||
| disabled={rowLocked} | |||
| sx={{ | |||
| fontSize: "0.7rem", | |||
| py: 0.5, | |||
| minHeight: "28px", | |||
| minWidth: "70px", | |||
| }} | |||
| > | |||
| {t("Submit")} | |||
| </Button> | |||
| </Stack> | |||
| )} | |||
| </Box> | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| }) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||