| @@ -40,6 +40,7 @@ export interface InventoryLotDetailResponse { | |||||
| approverQty: number | null; | approverQty: number | null; | ||||
| approverBadQty: number | null; | approverBadQty: number | null; | ||||
| finalQty: number | null; | finalQty: number | null; | ||||
| bookQty: number | null; | |||||
| } | } | ||||
| export const getInventoryLotDetailsBySection = async ( | export const getInventoryLotDetailsBySection = async ( | ||||
| @@ -207,6 +208,7 @@ export interface BatchSaveApproverStockTakeRecordRequest { | |||||
| stockTakeId: number; | stockTakeId: number; | ||||
| stockTakeSection: string; | stockTakeSection: string; | ||||
| approverId: number; | approverId: number; | ||||
| variancePercentTolerance?: number | null; | |||||
| } | } | ||||
| export interface BatchSaveApproverStockTakeRecordResponse { | export interface BatchSaveApproverStockTakeRecordResponse { | ||||
| @@ -312,7 +314,10 @@ export const getInventoryLotDetailsBySectionNotMatch = async ( | |||||
| ); | ); | ||||
| return response; | return response; | ||||
| } | } | ||||
| export interface SearchStockTransactionResult { | |||||
| records: StockTransactionResponse[]; | |||||
| total: number; | |||||
| } | |||||
| export interface SearchStockTransactionRequest { | export interface SearchStockTransactionRequest { | ||||
| startDate: string | null; | startDate: string | null; | ||||
| endDate: string | null; | endDate: string | null; | ||||
| @@ -345,7 +350,6 @@ export interface StockTransactionListResponse { | |||||
| } | } | ||||
| export const searchStockTransactions = cache(async (request: SearchStockTransactionRequest) => { | export const searchStockTransactions = cache(async (request: SearchStockTransactionRequest) => { | ||||
| // 构建查询字符串 | |||||
| const params = new URLSearchParams(); | const params = new URLSearchParams(); | ||||
| if (request.itemCode) params.append("itemCode", request.itemCode); | if (request.itemCode) params.append("itemCode", request.itemCode); | ||||
| @@ -366,7 +370,10 @@ export const searchStockTransactions = cache(async (request: SearchStockTransact | |||||
| next: { tags: ["Stock Transaction List"] }, | next: { tags: ["Stock Transaction List"] }, | ||||
| } | } | ||||
| ); | ); | ||||
| // 确保返回正确的格式 | |||||
| return response?.records || []; | |||||
| // 回傳 records 與 total,供分頁正確顯示 | |||||
| return { | |||||
| records: response?.records || [], | |||||
| total: response?.total ?? 0, | |||||
| }; | |||||
| }); | }); | ||||
| @@ -68,6 +68,7 @@ interface CommonProps extends Omit<ModalProps, "children"> { | |||||
| interface Props extends CommonProps { | interface Props extends CommonProps { | ||||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | ||||
| } | } | ||||
| const QcStockInModal: React.FC<Props> = ({ | const QcStockInModal: React.FC<Props> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -94,6 +95,10 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| () => `qcStockInModal_selectedPrinterId_${session?.id ?? "guest"}`, | () => `qcStockInModal_selectedPrinterId_${session?.id ?? "guest"}`, | ||||
| [session?.id], | [session?.id], | ||||
| ); | ); | ||||
| const labelPrinterCombo = useMemo( | |||||
| () => (printerCombo || []).filter((p) => p.type === "Label"), | |||||
| [printerCombo], | |||||
| ); | |||||
| const getDefaultPrinter = useMemo(() => { | const getDefaultPrinter = useMemo(() => { | ||||
| if (!printerCombo.length) return undefined; | if (!printerCombo.length) return undefined; | ||||
| if (typeof window === "undefined") return printerCombo[0]; | if (typeof window === "undefined") return printerCombo[0]; | ||||
| @@ -102,7 +107,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| const matched = savedId ? printerCombo.find(p => p.id === Number(savedId)) : undefined; | const matched = savedId ? printerCombo.find(p => p.id === Number(savedId)) : undefined; | ||||
| return matched ?? printerCombo[0]; | return matched ?? printerCombo[0]; | ||||
| }, [printerCombo, printerStorageKey]); | }, [printerCombo, printerStorageKey]); | ||||
| const [selectedPrinter, setSelectedPrinter] = useState(printerCombo[0]); | |||||
| const [selectedPrinter, setSelectedPrinter] = useState(labelPrinterCombo[0]); | |||||
| const [printQty, setPrintQty] = useState(1); | const [printQty, setPrintQty] = useState(1); | ||||
| const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
| @@ -504,6 +509,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| // Put away model | // Put away model | ||||
| const [pafRowModesModel, setPafRowModesModel] = useState<GridRowModesModel>({}) | const [pafRowModesModel, setPafRowModesModel] = useState<GridRowModesModel>({}) | ||||
| const [pafRowSelectionModel, setPafRowSelectionModel] = useState<GridRowSelectionModel>([]) | const [pafRowSelectionModel, setPafRowSelectionModel] = useState<GridRowSelectionModel>([]) | ||||
| const pafSubmitDisable = useMemo(() => { | const pafSubmitDisable = useMemo(() => { | ||||
| return Object.entries(pafRowModesModel).length > 0 || Object.entries(pafRowModesModel).some(([key, value], index) => value.mode === GridRowModes.Edit) | return Object.entries(pafRowModesModel).length > 0 || Object.entries(pafRowModesModel).some(([key, value], index) => value.mode === GridRowModes.Edit) | ||||
| }, [pafRowModesModel]) | }, [pafRowModesModel]) | ||||
| @@ -749,21 +755,25 @@ const printQrcode = useCallback( | |||||
| {tabIndex == 1 && ( | {tabIndex == 1 && ( | ||||
| <Stack direction="row" justifyContent="flex-end" gap={1} sx={{m:3, mt:"auto"}}> | <Stack direction="row" justifyContent="flex-end" gap={1} sx={{m:3, mt:"auto"}}> | ||||
| <Autocomplete | <Autocomplete | ||||
| disableClearable | |||||
| options={printerCombo} | |||||
| defaultValue={selectedPrinter} | |||||
| onChange={(event, value) => { | |||||
| setSelectedPrinter(value) | |||||
| }} | |||||
| renderInput={(params) => ( | |||||
| <TextField | |||||
| {...params} | |||||
| variant="outlined" | |||||
| label={t("Printer")} | |||||
| sx={{ width: 300}} | |||||
| /> | |||||
| )} | |||||
| /> | |||||
| disableClearable | |||||
| options={labelPrinterCombo} | |||||
| getOptionLabel={(option) => | |||||
| option.name || option.label || option.code || `Printer ${option.id}` | |||||
| } | |||||
| value={selectedPrinter} | |||||
| onChange={(_, newValue) => { | |||||
| if (newValue) setSelectedPrinter(newValue); | |||||
| }} | |||||
| renderInput={(params) => ( | |||||
| <TextField | |||||
| {...params} | |||||
| variant="outlined" | |||||
| label={t("Printer")} | |||||
| sx={{ width: 300 }} | |||||
| inputProps={{ ...params.inputProps, readOnly: true }} | |||||
| /> | |||||
| )} | |||||
| /> | |||||
| <TextField | <TextField | ||||
| variant="outlined" | variant="outlined" | ||||
| label={t("Print Qty")} | label={t("Print Qty")} | ||||
| @@ -134,7 +134,7 @@ const SearchPage: React.FC<Props> = ({ dataList: initialDataList }) => { | |||||
| // 当 processedData 变化时更新 filteredList(不更新 pagingController,避免循环) | // 当 processedData 变化时更新 filteredList(不更新 pagingController,避免循环) | ||||
| useEffect(() => { | useEffect(() => { | ||||
| setFilteredList(processedData); | setFilteredList(processedData); | ||||
| setTotalCount(processedData.length); | |||||
| // 只在初始加载时设置 pageSize | // 只在初始加载时设置 pageSize | ||||
| if (isInitialMount.current && processedData.length > 0) { | if (isInitialMount.current && processedData.length > 0) { | ||||
| setPageSize("all"); | setPageSize("all"); | ||||
| @@ -146,55 +146,53 @@ const SearchPage: React.FC<Props> = ({ dataList: initialDataList }) => { | |||||
| // API 调用函数(参考 PoSearch 的实现) | // API 调用函数(参考 PoSearch 的实现) | ||||
| // API 调用函数(参考 PoSearch 的实现) | // API 调用函数(参考 PoSearch 的实现) | ||||
| const newPageFetch = useCallback( | |||||
| async ( | |||||
| pagingController: Record<string, number>, | |||||
| filterArgs: Record<string, any>, | |||||
| ) => { | |||||
| setLoading(true); | |||||
| try { | |||||
| // 处理空字符串,转换为 null | |||||
| const itemCode = filterArgs.itemCode?.trim() || null; | |||||
| const itemName = filterArgs.itemName?.trim() || null; | |||||
| // 验证:至少需要 itemCode 或 itemName | |||||
| if (!itemCode && !itemName) { | |||||
| console.warn("Search requires at least itemCode or itemName"); | |||||
| const newPageFetch = useCallback( | |||||
| async ( | |||||
| pagingController: Record<string, number>, | |||||
| filterArgs: Record<string, any>, | |||||
| ) => { | |||||
| setLoading(true); | |||||
| try { | |||||
| const itemCode = filterArgs.itemCode?.trim() || null; | |||||
| const itemName = filterArgs.itemName?.trim() || null; | |||||
| if (!itemCode && !itemName) { | |||||
| console.warn("Search requires at least itemCode or itemName"); | |||||
| setDataList([]); | |||||
| setTotalCount(0); | |||||
| return; | |||||
| } | |||||
| const params: SearchStockTransactionRequest = { | |||||
| itemCode: itemCode, | |||||
| itemName: itemName, | |||||
| type: filterArgs.type?.trim() || null, | |||||
| startDate: filterArgs.startDate || null, | |||||
| endDate: filterArgs.endDate || null, | |||||
| pageNum: pagingController.pageNum - 1 || 0, | |||||
| pageSize: pagingController.pageSize || 100, | |||||
| }; | |||||
| const res = await searchStockTransactions(params); | |||||
| if (res && typeof res === 'object' && Array.isArray(res.records)) { | |||||
| setDataList(res.records); | |||||
| setTotalCount(res.total ?? res.records.length); | |||||
| } else { | |||||
| console.error("Invalid response format:", res); | |||||
| setDataList([]); | |||||
| setTotalCount(0); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("Fetch error:", error); | |||||
| setDataList([]); | setDataList([]); | ||||
| setTotalCount(0); | setTotalCount(0); | ||||
| return; | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | } | ||||
| const params: SearchStockTransactionRequest = { | |||||
| itemCode: itemCode, | |||||
| itemName: itemName, | |||||
| type: filterArgs.type?.trim() || null, | |||||
| startDate: filterArgs.startDate || null, | |||||
| endDate: filterArgs.endDate || null, | |||||
| pageNum: pagingController.pageNum - 1 || 0, | |||||
| pageSize: pagingController.pageSize || 100, | |||||
| }; | |||||
| console.log("Search params:", params); // 添加调试日志 | |||||
| const res = await searchStockTransactions(params); | |||||
| console.log("Search response:", res); // 添加调试日志 | |||||
| if (res && Array.isArray(res)) { | |||||
| setDataList(res); | |||||
| } else { | |||||
| console.error("Invalid response format:", res); | |||||
| setDataList([]); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("Fetch error:", error); | |||||
| setDataList([]); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| // 使用 useRef 来存储上一次的值,避免不必要的 API 调用 | // 使用 useRef 来存储上一次的值,避免不必要的 API 调用 | ||||
| const prevPagingControllerRef = useRef(pagingController); | const prevPagingControllerRef = useRef(pagingController); | ||||
| @@ -240,13 +238,13 @@ const newPageFetch = useCallback( | |||||
| const newSize = parseInt(event.target.value, 10); | const newSize = parseInt(event.target.value, 10); | ||||
| if (newSize === -1) { | if (newSize === -1) { | ||||
| setPageSize("all"); | setPageSize("all"); | ||||
| setPagingController(prev => ({ ...prev, pageSize: filteredList.length, pageNum: 1 })); | |||||
| setPagingController(prev => ({ ...prev, pageSize: 100, pageNum: 1 })); // 用 100 觸發後端回傳全部 | |||||
| } else if (!isNaN(newSize)) { | } else if (!isNaN(newSize)) { | ||||
| setPageSize(newSize); | setPageSize(newSize); | ||||
| setPagingController(prev => ({ ...prev, pageSize: newSize, pageNum: 1 })); | setPagingController(prev => ({ ...prev, pageSize: newSize, pageNum: 1 })); | ||||
| } | } | ||||
| setPage(0); | setPage(0); | ||||
| }, [filteredList.length]); | |||||
| }, []); | |||||
| const searchCriteria: Criterion<string>[] = useMemo( | const searchCriteria: Criterion<string>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| @@ -390,29 +388,25 @@ const newPageFetch = useCallback( | |||||
| setPagingController(prev => ({ ...prev, pageNum: 1 })); | setPagingController(prev => ({ ...prev, pageNum: 1 })); | ||||
| }, []); | }, []); | ||||
| // 计算实际显示的 items(分页) | |||||
| const paginatedItems = useMemo(() => { | const paginatedItems = useMemo(() => { | ||||
| if (pageSize === "all") { | if (pageSize === "all") { | ||||
| return filteredList; | return filteredList; | ||||
| } | } | ||||
| const actualPageSize = typeof pageSize === 'number' ? pageSize : 10; | |||||
| const startIndex = page * actualPageSize; | |||||
| const endIndex = startIndex + actualPageSize; | |||||
| return filteredList.slice(startIndex, endIndex); | |||||
| }, [filteredList, page, pageSize]); | |||||
| return filteredList; | |||||
| }, [filteredList, pageSize]); | |||||
| // 计算传递给 SearchResults 的 pageSize(确保在选项中) | // 计算传递给 SearchResults 的 pageSize(确保在选项中) | ||||
| const actualPageSizeForTable = useMemo(() => { | const actualPageSizeForTable = useMemo(() => { | ||||
| if (pageSize === "all") { | if (pageSize === "all") { | ||||
| return filteredList.length; | |||||
| return totalCount > 0 ? totalCount : filteredList.length; | |||||
| } | } | ||||
| const size = typeof pageSize === 'number' ? pageSize : 10; | const size = typeof pageSize === 'number' ? pageSize : 10; | ||||
| // 如果 size 不在标准选项中,使用 "all" 模式 | |||||
| if (![10, 25, 100].includes(size)) { | if (![10, 25, 100].includes(size)) { | ||||
| return filteredList.length; | |||||
| return size; | |||||
| } | } | ||||
| return size; | return size; | ||||
| }, [pageSize, filteredList.length]); | |||||
| }, [pageSize, filteredList.length, totalCount]); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -56,8 +56,8 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| const [loadingDetails, setLoadingDetails] = useState(false); | const [loadingDetails, setLoadingDetails] = useState(false); | ||||
| const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(false); | |||||
| const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(true); | |||||
| const [variancePercentTolerance, setVariancePercentTolerance] = useState<string>("5"); | |||||
| // 每个记录的选择状态,key 为 detail.id | // 每个记录的选择状态,key 为 detail.id | ||||
| const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | ||||
| const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | ||||
| @@ -71,7 +71,17 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | ||||
| const isWithinVarianceTolerance = useCallback(( | |||||
| difference: number, | |||||
| bookQty: number, | |||||
| percentStr: string | |||||
| ): boolean => { | |||||
| const percent = parseFloat(percentStr || "0"); | |||||
| if (isNaN(percent) || percent < 0) return true; // 无效输入时视为全部通过 | |||||
| if (bookQty === 0) return difference === 0; | |||||
| const threshold = Math.abs(bookQty) * (percent / 100); | |||||
| return Math.abs(difference) <= threshold; | |||||
| }, []); | |||||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | const handleChangePage = useCallback((event: unknown, newPage: number) => { | ||||
| setPage(newPage); | setPage(newPage); | ||||
| }, []); | }, []); | ||||
| @@ -133,7 +143,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0")) || 0; | selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0")) || 0; | ||||
| } | } | ||||
| const bookQty = detail.availableQty || 0; | |||||
| const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); | |||||
| return selectedQty - bookQty; | return selectedQty - bookQty; | ||||
| }, [approverQty, approverBadQty]); | }, [approverQty, approverBadQty]); | ||||
| @@ -159,16 +169,29 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| // 4. 添加过滤逻辑(在渲染表格之前) | // 4. 添加过滤逻辑(在渲染表格之前) | ||||
| const filteredDetails = useMemo(() => { | const filteredDetails = useMemo(() => { | ||||
| if (!showOnlyWithDifference) { | |||||
| return inventoryLotDetails; | |||||
| let result = inventoryLotDetails; | |||||
| if (showOnlyWithDifference) { | |||||
| const percent = parseFloat(variancePercentTolerance || "0"); | |||||
| const thresholdPercent = isNaN(percent) || percent < 0 ? 0 : percent; | |||||
| result = result.filter(detail => { | |||||
| // 已完成項目一律顯示 | |||||
| if (detail.finalQty != null || detail.stockTakeRecordStatus === "completed") { | |||||
| return true; | |||||
| } | |||||
| const selection = qtySelection[detail.id] ?? | |||||
| (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 ? "second" : "first"); | |||||
| const difference = calculateDifference(detail, selection); | |||||
| const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); | |||||
| if (bookQty === 0) return difference !== 0; | |||||
| const threshold = Math.abs(bookQty) * (thresholdPercent / 100); | |||||
| return Math.abs(difference) > threshold; | |||||
| }); | |||||
| } | } | ||||
| return inventoryLotDetails.filter(detail => { | |||||
| const selection = qtySelection[detail.id] || (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 ? "second" : "first"); | |||||
| const difference = calculateDifference(detail, selection); | |||||
| return difference !== 0; | |||||
| }); | |||||
| }, [inventoryLotDetails, showOnlyWithDifference, qtySelection, calculateDifference]); | |||||
| return result; | |||||
| }, [inventoryLotDetails, showOnlyWithDifference, variancePercentTolerance, qtySelection, calculateDifference]); | |||||
| const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| @@ -231,7 +254,22 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| onSnackbar(t("Approver stock take record saved successfully"), "success"); | onSnackbar(t("Approver stock take record saved successfully"), "success"); | ||||
| await loadDetails(page, pageSize); | |||||
| // 計算最終數量(合格數) | |||||
| const goodQty = finalQty - finalBadQty; | |||||
| setInventoryLotDetails((prev) => | |||||
| prev.map((d) => | |||||
| d.id === detail.id | |||||
| ? { | |||||
| ...d, | |||||
| finalQty: goodQty, | |||||
| approverQty: selection === "approver" ? finalQty : d.approverQty, | |||||
| approverBadQty: selection === "approver" ? finalBadQty : d.approverBadQty, | |||||
| stockTakeRecordStatus: "completed", | |||||
| } | |||||
| : d | |||||
| ) | |||||
| ); | |||||
| } catch (e: any) { | } catch (e: any) { | ||||
| console.error("Save approver stock take record error:", e); | console.error("Save approver stock take record error:", e); | ||||
| let errorMessage = t("Failed to save approver stock take record"); | let errorMessage = t("Failed to save approver stock take record"); | ||||
| @@ -264,6 +302,11 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId); | await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId); | ||||
| onSnackbar(t("Stock take record status updated to not match"), "success"); | onSnackbar(t("Stock take record status updated to not match"), "success"); | ||||
| setInventoryLotDetails((prev) => | |||||
| prev.map((d) => | |||||
| d.id === detail.id ? { ...d, stockTakeRecordStatus: "notMatch" } : d | |||||
| ) | |||||
| ); | |||||
| } catch (e: any) { | } catch (e: any) { | ||||
| console.error("Update stock take record status error:", e); | console.error("Update stock take record status error:", e); | ||||
| let errorMessage = t("Failed to update stock take record status"); | let errorMessage = t("Failed to update stock take record status"); | ||||
| @@ -284,17 +327,9 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| setUpdatingStatus(false); | setUpdatingStatus(false); | ||||
| // Reload after status update - the useEffect will handle it with current page/pageSize | // Reload after status update - the useEffect will handle it with current page/pageSize | ||||
| // Or explicitly reload: | // Or explicitly reload: | ||||
| setPage((currentPage) => { | |||||
| setPageSize((currentPageSize) => { | |||||
| setTimeout(() => { | |||||
| loadDetails(currentPage, currentPageSize); | |||||
| }, 0); | |||||
| return currentPageSize; | |||||
| }); | |||||
| return currentPage; | |||||
| }); | |||||
| } | } | ||||
| }, [selectedSession, t, onSnackbar, loadDetails]); | |||||
| }, [selectedSession, t, onSnackbar, ]); | |||||
| const handleBatchSubmitAll = useCallback(async () => { | const handleBatchSubmitAll = useCallback(async () => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| @@ -309,6 +344,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| stockTakeId: selectedSession.stockTakeId, | stockTakeId: selectedSession.stockTakeId, | ||||
| stockTakeSection: selectedSession.stockTakeSession, | stockTakeSection: selectedSession.stockTakeSession, | ||||
| approverId: currentUserId, | approverId: currentUserId, | ||||
| variancePercentTolerance: parseFloat(variancePercentTolerance || "0") || undefined, | |||||
| }; | }; | ||||
| const result = await batchSaveApproverStockTakeRecords(request); | const result = await batchSaveApproverStockTakeRecords(request); | ||||
| @@ -349,10 +385,10 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| }, [handleBatchSubmitAll]); | }, [handleBatchSubmitAll]); | ||||
| const formatNumber = (num: number | null | undefined): string => { | const formatNumber = (num: number | null | undefined): string => { | ||||
| if (num == null) return "0.00"; | |||||
| if (num == null) return "0"; | |||||
| return num.toLocaleString('en-US', { | return num.toLocaleString('en-US', { | ||||
| minimumFractionDigits: 2, | |||||
| maximumFractionDigits: 2 | |||||
| minimumFractionDigits: 0, | |||||
| maximumFractionDigits: 0 | |||||
| }); | }); | ||||
| }; | }; | ||||
| @@ -411,25 +447,30 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" spacing={2} alignItems="center"> | <Stack direction="row" spacing={2} alignItems="center"> | ||||
| <Button | |||||
| variant={showOnlyWithDifference ? "contained" : "outlined"} | |||||
| color="primary" | |||||
| onClick={() => setShowOnlyWithDifference(!showOnlyWithDifference)} | |||||
| startIcon={ | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={variancePercentTolerance} | |||||
| onChange={(e) => setVariancePercentTolerance(e.target.value)} | |||||
| label={t("Variance %")} | |||||
| sx={{ width: 100 }} | |||||
| inputProps={{ min: 0, max: 100, step: 0.1 }} | |||||
| /> | |||||
| {/* | |||||
| <FormControlLabel | |||||
| control={ | |||||
| <Checkbox | <Checkbox | ||||
| checked={showOnlyWithDifference} | checked={showOnlyWithDifference} | ||||
| onChange={(e) => setShowOnlyWithDifference(e.target.checked)} | onChange={(e) => setShowOnlyWithDifference(e.target.checked)} | ||||
| sx={{ p: 0, pointerEvents: 'none' }} | |||||
| /> | /> | ||||
| } | } | ||||
| sx={{ textTransform: 'none' }} | |||||
| > | |||||
| {t("Only Variance")} | |||||
| label={t("Only Variance")} | |||||
| /> | |||||
| */} | |||||
| <Button variant="contained" color="primary" onClick={handleBatchSubmitAll} disabled={batchSaving}> | |||||
| {t("Batch Save All")} | |||||
| </Button> | </Button> | ||||
| <Button variant="contained" color="primary" onClick={handleBatchSubmitAll} disabled={batchSaving}> | |||||
| {t("Batch Save All")} | |||||
| </Button> | |||||
| </Stack> | |||||
| </Stack> | |||||
| </Stack> | </Stack> | ||||
| {loadingDetails ? ( | {loadingDetails ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | ||||
| @@ -454,9 +495,10 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell>{t("Warehouse Location")}</TableCell> | <TableCell>{t("Warehouse Location")}</TableCell> | ||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | ||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | ||||
| <TableCell>{t("Remark")}</TableCell> | <TableCell>{t("Remark")}</TableCell> | ||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Record Status")}</TableCell> | <TableCell>{t("Record Status")}</TableCell> | ||||
| <TableCell>{t("Action")}</TableCell> | <TableCell>{t("Action")}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -492,25 +534,27 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | ||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| <TableCell sx={{ minWidth: 300 }}> | <TableCell sx={{ minWidth: 300 }}> | ||||
| {detail.finalQty != null ? ( | |||||
| <Stack spacing={0.5}> | |||||
| {(() => { | |||||
| const finalDifference = (detail.finalQty || 0) - (detail.availableQty || 0); | |||||
| const differenceColor = finalDifference > 0 | |||||
| ? 'error.main' | |||||
| : finalDifference < 0 | |||||
| ? 'error.main' | |||||
| : 'success.main'; | |||||
| return ( | |||||
| <Typography variant="body2" sx={{ fontWeight: 'bold', color: differenceColor }}> | |||||
| {t("Difference")}: {formatNumber(detail.finalQty)} - {formatNumber(detail.availableQty)} = {formatNumber(finalDifference)} | |||||
| </Typography> | |||||
| ); | |||||
| })()} | |||||
| </Stack> | |||||
| {detail.finalQty != null ? ( | |||||
| <Stack spacing={0.5}> | |||||
| {(() => { | |||||
| // 若有 bookQty(盤點當時帳面),用它來算差異;否則用 availableQty | |||||
| const bookQtyToUse = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); | |||||
| const finalDifference = (detail.finalQty || 0) - bookQtyToUse; | |||||
| const differenceColor = detail.stockTakeRecordStatus === "completed" | |||||
| ? 'text.secondary' | |||||
| : finalDifference !== 0 | |||||
| ? 'error.main' | |||||
| : 'success.main'; | |||||
| return ( | |||||
| <Typography variant="body2" sx={{ fontWeight: 'bold', color: differenceColor }}> | |||||
| {t("Difference")}: {formatNumber(detail.finalQty)} - {formatNumber(bookQtyToUse)} = {formatNumber(finalDifference)} | |||||
| </Typography> | |||||
| ); | |||||
| })()} | |||||
| </Stack> | |||||
| ) : ( | ) : ( | ||||
| <Stack spacing={1}> | <Stack spacing={1}> | ||||
| {hasFirst && ( | {hasFirst && ( | ||||
| @@ -581,7 +625,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| disabled={selection !== "approver"} | disabled={selection !== "approver"} | ||||
| /> | /> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| ={(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} | |||||
| = {formatNumber(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} | |||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| )} | )} | ||||
| @@ -597,12 +641,12 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))|| 0; | selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))|| 0; | ||||
| } | } | ||||
| const bookQty = detail.availableQty || 0; | |||||
| const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); | |||||
| const difference = selectedQty - bookQty; | const difference = selectedQty - bookQty; | ||||
| const differenceColor = difference > 0 | |||||
| ? 'error.main' | |||||
| : difference < 0 | |||||
| ? 'error.main' | |||||
| const differenceColor = detail.stockTakeRecordStatus === "completed" | |||||
| ? 'text.secondary' | |||||
| : difference !== 0 | |||||
| ? 'error.main' | |||||
| : 'success.main'; | : 'success.main'; | ||||
| return ( | return ( | ||||
| @@ -621,11 +665,13 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| <TableCell> | <TableCell> | ||||
| {detail.stockTakeRecordStatus === "pass" ? ( | |||||
| {detail.stockTakeRecordStatus === "completed" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" /> | <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" /> | ||||
| ) : detail.stockTakeRecordStatus === "pass" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="default" /> | |||||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | ) : detail.stockTakeRecordStatus === "notMatch" ? ( | ||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" /> | <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" /> | ||||
| ) : ( | ) : ( | ||||
| @@ -21,7 +21,6 @@ import { useState, useCallback, useEffect, useRef } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { | import { | ||||
| AllPickedStockTakeListReponse, | AllPickedStockTakeListReponse, | ||||
| getInventoryLotDetailsBySection, | |||||
| InventoryLotDetailResponse, | InventoryLotDetailResponse, | ||||
| saveStockTakeRecord, | saveStockTakeRecord, | ||||
| SaveStockTakeRecordRequest, | SaveStockTakeRecordRequest, | ||||
| @@ -51,13 +50,13 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| const [loadingDetails, setLoadingDetails] = useState(false); | const [loadingDetails, setLoadingDetails] = useState(false); | ||||
| // 编辑状态 | |||||
| const [editingRecord, setEditingRecord] = useState<InventoryLotDetailResponse | null>(null); | |||||
| const [firstQty, setFirstQty] = useState<string>(""); | |||||
| const [secondQty, setSecondQty] = useState<string>(""); | |||||
| const [firstBadQty, setFirstBadQty] = useState<string>(""); | |||||
| const [secondBadQty, setSecondBadQty] = useState<string>(""); | |||||
| const [remark, setRemark] = useState<string>(""); | |||||
| const [recordInputs, setRecordInputs] = useState<Record<number, { | |||||
| firstQty: string; | |||||
| secondQty: string; | |||||
| firstBadQty: string; | |||||
| secondBadQty: string; | |||||
| remark: string; | |||||
| }>>({}); | |||||
| const [saving, setSaving] = useState(false); | const [saving, setSaving] = useState(false); | ||||
| const [batchSaving, setBatchSaving] = useState(false); | const [batchSaving, setBatchSaving] = useState(false); | ||||
| const [shortcutInput, setShortcutInput] = useState<string>(""); | const [shortcutInput, setShortcutInput] = useState<string>(""); | ||||
| @@ -115,28 +114,36 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| } | } | ||||
| }, [selectedSession, total]); | }, [selectedSession, total]); | ||||
| useEffect(() => { | |||||
| const inputs: Record<number, { firstQty: string; secondQty: string; firstBadQty: string; secondBadQty: string; remark: string }> = {}; | |||||
| inventoryLotDetails.forEach((detail) => { | |||||
| const firstTotal = detail.firstStockTakeQty != null | |||||
| ? (detail.firstStockTakeQty + (detail.firstBadQty ?? 0)).toString() | |||||
| : ""; | |||||
| const secondTotal = detail.secondStockTakeQty != null | |||||
| ? (detail.secondStockTakeQty + (detail.secondBadQty ?? 0)).toString() | |||||
| : ""; | |||||
| inputs[detail.id] = { | |||||
| firstQty: firstTotal, | |||||
| secondQty: secondTotal, | |||||
| firstBadQty: detail.firstBadQty?.toString() || "", | |||||
| secondBadQty: detail.secondBadQty?.toString() || "", | |||||
| remark: detail.remarks || "", | |||||
| }; | |||||
| }); | |||||
| setRecordInputs(inputs); | |||||
| }, [inventoryLotDetails]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| loadDetails(page, pageSize); | loadDetails(page, pageSize); | ||||
| }, [page, pageSize, loadDetails]); | }, [page, pageSize, loadDetails]); | ||||
| const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | |||||
| setEditingRecord(detail); | |||||
| setFirstQty(detail.firstStockTakeQty?.toString() || ""); | |||||
| setSecondQty(detail.secondStockTakeQty?.toString() || ""); | |||||
| setFirstBadQty(detail.firstBadQty?.toString() || ""); | |||||
| setSecondBadQty(detail.secondBadQty?.toString() || ""); | |||||
| setRemark(detail.remarks || ""); | |||||
| }, []); | |||||
| const handleCancelEdit = useCallback(() => { | |||||
| setEditingRecord(null); | |||||
| setFirstQty(""); | |||||
| setSecondQty(""); | |||||
| setFirstBadQty(""); | |||||
| setSecondBadQty(""); | |||||
| setRemark(""); | |||||
| }, []); | |||||
| const formatNumber = (num: number | null | undefined): string => { | |||||
| if (num == null || Number.isNaN(num)) return "0"; | |||||
| return num.toLocaleString("en-US", { | |||||
| minimumFractionDigits: 0, | |||||
| maximumFractionDigits: 0, | |||||
| }); | |||||
| }; | |||||
| const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| return; | return; | ||||
| @@ -145,41 +152,69 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | ||||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | ||||
| const qty = isFirstSubmit ? firstQty : secondQty; | |||||
| const badQty = isFirstSubmit ? firstBadQty : secondBadQty; | |||||
| if (!qty || !badQty) { | |||||
| // 用戶輸入為 total 和 bad,需計算 available = total - bad(與 PickerStockTake 一致) | |||||
| const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | |||||
| const badQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstBadQty : recordInputs[detail.id]?.secondBadQty; | |||||
| if (!totalQtyStr) { | |||||
| onSnackbar( | onSnackbar( | ||||
| isFirstSubmit | isFirstSubmit | ||||
| ? t("Please enter QTY and Bad QTY") | |||||
| : t("Please enter Second QTY and Bad QTY"), | |||||
| ? t("Please enter QTY") | |||||
| : t("Please enter Second QTY"), | |||||
| "error" | "error" | ||||
| ); | ); | ||||
| return; | return; | ||||
| } | } | ||||
| const totalQty = parseFloat(totalQtyStr); | |||||
| const badQty = parseFloat(badQtyStr || "0") || 0; | |||||
| if (Number.isNaN(totalQty)) { | |||||
| onSnackbar(t("Invalid QTY"), "error"); | |||||
| return; | |||||
| } | |||||
| const availableQty = totalQty - badQty; | |||||
| if (availableQty < 0) { | |||||
| onSnackbar(t("Available QTY cannot be negative"), "error"); | |||||
| return; | |||||
| } | |||||
| setSaving(true); | setSaving(true); | ||||
| try { | try { | ||||
| const request: SaveStockTakeRecordRequest = { | const request: SaveStockTakeRecordRequest = { | ||||
| stockTakeRecordId: detail.stockTakeRecordId || null, | stockTakeRecordId: detail.stockTakeRecordId || null, | ||||
| inventoryLotLineId: detail.id, | inventoryLotLineId: detail.id, | ||||
| qty: parseFloat(qty), | |||||
| badQty: parseFloat(badQty), | |||||
| remark: isSecondSubmit ? (remark || null) : null, | |||||
| qty: availableQty, | |||||
| badQty: badQty, | |||||
| remark: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : null, | |||||
| }; | }; | ||||
| console.log('handleSaveStockTake: request:', request); | |||||
| console.log('handleSaveStockTake: selectedSession.stockTakeId:', selectedSession.stockTakeId); | |||||
| console.log('handleSaveStockTake: currentUserId:', currentUserId); | |||||
| await saveStockTakeRecord( | |||||
| const result = await saveStockTakeRecord( | |||||
| request, | request, | ||||
| selectedSession.stockTakeId, | selectedSession.stockTakeId, | ||||
| currentUserId | currentUserId | ||||
| ); | ); | ||||
| onSnackbar(t("Stock take record saved successfully"), "success"); | onSnackbar(t("Stock take record saved successfully"), "success"); | ||||
| handleCancelEdit(); | |||||
| await loadDetails(page, pageSize); | |||||
| const savedId = result?.id ?? detail.stockTakeRecordId; | |||||
| setInventoryLotDetails((prev) => | |||||
| prev.map((d) => | |||||
| d.id === detail.id | |||||
| ? { | |||||
| ...d, | |||||
| stockTakeRecordId: savedId ?? d.stockTakeRecordId, | |||||
| firstStockTakeQty: isFirstSubmit ? availableQty : d.firstStockTakeQty, | |||||
| firstBadQty: isFirstSubmit ? badQty : d.firstBadQty ?? null, | |||||
| secondStockTakeQty: isSecondSubmit ? availableQty : d.secondStockTakeQty, | |||||
| secondBadQty: isSecondSubmit ? badQty : d.secondBadQty ?? null, | |||||
| remarks: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : d.remarks, | |||||
| stockTakeRecordStatus: "pass", | |||||
| } | |||||
| : d | |||||
| ) | |||||
| ); | |||||
| } catch (e: any) { | } catch (e: any) { | ||||
| console.error("Save stock take record error:", e); | console.error("Save stock take record error:", e); | ||||
| let errorMessage = t("Failed to save stock take record"); | let errorMessage = t("Failed to save stock take record"); | ||||
| @@ -199,15 +234,13 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| } finally { | } finally { | ||||
| setSaving(false); | setSaving(false); | ||||
| } | } | ||||
| }, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||||
| }, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||||
| const handleBatchSubmitAll = useCallback(async () => { | const handleBatchSubmitAll = useCallback(async () => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId'); | |||||
| return; | return; | ||||
| } | } | ||||
| console.log('handleBatchSubmitAll: Starting batch save...'); | |||||
| setBatchSaving(true); | setBatchSaving(true); | ||||
| try { | try { | ||||
| const request: BatchSaveStockTakeRecordRequest = { | const request: BatchSaveStockTakeRecordRequest = { | ||||
| @@ -217,7 +250,6 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| }; | }; | ||||
| const result = await batchSaveStockTakeRecords(request); | const result = await batchSaveStockTakeRecords(request); | ||||
| console.log('handleBatchSubmitAll: Result:', result); | |||||
| onSnackbar( | onSnackbar( | ||||
| t("Batch save completed: {{success}} success, {{errors}} errors", { | t("Batch save completed: {{success}} success, {{errors}} errors", { | ||||
| @@ -273,31 +305,19 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| const newInput = prev + e.key; | const newInput = prev + e.key; | ||||
| if (newInput === '{2fitestall}') { | if (newInput === '{2fitestall}') { | ||||
| console.log('✅ Shortcut {2fitestall} detected!'); | |||||
| setTimeout(() => { | setTimeout(() => { | ||||
| if (handleBatchSubmitAllRef.current) { | if (handleBatchSubmitAllRef.current) { | ||||
| console.log('Calling handleBatchSubmitAll...'); | |||||
| handleBatchSubmitAllRef.current().catch(err => { | handleBatchSubmitAllRef.current().catch(err => { | ||||
| console.error('Error in handleBatchSubmitAll:', err); | console.error('Error in handleBatchSubmitAll:', err); | ||||
| }); | }); | ||||
| } else { | |||||
| console.error('handleBatchSubmitAllRef.current is null'); | |||||
| } | } | ||||
| }, 0); | }, 0); | ||||
| return ""; | return ""; | ||||
| } | } | ||||
| if (newInput.length > 15) { | |||||
| return ""; | |||||
| } | |||||
| if (newInput.length > 0 && !newInput.startsWith('{')) { | |||||
| return ""; | |||||
| } | |||||
| if (newInput.length > 5 && !newInput.startsWith('{2fi')) { | |||||
| return ""; | |||||
| } | |||||
| if (newInput.length > 15) return ""; | |||||
| if (newInput.length > 0 && !newInput.startsWith('{')) return ""; | |||||
| if (newInput.length > 5 && !newInput.startsWith('{2fi')) return ""; | |||||
| return newInput; | return newInput; | ||||
| }); | }); | ||||
| @@ -315,11 +335,15 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| }, []); | }, []); | ||||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | ||||
| if (detail.stockTakeRecordStatus === "pass") { | |||||
| if (selectedSession?.status?.toLowerCase() === "completed") { | |||||
| return true; | |||||
| } | |||||
| const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | |||||
| if (recordStatus === "pass" || recordStatus === "completed") { | |||||
| return true; | return true; | ||||
| } | } | ||||
| return false; | return false; | ||||
| }, []); | |||||
| }, [selectedSession?.status]); | |||||
| const uniqueWarehouses = Array.from( | const uniqueWarehouses = Array.from( | ||||
| new Set( | new Set( | ||||
| @@ -328,6 +352,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| .filter(warehouse => warehouse && warehouse.trim() !== "") | .filter(warehouse => warehouse && warehouse.trim() !== "") | ||||
| ) | ) | ||||
| ).join(", "); | ).join(", "); | ||||
| const defaultInputs = { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }; | |||||
| return ( | return ( | ||||
| <Box> | <Box> | ||||
| <Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}> | <Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}> | ||||
| @@ -339,42 +366,31 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| <> {t("Warehouse")}: {uniqueWarehouses}</> | <> {t("Warehouse")}: {uniqueWarehouses}</> | ||||
| )} | )} | ||||
| </Typography> | </Typography> | ||||
| {/* | |||||
| {shortcutInput && ( | |||||
| <Box sx={{ mb: 2, p: 1.5, bgcolor: 'info.light', borderRadius: 1, border: '1px solid', borderColor: 'info.main' }}> | |||||
| <Typography variant="body2" color="info.dark" fontWeight={500}> | |||||
| {t("Shortcut Input")}: <strong style={{ fontFamily: 'monospace', fontSize: '1.1em' }}>{shortcutInput}</strong> | |||||
| </Typography> | |||||
| </Box> | |||||
| )} | |||||
| */} | |||||
| {loadingDetails ? ( | {loadingDetails ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | ||||
| <CircularProgress /> | <CircularProgress /> | ||||
| </Box> | </Box> | ||||
| ) : ( | ) : ( | ||||
| <> | <> | ||||
| <TablePagination | |||||
| component="div" | |||||
| count={total} | |||||
| page={page} | |||||
| onPageChange={handleChangePage} | |||||
| rowsPerPage={pageSize === "all" ? total : (pageSize as number)} | |||||
| onRowsPerPageChange={handleChangeRowsPerPage} | |||||
| rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]} | |||||
| labelRowsPerPage={t("Rows per page")} | |||||
| /> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={total} | |||||
| page={page} | |||||
| onPageChange={handleChangePage} | |||||
| rowsPerPage={pageSize === "all" ? total : (pageSize as number)} | |||||
| onRowsPerPageChange={handleChangeRowsPerPage} | |||||
| rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]} | |||||
| labelRowsPerPage={t("Rows per page")} | |||||
| /> | |||||
| <TableContainer component={Paper}> | <TableContainer component={Paper}> | ||||
| <Table> | <Table> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | |||||
| <TableRow> | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | <TableCell>{t("Warehouse Location")}</TableCell> | ||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | ||||
| <TableCell>{t("Qty")}</TableCell> | |||||
| <TableCell>{t("Bad Qty")}</TableCell> | |||||
| <TableCell>{t("Remark")}</TableCell> | |||||
| <TableCell>{t("UOM")}</TableCell> | <TableCell>{t("UOM")}</TableCell> | ||||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||||
| <TableCell>{t("Remark")}</TableCell> | |||||
| <TableCell>{t("Record Status")}</TableCell> | <TableCell>{t("Record Status")}</TableCell> | ||||
| <TableCell>{t("Action")}</TableCell> | <TableCell>{t("Action")}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -382,7 +398,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| <TableBody> | <TableBody> | ||||
| {inventoryLotDetails.length === 0 ? ( | {inventoryLotDetails.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={8} align="center"> | |||||
| <TableCell colSpan={7} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("No data")} | {t("No data")} | ||||
| </Typography> | </Typography> | ||||
| @@ -390,99 +406,156 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| inventoryLotDetails.map((detail) => { | inventoryLotDetails.map((detail) => { | ||||
| const isEditing = editingRecord?.id === detail.id; | |||||
| const submitDisabled = isSubmitDisabled(detail); | const submitDisabled = isSubmitDisabled(detail); | ||||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | ||||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | ||||
| const inputs = recordInputs[detail.id] ?? defaultInputs; | |||||
| return ( | return ( | ||||
| <TableRow key={detail.id}> | <TableRow key={detail.id}> | ||||
| <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell> | <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell> | ||||
| <TableCell sx={{ | <TableCell sx={{ | ||||
| maxWidth: 150, | |||||
| wordBreak: 'break-word', | |||||
| whiteSpace: 'normal', | |||||
| lineHeight: 1.5 | |||||
| }}> | |||||
| maxWidth: 150, | |||||
| wordBreak: 'break-word', | |||||
| whiteSpace: 'normal', | |||||
| lineHeight: 1.5 | |||||
| }}> | |||||
| <Stack spacing={0.5}> | <Stack spacing={0.5}> | ||||
| <Box>{detail.itemCode || "-"} {detail.itemName || "-"}</Box> | <Box>{detail.itemCode || "-"} {detail.itemName || "-"}</Box> | ||||
| <Box>{detail.lotNo || "-"}</Box> | <Box>{detail.lotNo || "-"}</Box> | ||||
| <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | ||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | |||||
| <Stack spacing={0.5}> | |||||
| {isEditing && isFirstSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={firstQty} | |||||
| onChange={(e) => setFirstQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.firstStockTakeQty ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("First")}: {detail.firstStockTakeQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {isEditing && isSecondSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondQty} | |||||
| onChange={(e) => setSecondQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.secondStockTakeQty ? ( | |||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| <TableCell sx={{ minWidth: 300 }}> | |||||
| <Stack spacing={1}> | |||||
| {/* First */} | |||||
| {!submitDisabled && isFirstSubmit ? ( | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Typography variant="body2">{t("First")}:</Typography> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={inputs.firstQty} | |||||
| inputProps={{ min: 0, step: "any" }} | |||||
| onChange={(e) => { | |||||
| const val = e.target.value; | |||||
| if (val.includes("-")) return; | |||||
| setRecordInputs(prev => ({ | |||||
| ...prev, | |||||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), firstQty: val } | |||||
| })); | |||||
| }} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| "& .MuiInputBase-input": { | |||||
| height: "1.4375em", | |||||
| padding: "4px 8px", | |||||
| }, | |||||
| }} | |||||
| placeholder={t("Stock Take Qty")} | |||||
| /> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={inputs.firstBadQty} | |||||
| inputProps={{ min: 0, step: "any" }} | |||||
| onChange={(e) => { | |||||
| const val = e.target.value; | |||||
| if (val.includes("-")) return; | |||||
| setRecordInputs(prev => ({ | |||||
| ...prev, | |||||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), firstBadQty: val } | |||||
| })); | |||||
| }} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| "& .MuiInputBase-input": { | |||||
| height: "1.4375em", | |||||
| padding: "4px 8px", | |||||
| }, | |||||
| }} | |||||
| placeholder={t("Bad Qty")} | |||||
| /> | |||||
| <Typography variant="body2"> | |||||
| = {formatNumber(parseFloat(inputs.firstQty || "0") - parseFloat(inputs.firstBadQty || "0"))} | |||||
| </Typography> | |||||
| </Stack> | |||||
| ) : detail.firstStockTakeQty != null ? ( | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {t("Second")}: {detail.secondStockTakeQty.toFixed(2)} | |||||
| {t("First")}:{" "} | |||||
| {formatNumber((detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0))}{" "} | |||||
| ({formatNumber(detail.firstBadQty ?? 0)}) ={" "} | |||||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | |||||
| </Typography> | </Typography> | ||||
| ) : null} | ) : null} | ||||
| {!detail.firstStockTakeQty && !detail.secondStockTakeQty && !isEditing && ( | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| - | |||||
| </Typography> | |||||
| )} | |||||
| </Stack> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Stack spacing={0.5}> | |||||
| {isEditing && isFirstSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={firstBadQty} | |||||
| onChange={(e) => setFirstBadQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.firstBadQty != null && detail.firstBadQty > 0 ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("First")}: {detail.firstBadQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : ( | |||||
| <Typography variant="body2" sx={{ visibility: 'hidden' }}> | |||||
| {t("First")}: 0.00 | |||||
| </Typography> | |||||
| )} | |||||
| {isEditing && isSecondSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondBadQty} | |||||
| onChange={(e) => setSecondBadQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.secondBadQty != null && detail.secondBadQty > 0 ? ( | |||||
| {/* Second */} | |||||
| {!submitDisabled && isSecondSubmit ? ( | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Typography variant="body2">{t("Second")}:</Typography> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={inputs.secondQty} | |||||
| inputProps={{ min: 0, step: "any" }} | |||||
| onChange={(e) => { | |||||
| const val = e.target.value; | |||||
| if (val.includes("-")) return; | |||||
| setRecordInputs(prev => ({ | |||||
| ...prev, | |||||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), secondQty: val } | |||||
| })); | |||||
| }} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| "& .MuiInputBase-input": { | |||||
| height: "1.4375em", | |||||
| padding: "4px 8px", | |||||
| }, | |||||
| }} | |||||
| placeholder={t("Stock Take Qty")} | |||||
| /> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={inputs.secondBadQty} | |||||
| inputProps={{ min: 0, step: "any" }} | |||||
| onChange={(e) => { | |||||
| const val = e.target.value; | |||||
| if (val.includes("-")) return; | |||||
| setRecordInputs(prev => ({ | |||||
| ...prev, | |||||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), secondBadQty: val } | |||||
| })); | |||||
| }} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| "& .MuiInputBase-input": { | |||||
| height: "1.4375em", | |||||
| padding: "4px 8px", | |||||
| }, | |||||
| }} | |||||
| placeholder={t("Bad Qty")} | |||||
| /> | |||||
| <Typography variant="body2"> | |||||
| = {formatNumber(parseFloat(inputs.secondQty || "0") - parseFloat(inputs.secondBadQty || "0"))} | |||||
| </Typography> | |||||
| </Stack> | |||||
| ) : detail.secondStockTakeQty != null ? ( | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {t("Second")}: {detail.secondBadQty.toFixed(2)} | |||||
| {t("Second")}:{" "} | |||||
| {formatNumber((detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0))}{" "} | |||||
| ({formatNumber(detail.secondBadQty ?? 0)}) ={" "} | |||||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | |||||
| </Typography> | </Typography> | ||||
| ) : null} | ) : null} | ||||
| {!detail.firstBadQty && !detail.secondBadQty && !isEditing && ( | |||||
| {!detail.firstStockTakeQty && !detail.secondStockTakeQty && !submitDisabled && ( | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| - | - | ||||
| </Typography> | </Typography> | ||||
| @@ -490,13 +563,16 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell sx={{ width: 180 }}> | <TableCell sx={{ width: 180 }}> | ||||
| {isEditing && isSecondSubmit ? ( | |||||
| {!submitDisabled && isSecondSubmit ? ( | |||||
| <> | <> | ||||
| <Typography variant="body2">{t("Remark")}</Typography> | <Typography variant="body2">{t("Remark")}</Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| value={remark} | |||||
| onChange={(e) => setRemark(e.target.value)} | |||||
| value={inputs.remark} | |||||
| onChange={(e) => setRecordInputs(prev => ({ | |||||
| ...prev, | |||||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), remark: e.target.value } | |||||
| }))} | |||||
| sx={{ width: 150 }} | sx={{ width: 150 }} | ||||
| /> | /> | ||||
| </> | </> | ||||
| @@ -506,49 +582,30 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| <TableCell> | <TableCell> | ||||
| {detail.stockTakeRecordStatus === "pass" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" /> | |||||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" /> | |||||
| ) : ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus || "")} color="default" /> | |||||
| )} | |||||
| </TableCell> | |||||
| {detail.stockTakeRecordStatus === "completed" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" /> | |||||
| ) : detail.stockTakeRecordStatus === "pass" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="default" /> | |||||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" /> | |||||
| ) : ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus || "")} color="default" /> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | <TableCell> | ||||
| {isEditing ? ( | |||||
| <Stack direction="row" spacing={1}> | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => handleSaveStockTake(detail)} | |||||
| disabled={saving || submitDisabled} | |||||
| > | |||||
| {t("Save")} | |||||
| </Button> | |||||
| <Button | |||||
| size="small" | |||||
| onClick={handleCancelEdit} | |||||
| > | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| </Stack> | |||||
| ) : ( | |||||
| <Stack direction="row" spacing={1}> | |||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| variant="outlined" | |||||
| onClick={() => handleStartEdit(detail)} | |||||
| disabled={submitDisabled} | |||||
| variant="contained" | |||||
| onClick={() => handleSaveStockTake(detail)} | |||||
| disabled={saving || submitDisabled } | |||||
| > | > | ||||
| {!detail.stockTakeRecordId | |||||
| ? t("Input") | |||||
| : detail.stockTakeRecordStatus === "notMatch" | |||||
| ? t("Input") | |||||
| : t("View")} | |||||
| {t("Save")} | |||||
| </Button> | </Button> | ||||
| )} | |||||
| </Stack> | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ); | ); | ||||
| @@ -55,13 +55,14 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| const [loadingDetails, setLoadingDetails] = useState(false); | const [loadingDetails, setLoadingDetails] = useState(false); | ||||
| // 编辑状态 | |||||
| const [editingRecord, setEditingRecord] = useState<InventoryLotDetailResponse | null>(null); | |||||
| // firstQty / secondQty 保存的是 total = available + bad | |||||
| const [firstQty, setFirstQty] = useState<string>(""); | |||||
| const [secondQty, setSecondQty] = useState<string>(""); | |||||
| const [firstBadQty, setFirstBadQty] = useState<string>(""); | |||||
| const [secondBadQty, setSecondBadQty] = useState<string>(""); | |||||
| const [recordInputs, setRecordInputs] = useState<Record<number, { | |||||
| firstQty: string; | |||||
| secondQty: string; | |||||
| firstBadQty: string; | |||||
| secondBadQty: string; | |||||
| remark: string; | |||||
| }>>({}); | |||||
| const [savingRecordId, setSavingRecordId] = useState<number | null>(null); | |||||
| const [remark, setRemark] = useState<string>(""); | const [remark, setRemark] = useState<string>(""); | ||||
| const [saving, setSaving] = useState(false); | const [saving, setSaving] = useState(false); | ||||
| const [batchSaving, setBatchSaving] = useState(false); | const [batchSaving, setBatchSaving] = useState(false); | ||||
| @@ -91,7 +92,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| } | } | ||||
| setPage(0); | setPage(0); | ||||
| }, []); | }, []); | ||||
| const loadDetails = useCallback(async (pageNum: number, size: number | string) => { | |||||
| const loadDetails = useCallback(async ( | |||||
| pageNum: number, | |||||
| size: number | string, | |||||
| options?: { silent?: boolean } | |||||
| ) => { | |||||
| console.log('loadDetails called with:', { pageNum, size, selectedSessionTotal: selectedSession.totalInventoryLotNumber }); | console.log('loadDetails called with:', { pageNum, size, selectedSessionTotal: selectedSession.totalInventoryLotNumber }); | ||||
| setLoadingDetails(true); | setLoadingDetails(true); | ||||
| try { | try { | ||||
| @@ -132,44 +137,34 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| setLoadingDetails(false); | setLoadingDetails(false); | ||||
| } | } | ||||
| }, [selectedSession, total]); | }, [selectedSession, total]); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| loadDetails(page, pageSize); | |||||
| }, [page, pageSize, loadDetails]); | |||||
| const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | |||||
| setEditingRecord(detail); | |||||
| // 编辑时,输入 total = qty + badQty | |||||
| const firstTotal = | |||||
| detail.firstStockTakeQty != null | |||||
| const inputs: Record<number, { firstQty: string; secondQty: string; firstBadQty: string; secondBadQty: string; remark: string }> = {}; | |||||
| inventoryLotDetails.forEach((detail) => { | |||||
| const firstTotal = detail.firstStockTakeQty != null | |||||
| ? (detail.firstStockTakeQty + (detail.firstBadQty ?? 0)).toString() | ? (detail.firstStockTakeQty + (detail.firstBadQty ?? 0)).toString() | ||||
| : ""; | : ""; | ||||
| const secondTotal = | |||||
| detail.secondStockTakeQty != null | |||||
| const secondTotal = detail.secondStockTakeQty != null | |||||
| ? (detail.secondStockTakeQty + (detail.secondBadQty ?? 0)).toString() | ? (detail.secondStockTakeQty + (detail.secondBadQty ?? 0)).toString() | ||||
| : ""; | : ""; | ||||
| setFirstQty(firstTotal); | |||||
| setSecondQty(secondTotal); | |||||
| setFirstBadQty(detail.firstBadQty?.toString() || ""); | |||||
| setSecondBadQty(detail.secondBadQty?.toString() || ""); | |||||
| setRemark(detail.remarks || ""); | |||||
| }, []); | |||||
| const handleCancelEdit = useCallback(() => { | |||||
| setEditingRecord(null); | |||||
| setFirstQty(""); | |||||
| setSecondQty(""); | |||||
| setFirstBadQty(""); | |||||
| setSecondBadQty(""); | |||||
| setRemark(""); | |||||
| }, []); | |||||
| inputs[detail.id] = { | |||||
| firstQty: firstTotal, | |||||
| secondQty: secondTotal, | |||||
| firstBadQty: detail.firstBadQty?.toString() || "", | |||||
| secondBadQty: detail.secondBadQty?.toString() || "", | |||||
| remark: detail.remarks || "", | |||||
| }; | |||||
| }); | |||||
| setRecordInputs(inputs); | |||||
| }, [inventoryLotDetails]); | |||||
| useEffect(() => { | |||||
| loadDetails(page, pageSize); | |||||
| }, [page, pageSize, loadDetails]); | |||||
| const formatNumber = (num: number | null | undefined): string => { | const formatNumber = (num: number | null | undefined): string => { | ||||
| if (num == null || Number.isNaN(num)) return "0.00"; | |||||
| if (num == null || Number.isNaN(num)) return "0"; | |||||
| return num.toLocaleString("en-US", { | return num.toLocaleString("en-US", { | ||||
| minimumFractionDigits: 2, | |||||
| maximumFractionDigits: 2, | |||||
| minimumFractionDigits: 0, | |||||
| maximumFractionDigits: 0, | |||||
| }); | }); | ||||
| }; | }; | ||||
| @@ -184,24 +179,25 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | ||||
| // 现在用户输入的是 total 和 bad,需要算 available = total - bad | // 现在用户输入的是 total 和 bad,需要算 available = total - bad | ||||
| const totalQtyStr = isFirstSubmit ? firstQty : secondQty; | |||||
| const badQtyStr = isFirstSubmit ? firstBadQty : secondBadQty; | |||||
| if (!totalQtyStr || !badQtyStr) { | |||||
| const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | |||||
| const badQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstBadQty : recordInputs[detail.id]?.secondBadQty; | |||||
| // 只檢查 totalQty,Bad Qty 未輸入時預設為 0 | |||||
| if (!totalQtyStr) { | |||||
| onSnackbar( | onSnackbar( | ||||
| isFirstSubmit | isFirstSubmit | ||||
| ? t("Please enter QTY and Bad QTY") | |||||
| : t("Please enter Second QTY and Bad QTY"), | |||||
| ? t("Please enter QTY") | |||||
| : t("Please enter Second QTY"), | |||||
| "error" | "error" | ||||
| ); | ); | ||||
| return; | return; | ||||
| } | } | ||||
| const totalQty = parseFloat(totalQtyStr); | const totalQty = parseFloat(totalQtyStr); | ||||
| const badQty = parseFloat(badQtyStr); | |||||
| if (Number.isNaN(totalQty) || Number.isNaN(badQty)) { | |||||
| onSnackbar(t("Invalid QTY or Bad QTY"), "error"); | |||||
| const badQty = parseFloat(badQtyStr || "0") || 0; // 空字串時為 0 | |||||
| if (Number.isNaN(totalQty)) { | |||||
| onSnackbar(t("Invalid QTY"), "error"); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -219,7 +215,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| inventoryLotLineId: detail.id, | inventoryLotLineId: detail.id, | ||||
| qty: availableQty, // 保存 available qty | qty: availableQty, // 保存 available qty | ||||
| badQty: badQty, // 保存 bad qty | badQty: badQty, // 保存 bad qty | ||||
| remark: isSecondSubmit ? (remark || null) : null, | |||||
| remark: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : null, | |||||
| }; | }; | ||||
| console.log("handleSaveStockTake: request:", request); | console.log("handleSaveStockTake: request:", request); | ||||
| console.log("handleSaveStockTake: selectedSession.stockTakeId:", selectedSession.stockTakeId); | console.log("handleSaveStockTake: selectedSession.stockTakeId:", selectedSession.stockTakeId); | ||||
| @@ -228,10 +224,24 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| await saveStockTakeRecord(request, selectedSession.stockTakeId, currentUserId); | await saveStockTakeRecord(request, selectedSession.stockTakeId, currentUserId); | ||||
| onSnackbar(t("Stock take record saved successfully"), "success"); | onSnackbar(t("Stock take record saved successfully"), "success"); | ||||
| handleCancelEdit(); | |||||
| await loadDetails(page, pageSize); | |||||
| //await loadDetails(page, pageSize, { silent: true }); | |||||
| setInventoryLotDetails((prev) => | |||||
| prev.map((d) => | |||||
| d.id === detail.id | |||||
| ? { | |||||
| ...d, | |||||
| stockTakeRecordId: d.stockTakeRecordId ?? null, // 首次儲存後可從 response 取得,此處先保留 | |||||
| firstStockTakeQty: isFirstSubmit ? availableQty : d.firstStockTakeQty, | |||||
| firstBadQty: isFirstSubmit ? badQty : d.firstBadQty ?? null, | |||||
| secondStockTakeQty: isSecondSubmit ? availableQty : d.secondStockTakeQty, | |||||
| secondBadQty: isSecondSubmit ? badQty : d.secondBadQty ?? null, | |||||
| remarks: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : d.remarks, | |||||
| stockTakeRecordStatus: "pass", | |||||
| } | |||||
| : d | |||||
| ) | |||||
| ); | |||||
| } catch (e: any) { | } catch (e: any) { | ||||
| console.error("Save stock take record error:", e); | console.error("Save stock take record error:", e); | ||||
| let errorMessage = t("Failed to save stock take record"); | let errorMessage = t("Failed to save stock take record"); | ||||
| @@ -254,18 +264,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| }, | }, | ||||
| [ | [ | ||||
| selectedSession, | selectedSession, | ||||
| firstQty, | |||||
| secondQty, | |||||
| firstBadQty, | |||||
| secondBadQty, | |||||
| recordInputs, | |||||
| remark, | remark, | ||||
| handleCancelEdit, | |||||
| t, | t, | ||||
| currentUserId, | currentUserId, | ||||
| onSnackbar, | onSnackbar, | ||||
| loadDetails, | |||||
| page, | |||||
| pageSize, | |||||
| ] | ] | ||||
| ); | ); | ||||
| @@ -387,11 +390,15 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| }, []); | }, []); | ||||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | ||||
| if (detail.stockTakeRecordStatus === "pass") { | |||||
| if (selectedSession?.status?.toLowerCase() === "completed") { | |||||
| return true; | |||||
| } | |||||
| const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | |||||
| if (recordStatus === "pass" || recordStatus === "completed") { | |||||
| return true; | return true; | ||||
| } | } | ||||
| return false; | return false; | ||||
| }, []); | |||||
| }, [selectedSession?.status]); | |||||
| const uniqueWarehouses = Array.from( | const uniqueWarehouses = Array.from( | ||||
| new Set( | new Set( | ||||
| @@ -460,9 +467,10 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell>{t("Warehouse Location")}</TableCell> | <TableCell>{t("Warehouse Location")}</TableCell> | ||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | ||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | ||||
| <TableCell>{t("Remark")}</TableCell> | <TableCell>{t("Remark")}</TableCell> | ||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Record Status")}</TableCell> | <TableCell>{t("Record Status")}</TableCell> | ||||
| <TableCell>{t("Action")}</TableCell> | <TableCell>{t("Action")}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -478,7 +486,6 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| inventoryLotDetails.map((detail) => { | inventoryLotDetails.map((detail) => { | ||||
| const isEditing = editingRecord?.id === detail.id; | |||||
| const submitDisabled = isSubmitDisabled(detail); | const submitDisabled = isSubmitDisabled(detail); | ||||
| const isFirstSubmit = | const isFirstSubmit = | ||||
| !detail.stockTakeRecordId || !detail.firstStockTakeQty; | !detail.stockTakeRecordId || !detail.firstStockTakeQty; | ||||
| @@ -513,19 +520,24 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| </Box> | </Box> | ||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| {/* Qty + Bad Qty 合并显示/输入 */} | {/* Qty + Bad Qty 合并显示/输入 */} | ||||
| <TableCell sx={{ minWidth: 300 }}> | <TableCell sx={{ minWidth: 300 }}> | ||||
| <Stack spacing={1}> | <Stack spacing={1}> | ||||
| {/* First */} | {/* First */} | ||||
| {isEditing && isFirstSubmit ? ( | |||||
| {!submitDisabled && isFirstSubmit ? ( | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| <Typography variant="body2">{t("First")}:</Typography> | <Typography variant="body2">{t("First")}:</Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| value={firstQty} | |||||
| onChange={(e) => setFirstQty(e.target.value)} | |||||
| value={recordInputs[detail.id]?.firstQty || ""} | |||||
| inputProps={{ min: 0, step: "any" }} | |||||
| onChange={(e) => { | |||||
| const val = e.target.value; | |||||
| if (val.includes("-")) return; | |||||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstQty: val } })); | |||||
| }} | |||||
| sx={{ | sx={{ | ||||
| width: 130, | width: 130, | ||||
| minWidth: 130, | minWidth: 130, | ||||
| @@ -533,14 +545,23 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| height: "1.4375em", | height: "1.4375em", | ||||
| padding: "4px 8px", | padding: "4px 8px", | ||||
| }, | }, | ||||
| "& .MuiInputBase-input::placeholder": { | |||||
| color: "grey.400", // MUI light grey | |||||
| opacity: 1, | |||||
| }, | |||||
| }} | }} | ||||
| placeholder={t("Stock Take Qty")} | placeholder={t("Stock Take Qty")} | ||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| value={firstBadQty} | |||||
| onChange={(e) => setFirstBadQty(e.target.value)} | |||||
| value={recordInputs[detail.id]?.firstBadQty || ""} | |||||
| inputProps={{ min: 0, step: "any" }} | |||||
| onChange={(e) => { | |||||
| const val = e.target.value; | |||||
| if (val.includes("-")) return; | |||||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstBadQty: val } })); | |||||
| }} | |||||
| sx={{ | sx={{ | ||||
| width: 130, | width: 130, | ||||
| minWidth: 130, | minWidth: 130, | ||||
| @@ -548,14 +569,18 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| height: "1.4375em", | height: "1.4375em", | ||||
| padding: "4px 8px", | padding: "4px 8px", | ||||
| }, | }, | ||||
| "& .MuiInputBase-input::placeholder": { | |||||
| color: "grey.400", // MUI light grey | |||||
| opacity: 1, | |||||
| }, | |||||
| }} | }} | ||||
| placeholder={t("Bad Qty")} | placeholder={t("Bad Qty")} | ||||
| /> | /> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| = | = | ||||
| {formatNumber( | {formatNumber( | ||||
| parseFloat(firstQty || "0") - | |||||
| parseFloat(firstBadQty || "0") | |||||
| parseFloat(recordInputs[detail.id]?.firstQty || "0") - | |||||
| parseFloat(recordInputs[detail.id]?.firstBadQty || "0") | |||||
| )} | )} | ||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| @@ -576,14 +601,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| ) : null} | ) : null} | ||||
| {/* Second */} | {/* Second */} | ||||
| {isEditing && isSecondSubmit ? ( | |||||
| {!submitDisabled && isSecondSubmit ? ( | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| <Typography variant="body2">{t("Second")}:</Typography> | <Typography variant="body2">{t("Second")}:</Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| value={secondQty} | |||||
| onChange={(e) => setSecondQty(e.target.value)} | |||||
| value={recordInputs[detail.id]?.secondQty || ""} | |||||
| inputProps={{ min: 0, step: "any" }} | |||||
| onChange={(e) => { | |||||
| const val = e.target.value; | |||||
| if (val.includes("-")) return; | |||||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondQty: val } })); | |||||
| }} | |||||
| sx={{ | sx={{ | ||||
| width: 130, | width: 130, | ||||
| minWidth: 130, | minWidth: 130, | ||||
| @@ -597,8 +627,13 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| value={secondBadQty} | |||||
| onChange={(e) => setSecondBadQty(e.target.value)} | |||||
| value={recordInputs[detail.id]?.secondBadQty || ""} | |||||
| inputProps={{ min: 0, step: "any" }} | |||||
| onChange={(e) => { | |||||
| const val = e.target.value; | |||||
| if (val.includes("-")) return; | |||||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondBadQty: val } })); | |||||
| }} | |||||
| sx={{ | sx={{ | ||||
| width: 130, | width: 130, | ||||
| minWidth: 130, | minWidth: 130, | ||||
| @@ -612,8 +647,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| = | = | ||||
| {formatNumber( | {formatNumber( | ||||
| parseFloat(secondQty || "0") - | |||||
| parseFloat(secondBadQty || "0") | |||||
| parseFloat(recordInputs[detail.id]?.secondQty || "0") - | |||||
| parseFloat(recordInputs[detail.id]?.secondBadQty || "0") | |||||
| )} | )} | ||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| @@ -635,7 +670,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| {!detail.firstStockTakeQty && | {!detail.firstStockTakeQty && | ||||
| !detail.secondStockTakeQty && | !detail.secondStockTakeQty && | ||||
| !isEditing && ( | |||||
| !submitDisabled && ( | |||||
| <Typography | <Typography | ||||
| variant="body2" | variant="body2" | ||||
| color="text.secondary" | color="text.secondary" | ||||
| @@ -648,13 +683,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| {/* Remark */} | {/* Remark */} | ||||
| <TableCell sx={{ width: 180 }}> | <TableCell sx={{ width: 180 }}> | ||||
| {isEditing && isSecondSubmit ? ( | |||||
| {!submitDisabled && isSecondSubmit ? ( | |||||
| <> | <> | ||||
| <Typography variant="body2">{t("Remark")}</Typography> | <Typography variant="body2">{t("Remark")}</Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| value={remark} | |||||
| onChange={(e) => setRemark(e.target.value)} | |||||
| value={recordInputs[detail.id]?.remark || ""} | |||||
| onChange={(e) => setRecordInputs(prev => ({ | |||||
| ...prev, | |||||
| [detail.id]: { | |||||
| ...(prev[detail.id] ?? { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }), | |||||
| remark: e.target.value | |||||
| } | |||||
| }))} | |||||
| sx={{ width: 150 }} | sx={{ width: 150 }} | ||||
| /> | /> | ||||
| </> | </> | ||||
| @@ -665,32 +706,38 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| <TableCell> | <TableCell> | ||||
| {detail.stockTakeRecordStatus === "pass" ? ( | |||||
| <Chip | |||||
| size="small" | |||||
| label={t(detail.stockTakeRecordStatus)} | |||||
| color="success" | |||||
| /> | |||||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||||
| <Chip | |||||
| size="small" | |||||
| label={t(detail.stockTakeRecordStatus)} | |||||
| color="warning" | |||||
| /> | |||||
| ) : ( | |||||
| <Chip | |||||
| size="small" | |||||
| label={t(detail.stockTakeRecordStatus || "")} | |||||
| color="default" | |||||
| /> | |||||
| )} | |||||
| </TableCell> | |||||
| {detail.stockTakeRecordStatus === "completed" ? ( | |||||
| <Chip | |||||
| size="small" | |||||
| label={t(detail.stockTakeRecordStatus)} | |||||
| color="success" | |||||
| /> | |||||
| ) : detail.stockTakeRecordStatus === "pass" ? ( | |||||
| <Chip | |||||
| size="small" | |||||
| label={t(detail.stockTakeRecordStatus)} | |||||
| color="default" | |||||
| /> | |||||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||||
| <Chip | |||||
| size="small" | |||||
| label={t(detail.stockTakeRecordStatus)} | |||||
| color="warning" | |||||
| /> | |||||
| ) : ( | |||||
| <Chip | |||||
| size="small" | |||||
| label={t(detail.stockTakeRecordStatus || "")} | |||||
| color="default" | |||||
| /> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | <TableCell> | ||||
| {isEditing ? ( | |||||
| <Stack direction="row" spacing={1}> | <Stack direction="row" spacing={1}> | ||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| @@ -700,24 +747,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| > | > | ||||
| {t("Save")} | {t("Save")} | ||||
| </Button> | </Button> | ||||
| <Button size="small" onClick={handleCancelEdit}> | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| </Stack> | </Stack> | ||||
| ) : ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| onClick={() => handleStartEdit(detail)} | |||||
| disabled={submitDisabled} | |||||
| > | |||||
| {!detail.stockTakeRecordId | |||||
| ? t("Input") | |||||
| : detail.stockTakeRecordStatus === "notMatch" | |||||
| ? t("Input") | |||||
| : t("View")} | |||||
| </Button> | |||||
| )} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ); | ); | ||||
| @@ -7,6 +7,8 @@ | |||||
| "Qty": "盤點數量", | "Qty": "盤點數量", | ||||
| "UoM": "單位", | "UoM": "單位", | ||||
| "mat": "物料", | "mat": "物料", | ||||
| "variance": "差異", | |||||
| "Variance %": "差異百分比", | |||||
| "fg": "成品", | "fg": "成品", | ||||
| "Back to List": "返回列表", | "Back to List": "返回列表", | ||||
| "Record Status": "記錄狀態", | "Record Status": "記錄狀態", | ||||