| @@ -354,8 +354,34 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||||
| const [lastProcessedQr, setLastProcessedQr] = useState<string>(''); | const [lastProcessedQr, setLastProcessedQr] = useState<string>(''); | ||||
| const [isRefreshingData, setIsRefreshingData] = useState<boolean>(false); | const [isRefreshingData, setIsRefreshingData] = useState<boolean>(false); | ||||
| const [currentPickOrderId, setCurrentPickOrderId] = useState<number | null>(null); | const [currentPickOrderId, setCurrentPickOrderId] = useState<number | null>(null); | ||||
| const getItemKey = useCallback((lot: any) => { | |||||
| return `${lot.pickOrderId}-${lot.pickOrderLineId}-${lot.itemId}`; | |||||
| }, []); | |||||
| // 添加:unassign 函数 | // 添加: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) => { | const handleUnassign = useCallback(async (pickOrderId: number | null) => { | ||||
| if (!pickOrderId || !currentUserId) { | if (!pickOrderId || !currentUserId) { | ||||
| console.log("No pickOrderId or userId to unassign"); | console.log("No pickOrderId or userId to unassign"); | ||||
| @@ -482,7 +508,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||||
| stockOutLineId: lot.stockOutLineId, | stockOutLineId: lot.stockOutLineId, | ||||
| stockOutLineStatus: lot.stockOutLineStatus, | stockOutLineStatus: lot.stockOutLineStatus, | ||||
| stockOutLineQty: lot.stockOutLineQty, | stockOutLineQty: lot.stockOutLineQty, | ||||
| matchQty: lot.matchQty ?? lot.match_qty ?? null, | |||||
| // Router info | // Router info | ||||
| routerIndex: lot.routerIndex, | routerIndex: lot.routerIndex, | ||||
| matchStatus: lot.matchStatus, | matchStatus: lot.matchStatus, | ||||
| @@ -1015,7 +1041,14 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||||
| pageSize: newPageSize, | 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 paginatedData = useMemo(() => { | ||||
| const sortedData = [...combinedLotData].sort((a, b) => { | const sortedData = [...combinedLotData].sort((a, b) => { | ||||
| const aIndex = a.routerIndex || 0; | 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."); | return t("Please finish QR code scan and pick order."); | ||||
| } | } | ||||
| }, [t]); | }, [t]); | ||||
| const [submitQtyFieldEnabledByLotKey, setSubmitQtyFieldEnabledByLotKey] =useState<Record<string, boolean>>({}); | |||||
| return ( | return ( | ||||
| <TestQrCodeProvider | <TestQrCodeProvider | ||||
| lotData={combinedLotData} | lotData={combinedLotData} | ||||
| @@ -1142,61 +1175,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||||
| t("Confirm All") | t("Confirm All") | ||||
| )} | )} | ||||
| </Button> | </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}> | <TableContainer component={Paper}> | ||||
| <Table> | <Table> | ||||
| <TableHead> | <TableHead> | ||||
| @@ -1207,8 +1186,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||||
| <TableCell>{t("Item Code")}</TableCell> | <TableCell>{t("Item Code")}</TableCell> | ||||
| <TableCell>{t("Item Name")}</TableCell> | <TableCell>{t("Item Name")}</TableCell> | ||||
| <TableCell>{t("Lot No")}</TableCell> | <TableCell>{t("Lot No")}</TableCell> | ||||
| <TableCell 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> | <TableCell align="center">{t("Submit Required Pick Qty")}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| @@ -1222,108 +1201,174 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </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> | </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> | </TableBody> | ||||
| </Table> | </Table> | ||||