| @@ -94,10 +94,12 @@ export interface GetPickOrderInfoResponse { | |||||
| export interface GetPickOrderInfo { | export interface GetPickOrderInfo { | ||||
| id: number; | id: number; | ||||
| code: string; | code: string; | ||||
| targetDate: string; | |||||
| consoCode: string | null; // ✅ 添加 consoCode 属性 | |||||
| targetDate: string | number[]; // ✅ Support both formats | |||||
| type: string; | type: string; | ||||
| status: string; | status: string; | ||||
| assignTo: number; | assignTo: number; | ||||
| groupName: string; // ✅ Add this field | |||||
| pickOrderLines: GetPickOrderLineInfo[]; | pickOrderLines: GetPickOrderLineInfo[]; | ||||
| } | } | ||||
| @@ -157,9 +159,126 @@ export interface LotDetailWithStockOutLine { | |||||
| stockOutLineStatus?: string; | stockOutLineStatus?: string; | ||||
| stockOutLineQty?: number; | stockOutLineQty?: number; | ||||
| } | } | ||||
| export interface PickAnotherLotFormData { | |||||
| pickOrderLineId: number; | |||||
| lotId: number; | |||||
| qty: number; | |||||
| type: string; | |||||
| handlerId?: number; | |||||
| category?: string; | |||||
| releasedBy?: number; | |||||
| recordDate?: string; | |||||
| } | |||||
| export const recordFailLot = async (data: PickAnotherLotFormData) => { | |||||
| const result = await serverFetchJson<PostPickOrderResponse>( | |||||
| `${BASE_API_URL}/suggestedPickLot/recordFailLot`, | |||||
| { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| revalidateTag("pickorder"); | |||||
| return result; | |||||
| }; | |||||
| export interface PickExecutionIssueData { | |||||
| pickOrderId: number; | |||||
| pickOrderCode: string; | |||||
| pickOrderCreateDate: string; | |||||
| pickExecutionDate: string; | |||||
| pickOrderLineId: number; | |||||
| itemId: number; | |||||
| itemCode: string; | |||||
| itemDescription: string; | |||||
| lotId: number; | |||||
| lotNo: string; | |||||
| storeLocation: string; | |||||
| requiredQty: number; | |||||
| actualPickQty: number; | |||||
| missQty: number; | |||||
| badItemQty: number; | |||||
| issueRemark: string; | |||||
| pickerName: string; | |||||
| handledBy?: number; | |||||
| } | |||||
| export interface AutoAssignReleaseResponse { | |||||
| id: number | null; | |||||
| name: string; | |||||
| code: string; | |||||
| type?: string; | |||||
| message: string | null; | |||||
| errorPosition: string; | |||||
| entity?: { | |||||
| consoCode?: string; | |||||
| pickOrderIds?: number[]; | |||||
| hasActiveOrders: boolean; | |||||
| }; | |||||
| } | |||||
| export interface PickOrderCompletionResponse { | |||||
| id: number | null; | |||||
| name: string; | |||||
| code: string; | |||||
| type?: string; | |||||
| message: string | null; | |||||
| errorPosition: string; | |||||
| entity?: { | |||||
| hasCompletedOrders: boolean; | |||||
| completedOrders: Array<{ | |||||
| pickOrderId: number; | |||||
| pickOrderCode: string; | |||||
| consoCode: string; | |||||
| isCompleted: boolean; | |||||
| stockOutStatus: string; | |||||
| totalLines: number; | |||||
| unfinishedLines: number; | |||||
| }>; | |||||
| allOrders: Array<{ | |||||
| pickOrderId: number; | |||||
| pickOrderCode: string; | |||||
| consoCode: string; | |||||
| isCompleted: boolean; | |||||
| stockOutStatus: string; | |||||
| totalLines: number; | |||||
| unfinishedLines: number; | |||||
| }>; | |||||
| }; | |||||
| } | |||||
| export const autoAssignAndReleasePickOrder = async (userId: number): Promise<AutoAssignReleaseResponse> => { | |||||
| const response = await serverFetchJson<AutoAssignReleaseResponse>( | |||||
| `${BASE_API_URL}/pickOrder/auto-assign-release/${userId}`, | |||||
| { | |||||
| method: "POST", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| revalidateTag("pickorder"); | |||||
| return response; | |||||
| }; | |||||
| export const checkPickOrderCompletion = async (userId: number): Promise<PickOrderCompletionResponse> => { | |||||
| const response = await serverFetchJson<PickOrderCompletionResponse>( | |||||
| `${BASE_API_URL}/pickOrder/check-pick-completion/${userId}`, | |||||
| { | |||||
| method: "GET", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| return response; | |||||
| }; | |||||
| export const recordPickExecutionIssue = async (data: PickExecutionIssueData) => { | |||||
| const result = await serverFetchJson<PostPickOrderResponse>( | |||||
| `${BASE_API_URL}/pickExecution/recordIssue`, | |||||
| { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| revalidateTag("pickorder"); | |||||
| return result; | |||||
| }; | |||||
| export const resuggestPickOrder = async (pickOrderId: number) => { | export const resuggestPickOrder = async (pickOrderId: number) => { | ||||
| console.log("Resuggesting pick order:", pickOrderId); | console.log("Resuggesting pick order:", pickOrderId); | ||||
| const result = await serverFetchJson<PostPickOrderResponse>( | const result = await serverFetchJson<PostPickOrderResponse>( | ||||
| @@ -286,7 +405,7 @@ export interface PickOrderLotDetailResponse { | |||||
| actualPickQty: number; | actualPickQty: number; | ||||
| suggestedPickLotId: number; | suggestedPickLotId: number; | ||||
| lotStatus: string; | lotStatus: string; | ||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||||
| } | } | ||||
| interface ALLPickOrderLotDetailResponse { | interface ALLPickOrderLotDetailResponse { | ||||
| // Pick Order Information | // Pick Order Information | ||||
| @@ -315,24 +434,62 @@ interface ALLPickOrderLotDetailResponse { | |||||
| lotNo: string; | lotNo: string; | ||||
| expiryDate: string; | expiryDate: string; | ||||
| location: string; | location: string; | ||||
| outQty: number; | |||||
| holdQty: number; | |||||
| stockUnit: string; | stockUnit: string; | ||||
| availableQty: number; | availableQty: number; | ||||
| requiredQty: number; | requiredQty: number; | ||||
| actualPickQty: number; | actualPickQty: number; | ||||
| totalPickedByAllPickOrders: number; | |||||
| suggestedPickLotId: number; | suggestedPickLotId: number; | ||||
| lotStatus: string; | lotStatus: string; | ||||
| stockOutLineId?: number; | stockOutLineId?: number; | ||||
| stockOutLineStatus?: string; | stockOutLineStatus?: string; | ||||
| stockOutLineQty?: number; | stockOutLineQty?: number; | ||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||||
| processingStatus: string; | processingStatus: string; | ||||
| } | } | ||||
| export const fetchALLPickOrderLineLotDetails = cache(async (userId?: number) => { | |||||
| interface SuggestionWithStatus { | |||||
| suggestionId: number; | |||||
| suggestionQty: number; | |||||
| suggestionCreated: string; | |||||
| lotLineId: number; | |||||
| lotNo: string; | |||||
| expiryDate: string; | |||||
| location: string; | |||||
| stockOutLineId?: number; | |||||
| stockOutLineStatus?: string; | |||||
| stockOutLineQty?: number; | |||||
| suggestionStatus: 'active' | 'completed' | 'rejected' | 'in_progress' | 'unknown'; | |||||
| } | |||||
| export interface CheckCompleteResponse { | |||||
| id: number | null; | |||||
| name: string; | |||||
| code: string; | |||||
| type?: string; | |||||
| message: string | null; | |||||
| errorPosition: string; | |||||
| } | |||||
| export const checkAndCompletePickOrderByConsoCode = async (consoCode: string): Promise<CheckCompleteResponse> => { | |||||
| const response = await serverFetchJson<CheckCompleteResponse>( | |||||
| `${BASE_API_URL}/pickOrder/check-complete/${consoCode}`, | |||||
| { | |||||
| method: "POST", | |||||
| headers: { | |||||
| "Content-Type": "application/json", | |||||
| }, | |||||
| }, | |||||
| ); | |||||
| revalidateTag("pickorder"); | |||||
| return response; | |||||
| }; | |||||
| export const fetchPickOrderDetailsOptimized = cache(async (userId?: number) => { | |||||
| const url = userId | const url = userId | ||||
| ? `${BASE_API_URL}/pickOrder/all-lots-with-details?userId=${userId}` | |||||
| : `${BASE_API_URL}/pickOrder/all-lots-with-details`; | |||||
| ? `${BASE_API_URL}/pickOrder/detail-optimized?userId=${userId}` | |||||
| : `${BASE_API_URL}/pickOrder/detail-optimized`; | |||||
| return serverFetchJson<ALLPickOrderLotDetailResponse[]>( | |||||
| return serverFetchJson<any[]>( | |||||
| url, | url, | ||||
| { | { | ||||
| method: "GET", | method: "GET", | ||||
| @@ -340,11 +497,49 @@ export const fetchALLPickOrderLineLotDetails = cache(async (userId?: number) => | |||||
| }, | }, | ||||
| ); | ); | ||||
| }); | }); | ||||
| export const fetchAllPickOrderDetails = cache(async (userId?: number) => { | |||||
| const url = userId | |||||
| ? `${BASE_API_URL}/pickOrder/detail?userId=${userId}` | |||||
| : `${BASE_API_URL}/pickOrder/detail`; | |||||
| const fetchSuggestionsWithStatus = async (pickOrderLineId: number) => { | |||||
| try { | |||||
| const response = await fetch(`/api/suggestedPickLot/suggestions-with-status/${pickOrderLineId}`); | |||||
| const suggestions: SuggestionWithStatus[] = await response.json(); | |||||
| return suggestions; | |||||
| } catch (error) { | |||||
| console.error('Error fetching suggestions with status:', error); | |||||
| return []; | |||||
| } | |||||
| }; | |||||
| export const fetchALLPickOrderLineLotDetails = cache(async (userId: number): Promise<any[]> => { | |||||
| try { | |||||
| console.log("🔍 Fetching all pick order line lot details for userId:", userId); | |||||
| // ✅ 使用 serverFetchJson 而不是直接的 fetch | |||||
| const data = await serverFetchJson<any[]>( | |||||
| `${BASE_API_URL}/pickOrder/all-lots-with-details/${userId}`, | |||||
| { | |||||
| method: 'GET', | |||||
| next: { tags: ["pickorder"] }, | |||||
| } | |||||
| ); | |||||
| console.log("✅ API Response:", data); | |||||
| return data; | |||||
| } catch (error) { | |||||
| console.error("❌ Error fetching all pick order line lot details:", error); | |||||
| throw error; | |||||
| } | |||||
| }); | |||||
| export const fetchAllPickOrderDetails = cache(async (userId?: number) => { | |||||
| if (!userId) { | |||||
| return { | |||||
| consoCode: null, | |||||
| pickOrders: [], | |||||
| items: [] | |||||
| }; | |||||
| } | |||||
| // ✅ Use the correct endpoint with userId in the path | |||||
| const url = `${BASE_API_URL}/pickOrder/detail-optimized/${userId}`; | |||||
| return serverFetchJson<GetPickOrderInfoResponse>( | return serverFetchJson<GetPickOrderInfoResponse>( | ||||
| url, | url, | ||||
| { | { | ||||
| @@ -271,8 +271,6 @@ const PickOrderSearch: React.FC<Props> = ({ pickOrders }) => { | |||||
| borderBottom: '1px solid #e0e0e0' | borderBottom: '1px solid #e0e0e0' | ||||
| }}> | }}> | ||||
| <Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable"> | <Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable"> | ||||
| <Tab label={t("Assign")} iconPosition="end" /> | |||||
| <Tab label={t("Release")} iconPosition="end" /> | |||||
| <Tab label={t("Pick Execution")} iconPosition="end" /> | <Tab label={t("Pick Execution")} iconPosition="end" /> | ||||
| </Tabs> | </Tabs> | ||||
| </Box> | </Box> | ||||
| @@ -281,9 +279,7 @@ const PickOrderSearch: React.FC<Props> = ({ pickOrders }) => { | |||||
| <Box sx={{ | <Box sx={{ | ||||
| p: 2 | p: 2 | ||||
| }}> | }}> | ||||
| {tabIndex === 2 && <PickExecution filterArgs={filterArgs} />} | |||||
| {tabIndex === 0 && <AssignAndRelease filterArgs={filterArgs} />} | |||||
| {tabIndex === 1 && <AssignTo filterArgs={filterArgs} />} | |||||
| {tabIndex === 0 && <PickExecution filterArgs={filterArgs} />} | |||||
| </Box> | </Box> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -0,0 +1,372 @@ | |||||
| // FPSMS-frontend/src/components/PickOrderSearch/PickExecutionForm.tsx | |||||
| "use client"; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| Dialog, | |||||
| DialogActions, | |||||
| DialogContent, | |||||
| DialogTitle, | |||||
| FormControl, | |||||
| Grid, | |||||
| InputLabel, | |||||
| MenuItem, | |||||
| Select, | |||||
| TextField, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import { useCallback, useEffect, useState } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { GetPickOrderLineInfo, PickExecutionIssueData } from "@/app/api/pickOrder/actions"; | |||||
| import { fetchEscalationCombo } from "@/app/api/user/actions"; | |||||
| interface LotPickData { | |||||
| id: number; | |||||
| lotId: number; | |||||
| lotNo: string; | |||||
| expiryDate: string; | |||||
| location: string; | |||||
| stockUnit: string; | |||||
| inQty: number; | |||||
| outQty: number; | |||||
| holdQty: number; | |||||
| totalPickedByAllPickOrders: number; | |||||
| availableQty: number; | |||||
| requiredQty: number; | |||||
| actualPickQty: number; | |||||
| lotStatus: string; | |||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||||
| stockOutLineId?: number; | |||||
| stockOutLineStatus?: string; | |||||
| stockOutLineQty?: number; | |||||
| } | |||||
| interface PickExecutionFormProps { | |||||
| open: boolean; | |||||
| onClose: () => void; | |||||
| onSubmit: (data: PickExecutionIssueData) => Promise<void>; | |||||
| selectedLot: LotPickData | null; | |||||
| selectedPickOrderLine: (GetPickOrderLineInfo & { pickOrderCode: string }) | null; | |||||
| pickOrderId?: number; | |||||
| pickOrderCreateDate: any; | |||||
| // ✅ Remove these props since we're not handling normal cases | |||||
| // onNormalPickSubmit?: (lineId: number, lotId: number, qty: number) => Promise<void>; | |||||
| // selectedRowId?: number | null; | |||||
| } | |||||
| // 定义错误类型 | |||||
| interface FormErrors { | |||||
| actualPickQty?: string; | |||||
| missQty?: string; | |||||
| badItemQty?: string; | |||||
| issueRemark?: string; | |||||
| handledBy?: string; | |||||
| } | |||||
| const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||||
| open, | |||||
| onClose, | |||||
| onSubmit, | |||||
| selectedLot, | |||||
| selectedPickOrderLine, | |||||
| pickOrderId, | |||||
| pickOrderCreateDate, | |||||
| // ✅ Remove these props | |||||
| // onNormalPickSubmit, | |||||
| // selectedRowId, | |||||
| }) => { | |||||
| const { t } = useTranslation(); | |||||
| const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({}); | |||||
| const [errors, setErrors] = useState<FormErrors>({}); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]); | |||||
| // 计算剩余可用数量 | |||||
| const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => { | |||||
| const remainingQty = lot.inQty - lot.outQty; | |||||
| return Math.max(0, remainingQty); | |||||
| }, []); | |||||
| const calculateRequiredQty = useCallback((lot: LotPickData) => { | |||||
| const requiredQty = lot.requiredQty-(lot.actualPickQty||0); | |||||
| return Math.max(0, requiredQty); | |||||
| }, []); | |||||
| // 获取处理人员列表 | |||||
| useEffect(() => { | |||||
| const fetchHandlers = async () => { | |||||
| try { | |||||
| const escalationCombo = await fetchEscalationCombo(); | |||||
| setHandlers(escalationCombo); | |||||
| } catch (error) { | |||||
| console.error("Error fetching handlers:", error); | |||||
| } | |||||
| }; | |||||
| fetchHandlers(); | |||||
| }, []); | |||||
| // 初始化表单数据 - 每次打开时都重新初始化 | |||||
| useEffect(() => { | |||||
| if (open && selectedLot && selectedPickOrderLine && pickOrderId) { | |||||
| const getSafeDate = (dateValue: any): string => { | |||||
| if (!dateValue) return new Date().toISOString().split('T')[0]; | |||||
| try { | |||||
| const date = new Date(dateValue); | |||||
| if (isNaN(date.getTime())) { | |||||
| return new Date().toISOString().split('T')[0]; | |||||
| } | |||||
| return date.toISOString().split('T')[0]; | |||||
| } catch { | |||||
| return new Date().toISOString().split('T')[0]; | |||||
| } | |||||
| }; | |||||
| // 计算剩余可用数量 | |||||
| const remainingAvailableQty = calculateRemainingAvailableQty(selectedLot); | |||||
| const requiredQty = calculateRequiredQty(selectedLot); | |||||
| console.log("=== PickExecutionForm Debug ==="); | |||||
| console.log("selectedLot:", selectedLot); | |||||
| console.log("inQty:", selectedLot.inQty); | |||||
| console.log("outQty:", selectedLot.outQty); | |||||
| console.log("holdQty:", selectedLot.holdQty); | |||||
| console.log("availableQty:", selectedLot.availableQty); | |||||
| console.log("calculated remainingAvailableQty:", remainingAvailableQty); | |||||
| console.log("=== End Debug ==="); | |||||
| setFormData({ | |||||
| pickOrderId: pickOrderId, | |||||
| pickOrderCode: selectedPickOrderLine.pickOrderCode, | |||||
| pickOrderCreateDate: getSafeDate(pickOrderCreateDate), | |||||
| pickExecutionDate: new Date().toISOString().split('T')[0], | |||||
| pickOrderLineId: selectedPickOrderLine.id, | |||||
| itemId: selectedPickOrderLine.itemId, | |||||
| itemCode: selectedPickOrderLine.itemCode, | |||||
| itemDescription: selectedPickOrderLine.itemName, | |||||
| lotId: selectedLot.lotId, | |||||
| lotNo: selectedLot.lotNo, | |||||
| storeLocation: selectedLot.location, | |||||
| requiredQty: selectedLot.requiredQty, | |||||
| actualPickQty: selectedLot.actualPickQty || 0, | |||||
| missQty: 0, | |||||
| badItemQty: 0, // 初始化为 0,用户需要手动输入 | |||||
| issueRemark: '', | |||||
| pickerName: '', | |||||
| handledBy: undefined, | |||||
| }); | |||||
| } | |||||
| }, [open, selectedLot, selectedPickOrderLine, pickOrderId, pickOrderCreateDate, calculateRemainingAvailableQty]); | |||||
| const handleInputChange = useCallback((field: keyof PickExecutionIssueData, value: any) => { | |||||
| setFormData(prev => ({ ...prev, [field]: value })); | |||||
| // 清除错误 | |||||
| if (errors[field as keyof FormErrors]) { | |||||
| setErrors(prev => ({ ...prev, [field]: undefined })); | |||||
| } | |||||
| }, [errors]); | |||||
| // ✅ Update form validation to require either missQty > 0 OR badItemQty > 0 | |||||
| const validateForm = (): boolean => { | |||||
| const newErrors: FormErrors = {}; | |||||
| if (formData.actualPickQty === undefined || formData.actualPickQty < 0) { | |||||
| newErrors.actualPickQty = t('pickOrder.validation.actualPickQtyRequired'); | |||||
| } | |||||
| // ✅ NEW: Require either missQty > 0 OR badItemQty > 0 (at least one issue must be reported) | |||||
| const hasMissQty = formData.missQty && formData.missQty > 0; | |||||
| const hasBadItemQty = formData.badItemQty && formData.badItemQty > 0; | |||||
| if (!hasMissQty && !hasBadItemQty) { | |||||
| newErrors.missQty = t('pickOrder.validation.mustReportMissOrBadItems'); | |||||
| newErrors.badItemQty = t('pickOrder.validation.mustReportMissOrBadItems'); | |||||
| } | |||||
| if (formData.missQty && formData.missQty < 0) { | |||||
| newErrors.missQty = t('pickOrder.validation.missQtyInvalid'); | |||||
| } | |||||
| if (formData.badItemQty && formData.badItemQty < 0) { | |||||
| newErrors.badItemQty = t('pickOrder.validation.badItemQtyInvalid'); | |||||
| } | |||||
| if (formData.badItemQty && formData.badItemQty > 0 && !formData.issueRemark) { | |||||
| newErrors.issueRemark = t('pickOrder.validation.issueRemarkRequired'); | |||||
| } | |||||
| if (formData.badItemQty && formData.badItemQty > 0 && !formData.handledBy) { | |||||
| newErrors.handledBy = t('pickOrder.validation.handlerRequired'); | |||||
| } | |||||
| setErrors(newErrors); | |||||
| return Object.keys(newErrors).length === 0; | |||||
| }; | |||||
| const handleSubmit = async () => { | |||||
| if (!validateForm() || !formData.pickOrderId) { | |||||
| return; | |||||
| } | |||||
| setLoading(true); | |||||
| try { | |||||
| await onSubmit(formData as PickExecutionIssueData); | |||||
| onClose(); | |||||
| } catch (error) { | |||||
| console.error('Error submitting pick execution issue:', error); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }; | |||||
| const handleClose = () => { | |||||
| setFormData({}); | |||||
| setErrors({}); | |||||
| onClose(); | |||||
| }; | |||||
| if (!selectedLot || !selectedPickOrderLine) { | |||||
| return null; | |||||
| } | |||||
| const remainingAvailableQty = calculateRemainingAvailableQty(selectedLot); | |||||
| const requiredQty = calculateRequiredQty(selectedLot); | |||||
| return ( | |||||
| <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> | |||||
| <DialogTitle> | |||||
| {t('Pick Execution Issue Form')} {/* ✅ Always show issue form title */} | |||||
| </DialogTitle> | |||||
| <DialogContent> | |||||
| <Box sx={{ mt: 2 }}> | |||||
| {/* ✅ Add instruction text */} | |||||
| <Grid container spacing={2}> | |||||
| <Grid item xs={12}> | |||||
| <Box sx={{ p: 2, backgroundColor: '#fff3cd', borderRadius: 1, mb: 2 }}> | |||||
| <Typography variant="body2" color="warning.main"> | |||||
| <strong>{t('Note:')}</strong> {t('This form is for reporting issues only. You must report either missing items or bad items.')} | |||||
| </Typography> | |||||
| </Box> | |||||
| </Grid> | |||||
| {/* ✅ Keep the existing form fields */} | |||||
| <Grid item xs={6}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t('requiredQty')} | |||||
| value={requiredQty || 0} | |||||
| disabled | |||||
| variant="outlined" | |||||
| helperText={t('Still need to pick')} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={6}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t('remainingAvailableQty')} | |||||
| value={remainingAvailableQty} | |||||
| disabled | |||||
| variant="outlined" | |||||
| helperText={t('Available in warehouse')} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t('actualPickQty')} | |||||
| type="number" | |||||
| value={formData.actualPickQty || 0} | |||||
| onChange={(e) => handleInputChange('actualPickQty', parseFloat(e.target.value) || 0)} | |||||
| error={!!errors.actualPickQty} | |||||
| helperText={errors.actualPickQty || t('Enter the quantity actually picked')} | |||||
| variant="outlined" | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t('missQty')} | |||||
| type="number" | |||||
| value={formData.missQty || 0} | |||||
| onChange={(e) => handleInputChange('missQty', parseFloat(e.target.value) || 0)} | |||||
| error={!!errors.missQty} | |||||
| helperText={errors.missQty || t('Enter missing quantity (required if no bad items)')} | |||||
| variant="outlined" | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t('badItemQty')} | |||||
| type="number" | |||||
| value={formData.badItemQty || 0} | |||||
| onChange={(e) => handleInputChange('badItemQty', parseFloat(e.target.value) || 0)} | |||||
| error={!!errors.badItemQty} | |||||
| helperText={errors.badItemQty || t('Enter bad item quantity (required if no missing items)')} | |||||
| variant="outlined" | |||||
| /> | |||||
| </Grid> | |||||
| {/* ✅ Show issue description and handler fields when bad items > 0 */} | |||||
| {(formData.badItemQty && formData.badItemQty > 0) && ( | |||||
| <> | |||||
| <Grid item xs={12}> | |||||
| <TextField | |||||
| fullWidth | |||||
| id="issueRemark" | |||||
| label={t('issueRemark')} | |||||
| multiline | |||||
| rows={4} | |||||
| value={formData.issueRemark || ''} | |||||
| onChange={(e) => handleInputChange('issueRemark', e.target.value)} | |||||
| error={!!errors.issueRemark} | |||||
| helperText={errors.issueRemark} | |||||
| placeholder={t('Describe the issue with bad items')} | |||||
| variant="outlined" | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | |||||
| <FormControl fullWidth error={!!errors.handledBy}> | |||||
| <InputLabel>{t('handler')}</InputLabel> | |||||
| <Select | |||||
| value={formData.handledBy ? formData.handledBy.toString() : ''} | |||||
| onChange={(e) => handleInputChange('handledBy', e.target.value ? parseInt(e.target.value) : undefined)} | |||||
| label={t('handler')} | |||||
| > | |||||
| {handlers.map((handler) => ( | |||||
| <MenuItem key={handler.id} value={handler.id.toString()}> | |||||
| {handler.name} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| {errors.handledBy && ( | |||||
| <Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.75 }}> | |||||
| {errors.handledBy} | |||||
| </Typography> | |||||
| )} | |||||
| </FormControl> | |||||
| </Grid> | |||||
| </> | |||||
| )} | |||||
| </Grid> | |||||
| </Box> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={handleClose} disabled={loading}> | |||||
| {t('cancel')} | |||||
| </Button> | |||||
| <Button | |||||
| onClick={handleSubmit} | |||||
| variant="contained" | |||||
| disabled={loading} | |||||
| > | |||||
| {loading ? t('submitting') : t('submit')} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| ); | |||||
| }; | |||||
| export default PickExecutionForm; | |||||
| @@ -1,737 +0,0 @@ | |||||
| "use client"; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| Checkbox, | |||||
| Paper, | |||||
| Stack, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| TextField, | |||||
| Typography, | |||||
| TablePagination, | |||||
| Modal, | |||||
| } from "@mui/material"; | |||||
| import { useCallback, useMemo, useState, useEffect } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import QrCodeIcon from '@mui/icons-material/QrCode'; | |||||
| import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | |||||
| import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider'; | |||||
| import { updateInventoryLotLineStatus } from "@/app/api/inventory/actions"; | |||||
| import { updateStockOutLineStatus } from "@/app/api/pickOrder/actions"; | |||||
| interface LotPickData { | |||||
| id: number; | |||||
| lotId: number; | |||||
| lotNo: string; | |||||
| expiryDate: string; | |||||
| location: string; | |||||
| stockUnit: string; | |||||
| availableQty: number; | |||||
| requiredQty: number; | |||||
| actualPickQty: number; | |||||
| lotStatus: string; | |||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||||
| stockOutLineId?: number; | |||||
| stockOutLineStatus?: string; | |||||
| stockOutLineQty?: number; | |||||
| } | |||||
| interface PickQtyData { | |||||
| [lineId: number]: { | |||||
| [lotId: number]: number; | |||||
| }; | |||||
| } | |||||
| interface LotTableProps { | |||||
| lotData: LotPickData[]; | |||||
| selectedRowId: number | null; | |||||
| selectedRow: (GetPickOrderLineInfo & { pickOrderCode: string }) | null; | |||||
| pickQtyData: PickQtyData; | |||||
| selectedLotRowId: string | null; | |||||
| selectedLotId: number | null; | |||||
| onLotSelection: (uniqueLotId: string, lotId: number) => void; | |||||
| onPickQtyChange: (lineId: number, lotId: number, value: number) => void; | |||||
| onSubmitPickQty: (lineId: number, lotId: number) => void; | |||||
| onCreateStockOutLine: (inventoryLotLineId: number) => void; | |||||
| onQcCheck: (line: GetPickOrderLineInfo, pickOrderCode: string) => void; | |||||
| onLotSelectForInput: (lot: LotPickData) => void; | |||||
| showInputBody: boolean; | |||||
| setShowInputBody: (show: boolean) => void; | |||||
| selectedLotForInput: LotPickData | null; | |||||
| generateInputBody: () => any; | |||||
| onDataRefresh: () => Promise<void>; | |||||
| } | |||||
| // ✅ QR Code Modal Component | |||||
| const QrCodeModal: React.FC<{ | |||||
| open: boolean; | |||||
| onClose: () => void; | |||||
| lot: LotPickData | null; | |||||
| onQrCodeSubmit: (lotNo: string) => void; | |||||
| }> = ({ open, onClose, lot, onQrCodeSubmit }) => { | |||||
| const { t } = useTranslation("pickOrder"); | |||||
| const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||||
| const [manualInput, setManualInput] = useState<string>(''); | |||||
| // ✅ Add state to track manual input submission | |||||
| const [manualInputSubmitted, setManualInputSubmitted] = useState<boolean>(false); | |||||
| const [manualInputError, setManualInputError] = useState<boolean>(false); | |||||
| // ✅ Process scanned QR codes | |||||
| useEffect(() => { | |||||
| if (qrValues.length > 0 && lot) { | |||||
| const latestQr = qrValues[qrValues.length - 1]; | |||||
| const qrContent = latestQr.replace(/[{}]/g, ''); | |||||
| if (qrContent === lot.lotNo) { | |||||
| onQrCodeSubmit(lot.lotNo); | |||||
| onClose(); | |||||
| resetScan(); | |||||
| } else { | |||||
| // ✅ Set error state for helper text | |||||
| setManualInputError(true); | |||||
| setManualInputSubmitted(true); | |||||
| } | |||||
| } | |||||
| }, [qrValues, lot, onQrCodeSubmit, onClose, resetScan]); | |||||
| // ✅ Clear states when modal opens or lot changes | |||||
| useEffect(() => { | |||||
| if (open) { | |||||
| setManualInput(''); | |||||
| setManualInputSubmitted(false); | |||||
| setManualInputError(false); | |||||
| } | |||||
| }, [open]); | |||||
| useEffect(() => { | |||||
| if (lot) { | |||||
| setManualInput(''); | |||||
| setManualInputSubmitted(false); | |||||
| setManualInputError(false); | |||||
| } | |||||
| }, [lot]); | |||||
| {/* | |||||
| const handleManualSubmit = () => { | |||||
| if (manualInput.trim() === lot?.lotNo) { | |||||
| // ✅ Success - no error helper text needed | |||||
| onQrCodeSubmit(lot.lotNo); | |||||
| onClose(); | |||||
| setManualInput(''); | |||||
| } else { | |||||
| // ✅ Show error helper text after submit | |||||
| setManualInputError(true); | |||||
| setManualInputSubmitted(true); | |||||
| // Don't clear input - let user see what they typed | |||||
| } | |||||
| }; | |||||
| return ( | |||||
| <Modal open={open} onClose={onClose}> | |||||
| <Box sx={{ | |||||
| position: 'absolute', | |||||
| top: '50%', | |||||
| left: '50%', | |||||
| transform: 'translate(-50%, -50%)', | |||||
| bgcolor: 'background.paper', | |||||
| p: 3, | |||||
| borderRadius: 2, | |||||
| minWidth: 400, | |||||
| }}> | |||||
| <Typography variant="h6" gutterBottom> | |||||
| {t("QR Code Scan for Lot")}: {lot?.lotNo} | |||||
| </Typography> | |||||
| <Box sx={{ mb: 2, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}> | |||||
| <Typography variant="body2" gutterBottom> | |||||
| <strong>Scanner Status:</strong> {isScanning ? 'Scanning...' : 'Ready'} | |||||
| </Typography> | |||||
| <Stack direction="row" spacing={1}> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={isScanning ? stopScan : startScan} | |||||
| size="small" | |||||
| > | |||||
| {isScanning ? 'Stop Scan' : 'Start Scan'} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| onClick={resetScan} | |||||
| size="small" | |||||
| > | |||||
| Reset | |||||
| </Button> | |||||
| </Stack> | |||||
| </Box> | |||||
| <Box sx={{ mb: 2 }}> | |||||
| <Typography variant="body2" gutterBottom> | |||||
| <strong>Manual Input:</strong> | |||||
| </Typography> | |||||
| <TextField | |||||
| fullWidth | |||||
| size="small" | |||||
| value={manualInput} | |||||
| onChange={(e) => setManualInput(e.target.value)} | |||||
| sx={{ mb: 1 }} | |||||
| // ✅ Only show error after submit button is clicked | |||||
| error={manualInputSubmitted && manualInputError} | |||||
| helperText={ | |||||
| // ✅ Show helper text only after submit with error | |||||
| manualInputSubmitted && manualInputError | |||||
| ? `The input is not the same as the expected lot number. Expected: ${lot?.lotNo}` | |||||
| : '' | |||||
| } | |||||
| /> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={handleManualSubmit} | |||||
| disabled={!manualInput.trim()} | |||||
| size="small" | |||||
| color="primary" | |||||
| > | |||||
| Submit Manual Input | |||||
| </Button> | |||||
| </Box> | |||||
| {qrValues.length > 0 && ( | |||||
| <Box sx={{ mb: 2, p: 2, backgroundColor: manualInputError ? '#ffebee' : '#e8f5e8', borderRadius: 1 }}> | |||||
| <Typography variant="body2" color={manualInputError ? 'error' : 'success'}> | |||||
| <strong>QR Scan Result:</strong> {qrValues[qrValues.length - 1]} | |||||
| </Typography> | |||||
| {manualInputError && ( | |||||
| <Typography variant="caption" color="error" display="block"> | |||||
| ❌ Mismatch! Expected: {lot?.lotNo} | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| )} | |||||
| <Box sx={{ mt: 2, textAlign: 'right' }}> | |||||
| <Button onClick={onClose} variant="outlined"> | |||||
| Cancel | |||||
| </Button> | |||||
| </Box> | |||||
| </Box> | |||||
| </Modal> | |||||
| ); | |||||
| }; | |||||
| */} | |||||
| useEffect(() => { | |||||
| if (manualInput.trim() === lot?.lotNo && manualInput.trim() !== '') { | |||||
| // Auto-submit when manual input matches the expected lot number | |||||
| console.log('🔄 Auto-submitting manual input:', manualInput.trim()); | |||||
| // Add a small delay to ensure proper execution order | |||||
| const timer = setTimeout(() => { | |||||
| onQrCodeSubmit(lot.lotNo); | |||||
| onClose(); | |||||
| setManualInput(''); | |||||
| setManualInputError(false); | |||||
| setManualInputSubmitted(false); | |||||
| }, 200); // 200ms delay | |||||
| return () => clearTimeout(timer); | |||||
| } | |||||
| }, [manualInput, lot, onQrCodeSubmit, onClose]); | |||||
| const handleManualSubmit = () => { | |||||
| if (manualInput.trim() === lot?.lotNo) { | |||||
| // ✅ Success - no error helper text needed | |||||
| onQrCodeSubmit(lot.lotNo); | |||||
| onClose(); | |||||
| setManualInput(''); | |||||
| } else { | |||||
| // ✅ Show error helper text after submit | |||||
| setManualInputError(true); | |||||
| setManualInputSubmitted(true); | |||||
| // Don't clear input - let user see what they typed | |||||
| } | |||||
| }; | |||||
| useEffect(() => { | |||||
| if (open) { | |||||
| startScan(); | |||||
| } | |||||
| }, [open, startScan]); | |||||
| return ( | |||||
| <Modal open={open} onClose={onClose}> | |||||
| <Box sx={{ | |||||
| position: 'absolute', | |||||
| top: '50%', | |||||
| left: '50%', | |||||
| transform: 'translate(-50%, -50%)', | |||||
| bgcolor: 'background.paper', | |||||
| p: 3, | |||||
| borderRadius: 2, | |||||
| minWidth: 400, | |||||
| }}> | |||||
| <Typography variant="h6" gutterBottom> | |||||
| QR Code Scan for Lot: {lot?.lotNo} | |||||
| </Typography> | |||||
| {/* Manual Input with Submit-Triggered Helper Text */} | |||||
| <Box sx={{ mb: 2 }}> | |||||
| <Typography variant="body2" gutterBottom> | |||||
| <strong>Manual Input:</strong> | |||||
| </Typography> | |||||
| <TextField | |||||
| fullWidth | |||||
| size="small" | |||||
| value={manualInput} | |||||
| onChange={(e) => setManualInput(e.target.value)} | |||||
| sx={{ mb: 1 }} | |||||
| error={manualInputSubmitted && manualInputError} | |||||
| helperText={ | |||||
| manualInputSubmitted && manualInputError | |||||
| ? `The input is not the same as the expected lot number.` | |||||
| : '' | |||||
| } | |||||
| /> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={handleManualSubmit} | |||||
| disabled={!manualInput.trim()} | |||||
| size="small" | |||||
| color="primary" | |||||
| > | |||||
| Submit Manual Input | |||||
| </Button> | |||||
| </Box> | |||||
| {/* Show QR Scan Status */} | |||||
| {qrValues.length > 0 && ( | |||||
| <Box sx={{ mb: 2, p: 2, backgroundColor: manualInputError ? '#ffebee' : '#e8f5e8', borderRadius: 1 }}> | |||||
| <Typography variant="body2" color={manualInputError ? 'error' : 'success'}> | |||||
| <strong>QR Scan Result:</strong> {qrValues[qrValues.length - 1]} | |||||
| </Typography> | |||||
| {manualInputError && ( | |||||
| <Typography variant="caption" color="error" display="block"> | |||||
| ❌ Mismatch! Expected! | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| )} | |||||
| <Box sx={{ mt: 2, textAlign: 'right' }}> | |||||
| <Button onClick={onClose} variant="outlined"> | |||||
| Cancel | |||||
| </Button> | |||||
| </Box> | |||||
| </Box> | |||||
| </Modal> | |||||
| ); | |||||
| }; | |||||
| const LotTable: React.FC<LotTableProps> = ({ | |||||
| lotData, | |||||
| selectedRowId, | |||||
| selectedRow, | |||||
| pickQtyData, | |||||
| selectedLotRowId, | |||||
| selectedLotId, | |||||
| onLotSelection, | |||||
| onPickQtyChange, | |||||
| onSubmitPickQty, | |||||
| onCreateStockOutLine, | |||||
| onQcCheck, | |||||
| onLotSelectForInput, | |||||
| showInputBody, | |||||
| setShowInputBody, | |||||
| selectedLotForInput, | |||||
| generateInputBody, | |||||
| onDataRefresh, | |||||
| }) => { | |||||
| const { t } = useTranslation("pickOrder"); | |||||
| // ✅ Add QR scanner context | |||||
| const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||||
| // ✅ Add state for QR input modal | |||||
| const [qrModalOpen, setQrModalOpen] = useState(false); | |||||
| const [selectedLotForQr, setSelectedLotForQr] = useState<LotPickData | null>(null); | |||||
| const [manualQrInput, setManualQrInput] = useState<string>(''); | |||||
| // 分页控制器 | |||||
| const [lotTablePagingController, setLotTablePagingController] = useState({ | |||||
| pageNum: 0, | |||||
| pageSize: 10, | |||||
| }); | |||||
| // ✅ 添加状态消息生成函数 | |||||
| const getStatusMessage = useCallback((lot: LotPickData) => { | |||||
| if (!lot.stockOutLineId) { | |||||
| return t("Please finish QR code scan, QC check and pick order."); | |||||
| } | |||||
| switch (lot.stockOutLineStatus?.toLowerCase()) { | |||||
| case 'pending': | |||||
| return t("Please submit pick order."); | |||||
| case 'checked': | |||||
| return t("Please submit the pick order."); | |||||
| case 'partially_completed': | |||||
| return t("Partial quantity submitted. Please submit more or complete the order.") ; | |||||
| case 'completed': | |||||
| return t("Pick order completed successfully!"); | |||||
| case 'rejected': | |||||
| return t("QC check failed. Lot has been rejected and marked as unavailable."); | |||||
| case 'unavailable': | |||||
| return t("This order is insufficient, please pick another lot."); | |||||
| default: | |||||
| return t("Please finish QR code scan, QC check and pick order."); | |||||
| } | |||||
| }, []); | |||||
| const prepareLotTableData = useMemo(() => { | |||||
| return lotData.map((lot) => ({ | |||||
| ...lot, | |||||
| id: lot.lotId, | |||||
| })); | |||||
| }, [lotData]); | |||||
| // 分页数据 | |||||
| const paginatedLotTableData = useMemo(() => { | |||||
| const startIndex = lotTablePagingController.pageNum * lotTablePagingController.pageSize; | |||||
| const endIndex = startIndex + lotTablePagingController.pageSize; | |||||
| return prepareLotTableData.slice(startIndex, endIndex); | |||||
| }, [prepareLotTableData, lotTablePagingController]); | |||||
| // 分页处理函数 | |||||
| const handleLotTablePageChange = useCallback((event: unknown, newPage: number) => { | |||||
| setLotTablePagingController(prev => ({ | |||||
| ...prev, | |||||
| pageNum: newPage, | |||||
| })); | |||||
| }, []); | |||||
| const handleLotTablePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||||
| const newPageSize = parseInt(event.target.value, 10); | |||||
| setLotTablePagingController({ | |||||
| pageNum: 0, | |||||
| pageSize: newPageSize, | |||||
| }); | |||||
| }, []); | |||||
| // ✅ Handle QR code submission | |||||
| const handleQrCodeSubmit = useCallback(async (lotNo: string) => { | |||||
| if (selectedLotForQr && selectedLotForQr.lotNo === lotNo) { | |||||
| console.log(`✅ QR Code verified for lot: ${lotNo}`); | |||||
| // ✅ Store the required quantity before creating stock out line | |||||
| const requiredQty = selectedLotForQr.requiredQty; | |||||
| const lotId = selectedLotForQr.lotId; | |||||
| // ✅ Create stock out line and wait for it to complete | |||||
| await onCreateStockOutLine(selectedLotForQr.lotId); | |||||
| // ✅ Close modal | |||||
| setQrModalOpen(false); | |||||
| setSelectedLotForQr(null); | |||||
| // ✅ Set pick quantity AFTER stock out line creation and refresh is complete | |||||
| if (selectedRowId) { | |||||
| // Add a small delay to ensure the data refresh from onCreateStockOutLine is complete | |||||
| setTimeout(() => { | |||||
| onPickQtyChange(selectedRowId, lotId, requiredQty); | |||||
| console.log(`✅ Auto-set pick quantity to ${requiredQty} for lot ${lotNo}`); | |||||
| }, 500); // 500ms delay to ensure refresh is complete | |||||
| } | |||||
| // ✅ Show success message | |||||
| console.log("Stock out line created successfully!"); | |||||
| } | |||||
| }, [selectedLotForQr, onCreateStockOutLine, selectedRowId, onPickQtyChange]); | |||||
| return ( | |||||
| <> | |||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Selected")}</TableCell> | |||||
| <TableCell>{t("Lot#")}</TableCell> | |||||
| <TableCell>{t("Lot Expiry Date")}</TableCell> | |||||
| <TableCell>{t("Lot Location")}</TableCell> | |||||
| <TableCell align="right">{t("Available Lot")}</TableCell> | |||||
| <TableCell align="right">{t("Lot Required Pick Qty")}</TableCell> | |||||
| <TableCell>{t("Stock Unit")}</TableCell> | |||||
| <TableCell align="center">{t("QR Code Scan")}</TableCell> | |||||
| <TableCell align="center">{t("QC Check")}</TableCell> | |||||
| <TableCell align="right">{t("Lot Actual Pick Qty")}</TableCell> | |||||
| <TableCell align="center">{t("Submit")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {paginatedLotTableData.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={11} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data available")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| paginatedLotTableData.map((lot, index) => ( | |||||
| <TableRow key={lot.id}> | |||||
| <TableCell> | |||||
| <Checkbox | |||||
| checked={selectedLotRowId === `row_${index}`} | |||||
| onChange={() => onLotSelection(`row_${index}`, lot.lotId)} | |||||
| // ✅ Allow selection of available AND insufficient_stock lots | |||||
| //disabled={lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable'} | |||||
| value={`row_${index}`} | |||||
| name="lot-selection" | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Box> | |||||
| <Typography>{lot.lotNo}</Typography> | |||||
| {lot.lotAvailability !== 'available' && ( | |||||
| <Typography variant="caption" color="error" display="block"> | |||||
| ({lot.lotAvailability === 'expired' ? 'Expired' : | |||||
| lot.lotAvailability === 'insufficient_stock' ? 'Insufficient' : | |||||
| 'Unavailable'}) | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell>{lot.expiryDate}</TableCell> | |||||
| <TableCell>{lot.location}</TableCell> | |||||
| <TableCell align="right">{lot.availableQty.toLocaleString()}</TableCell> | |||||
| <TableCell align="right">{lot.requiredQty.toLocaleString()}</TableCell> | |||||
| <TableCell>{lot.stockUnit}</TableCell> | |||||
| {/* QR Code Scan Button */} | |||||
| <TableCell align="center"> | |||||
| <Box sx={{ textAlign: 'center' }}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| onClick={() => { | |||||
| setSelectedLotForQr(lot); | |||||
| setQrModalOpen(true); | |||||
| resetScan(); | |||||
| }} | |||||
| // ✅ Disable when: | |||||
| // 1. Lot is expired or unavailable | |||||
| // 2. Already scanned (has stockOutLineId) | |||||
| // 3. Not selected (selectedLotRowId doesn't match) | |||||
| disabled={ | |||||
| (lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') || | |||||
| Boolean(lot.stockOutLineId) || | |||||
| selectedLotRowId !== `row_${index}` | |||||
| } | |||||
| sx={{ | |||||
| fontSize: '0.7rem', | |||||
| py: 0.5, | |||||
| minHeight: '28px', | |||||
| whiteSpace: 'nowrap', | |||||
| minWidth: '40px', | |||||
| // ✅ Visual feedback | |||||
| opacity: selectedLotRowId === `row_${index}` ? 1 : 0.5 | |||||
| }} | |||||
| startIcon={<QrCodeIcon />} | |||||
| title={ | |||||
| selectedLotRowId !== `row_${index}` | |||||
| ? "Please select this lot first to enable QR scanning" | |||||
| : lot.stockOutLineId | |||||
| ? "Already scanned" | |||||
| : "Click to scan QR code" | |||||
| } | |||||
| > | |||||
| {lot.stockOutLineId ? t("Scanned") : t("Scan")} | |||||
| </Button> | |||||
| </Box> | |||||
| </TableCell> | |||||
| {/* QC Check Button */} | |||||
| {/* | |||||
| <TableCell align="center"> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| onClick={() => { | |||||
| if (selectedRowId && selectedRow) { | |||||
| onQcCheck(selectedRow, selectedRow.pickOrderCode); | |||||
| } | |||||
| }} | |||||
| // ✅ Enable QC check only when stock out line exists | |||||
| disabled={!lot.stockOutLineId || selectedLotRowId !== `row_${index}`} | |||||
| sx={{ | |||||
| fontSize: '0.7rem', | |||||
| py: 0.5, | |||||
| minHeight: '28px', | |||||
| whiteSpace: 'nowrap', | |||||
| minWidth: '40px' | |||||
| }} | |||||
| > | |||||
| {t("QC")} | |||||
| </Button> | |||||
| */} | |||||
| {/* Lot Actual Pick Qty */} | |||||
| <TableCell align="right"> | |||||
| <TextField | |||||
| type="number" | |||||
| value={selectedRowId ? (pickQtyData[selectedRowId]?.[lot.lotId] || '') : ''} // ✅ Fixed: Use empty string instead of 0 | |||||
| onChange={(e) => { | |||||
| if (selectedRowId) { | |||||
| const inputValue = e.target.value; | |||||
| // ✅ Fixed: Handle empty string and prevent leading zeros | |||||
| if (inputValue === '') { | |||||
| // Allow empty input (user can backspace to clear) | |||||
| onPickQtyChange(selectedRowId, lot.lotId, 0); | |||||
| } else { | |||||
| // Parse the number and prevent leading zeros | |||||
| const numValue = parseInt(inputValue, 10); | |||||
| if (!isNaN(numValue)) { | |||||
| onPickQtyChange(selectedRowId, lot.lotId, numValue); | |||||
| } | |||||
| } | |||||
| } | |||||
| }} | |||||
| onBlur={(e) => { | |||||
| // ✅ Fixed: When input loses focus, ensure we have a valid number | |||||
| if (selectedRowId) { | |||||
| const currentValue = pickQtyData[selectedRowId]?.[lot.lotId]; | |||||
| if (currentValue === undefined || currentValue === null) { | |||||
| // Set to 0 if no value | |||||
| onPickQtyChange(selectedRowId, lot.lotId, 0); | |||||
| } | |||||
| } | |||||
| }} | |||||
| inputProps={{ | |||||
| min: 0, | |||||
| max: lot.availableQty, | |||||
| step: 1 // Allow only whole numbers | |||||
| }} | |||||
| // ✅ Allow input for available AND insufficient_stock lots | |||||
| disabled={lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable'} | |||||
| sx={{ width: '80px' }} | |||||
| placeholder="0" // Show placeholder instead of default value | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| onClick={async () => { | |||||
| if (selectedRowId && selectedRow && lot.stockOutLineId) { | |||||
| try { | |||||
| // ✅ Call updateStockOutLineStatus to reject the stock out line | |||||
| await updateStockOutLineStatus({ | |||||
| id: lot.stockOutLineId, | |||||
| status: 'rejected', | |||||
| qty: 0 | |||||
| }); | |||||
| // ✅ Refresh data after rejection | |||||
| if (onDataRefresh) { | |||||
| await onDataRefresh(); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("Error rejecting lot:", error); | |||||
| } | |||||
| } | |||||
| }} | |||||
| // ✅ Only enable if stock out line exists | |||||
| disabled={!lot.stockOutLineId} | |||||
| sx={{ | |||||
| fontSize: '0.7rem', | |||||
| py: 0.5, | |||||
| minHeight: '28px', | |||||
| whiteSpace: 'nowrap', | |||||
| minWidth: '40px' | |||||
| }} | |||||
| > | |||||
| {t("Reject")} | |||||
| </Button> | |||||
| </TableCell> | |||||
| {/* Submit Button */} | |||||
| <TableCell align="center"> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={() => { | |||||
| if (selectedRowId) { | |||||
| onSubmitPickQty(selectedRowId, lot.lotId); | |||||
| } | |||||
| }} | |||||
| disabled={ | |||||
| (lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') || | |||||
| !pickQtyData[selectedRowId!]?.[lot.lotId] || | |||||
| !lot.stockOutLineStatus || // Must have stock out line | |||||
| !['pending','checked', 'partially_completed'].includes(lot.stockOutLineStatus.toLowerCase()) // Only these statuses | |||||
| } | |||||
| // ✅ Allow submission for available AND insufficient_stock lots | |||||
| sx={{ | |||||
| fontSize: '0.75rem', | |||||
| py: 0.5, | |||||
| minHeight: '28px' | |||||
| }} | |||||
| > | |||||
| {t("Submit")} | |||||
| </Button> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| )) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| {/* ✅ Status Messages Display */} | |||||
| {paginatedLotTableData.length > 0 && ( | |||||
| <Box sx={{ mt: 2, p: 2, backgroundColor: 'grey.50', borderRadius: 1 }}> | |||||
| {paginatedLotTableData.map((lot, index) => ( | |||||
| <Box key={lot.id} sx={{ mb: 1 }}> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| <strong>Lot {lot.lotNo}:</strong> {getStatusMessage(lot)} | |||||
| </Typography> | |||||
| </Box> | |||||
| ))} | |||||
| </Box> | |||||
| )} | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={prepareLotTableData.length} | |||||
| page={lotTablePagingController.pageNum} | |||||
| rowsPerPage={lotTablePagingController.pageSize} | |||||
| onPageChange={handleLotTablePageChange} | |||||
| onRowsPerPageChange={handleLotTablePageSizeChange} | |||||
| rowsPerPageOptions={[10, 25, 50]} | |||||
| labelRowsPerPage={t("Rows per page")} | |||||
| labelDisplayedRows={({ from, to, count }) => | |||||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||||
| } | |||||
| /> | |||||
| {/* ✅ QR Code Modal */} | |||||
| <QrCodeModal | |||||
| open={qrModalOpen} | |||||
| onClose={() => { | |||||
| setQrModalOpen(false); | |||||
| setSelectedLotForQr(null); | |||||
| stopScan(); | |||||
| resetScan(); | |||||
| }} | |||||
| lot={selectedLotForQr} | |||||
| onQrCodeSubmit={handleQrCodeSubmit} | |||||
| /> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default LotTable; | |||||
| @@ -120,7 +120,7 @@ interface LotPickData { | |||||
| requiredQty: number; | requiredQty: number; | ||||
| actualPickQty: number; | actualPickQty: number; | ||||
| lotStatus: string; | lotStatus: string; | ||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||||
| stockOutLineId?: number; | stockOutLineId?: number; | ||||
| stockOutLineStatus?: string; | stockOutLineStatus?: string; | ||||
| stockOutLineQty?: number; | stockOutLineQty?: number; | ||||
| @@ -687,7 +687,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||||
| formProps.reset(); | formProps.reset(); | ||||
| setHasSearched(false); | setHasSearched(false); | ||||
| setFilteredItems([]); | setFilteredItems([]); | ||||
| alert(t("All pick orders created successfully")); | |||||
| // alert(t("All pick orders created successfully")); | |||||
| // 通知父组件切换到 Assign & Release 标签页 | // 通知父组件切换到 Assign & Release 标签页 | ||||
| if (onPickOrderCreated) { | if (onPickOrderCreated) { | ||||
| @@ -20,11 +20,12 @@ import { | |||||
| import { useCallback, useMemo, useState, useEffect } from "react"; | import { useCallback, useMemo, useState, useEffect } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import QrCodeIcon from '@mui/icons-material/QrCode'; | import QrCodeIcon from '@mui/icons-material/QrCode'; | ||||
| import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | |||||
| import { GetPickOrderLineInfo, recordPickExecutionIssue } from "@/app/api/pickOrder/actions"; | |||||
| import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider'; | import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider'; | ||||
| import { updateInventoryLotLineStatus } from "@/app/api/inventory/actions"; | import { updateInventoryLotLineStatus } from "@/app/api/inventory/actions"; | ||||
| import { updateStockOutLineStatus } from "@/app/api/pickOrder/actions"; | import { updateStockOutLineStatus } from "@/app/api/pickOrder/actions"; | ||||
| import { fetchStockInLineInfo } from "@/app/api/po/actions"; // ✅ Add this import | import { fetchStockInLineInfo } from "@/app/api/po/actions"; // ✅ Add this import | ||||
| import PickExecutionForm from "./PickExecutionForm"; | |||||
| interface LotPickData { | interface LotPickData { | ||||
| id: number; | id: number; | ||||
| lotId: number; | lotId: number; | ||||
| @@ -37,7 +38,10 @@ interface LotPickData { | |||||
| requiredQty: number; | requiredQty: number; | ||||
| actualPickQty: number; | actualPickQty: number; | ||||
| lotStatus: string; | lotStatus: string; | ||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||||
| outQty: number; | |||||
| holdQty: number; | |||||
| totalPickedByAllPickOrders: number; | |||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable' | 'rejected'; // ✅ 添加 'rejected' | |||||
| stockOutLineId?: number; | stockOutLineId?: number; | ||||
| stockOutLineStatus?: string; | stockOutLineStatus?: string; | ||||
| stockOutLineQty?: number; | stockOutLineQty?: number; | ||||
| @@ -52,7 +56,7 @@ interface PickQtyData { | |||||
| interface LotTableProps { | interface LotTableProps { | ||||
| lotData: LotPickData[]; | lotData: LotPickData[]; | ||||
| selectedRowId: number | null; | selectedRowId: number | null; | ||||
| selectedRow: (GetPickOrderLineInfo & { pickOrderCode: string }) | null; | |||||
| selectedRow: (GetPickOrderLineInfo & { pickOrderCode: string; pickOrderId: number }) | null; // ✅ 添加 pickOrderId | |||||
| pickQtyData: PickQtyData; | pickQtyData: PickQtyData; | ||||
| selectedLotRowId: string | null; | selectedLotRowId: string | null; | ||||
| selectedLotId: number | null; | selectedLotId: number | null; | ||||
| @@ -63,6 +67,9 @@ interface LotTableProps { | |||||
| onQcCheck: (line: GetPickOrderLineInfo, pickOrderCode: string) => void; | onQcCheck: (line: GetPickOrderLineInfo, pickOrderCode: string) => void; | ||||
| onLotSelectForInput: (lot: LotPickData) => void; | onLotSelectForInput: (lot: LotPickData) => void; | ||||
| showInputBody: boolean; | showInputBody: boolean; | ||||
| totalPickedByAllPickOrders: number; | |||||
| outQty: number; | |||||
| holdQty: number; | |||||
| setShowInputBody: (show: boolean) => void; | setShowInputBody: (show: boolean) => void; | ||||
| selectedLotForInput: LotPickData | null; | selectedLotForInput: LotPickData | null; | ||||
| generateInputBody: () => any; | generateInputBody: () => any; | ||||
| @@ -383,7 +390,11 @@ const LotTable: React.FC<LotTableProps> = ({ | |||||
| onLotDataRefresh, | onLotDataRefresh, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const calculateRemainingRequiredQty = useCallback((lot: LotPickData) => { | |||||
| const requiredQty = lot.requiredQty || 0; | |||||
| const stockOutLineQty = lot.stockOutLineQty || 0; | |||||
| return Math.max(0, requiredQty - stockOutLineQty); | |||||
| }, []); | |||||
| // ✅ Add QR scanner context | // ✅ Add QR scanner context | ||||
| const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | ||||
| @@ -414,7 +425,7 @@ const LotTable: React.FC<LotTableProps> = ({ | |||||
| case 'completed': | case 'completed': | ||||
| return t("Pick order completed successfully!"); | return t("Pick order completed successfully!"); | ||||
| case 'rejected': | case 'rejected': | ||||
| return t("QC check failed. Lot has been rejected and marked as unavailable."); | |||||
| return t("Lot has been rejected and marked as unavailable."); | |||||
| case 'unavailable': | case 'unavailable': | ||||
| return t("This order is insufficient, please pick another lot."); | return t("This order is insufficient, please pick another lot."); | ||||
| default: | default: | ||||
| @@ -455,7 +466,7 @@ const LotTable: React.FC<LotTableProps> = ({ | |||||
| if (!selectedRowId) return lot.availableQty; | if (!selectedRowId) return lot.availableQty; | ||||
| const lactualPickQty = lot.actualPickQty || 0; | const lactualPickQty = lot.actualPickQty || 0; | ||||
| const actualPickQty = pickQtyData[selectedRowId]?.[lot.lotId] || 0; | const actualPickQty = pickQtyData[selectedRowId]?.[lot.lotId] || 0; | ||||
| const remainingQty = lot.inQty - actualPickQty - lactualPickQty; | |||||
| const remainingQty = lot.inQty - lot.outQty; | |||||
| // Ensure it doesn't go below 0 | // Ensure it doesn't go below 0 | ||||
| return Math.max(0, remainingQty); | return Math.max(0, remainingQty); | ||||
| @@ -490,6 +501,57 @@ const LotTable: React.FC<LotTableProps> = ({ | |||||
| } | } | ||||
| }, [selectedLotForQr, onCreateStockOutLine, selectedRowId, onPickQtyChange]); | }, [selectedLotForQr, onCreateStockOutLine, selectedRowId, onPickQtyChange]); | ||||
| // ✅ 添加 PickExecutionForm 相关的状态 | |||||
| const [pickExecutionFormOpen, setPickExecutionFormOpen] = useState(false); | |||||
| const [selectedLotForExecutionForm, setSelectedLotForExecutionForm] = useState<LotPickData | null>(null); | |||||
| // ✅ 添加处理函数 | |||||
| const handlePickExecutionForm = useCallback((lot: LotPickData) => { | |||||
| console.log("=== Pick Execution Form ==="); | |||||
| console.log("Lot data:", lot); | |||||
| if (!lot) { | |||||
| console.warn("No lot data provided for pick execution form"); | |||||
| return; | |||||
| } | |||||
| console.log("Opening pick execution form for lot:", lot.lotNo); | |||||
| setSelectedLotForExecutionForm(lot); | |||||
| setPickExecutionFormOpen(true); | |||||
| console.log("Pick execution form opened for lot ID:", lot.lotId); | |||||
| }, []); | |||||
| const handlePickExecutionFormSubmit = useCallback(async (data: any) => { | |||||
| try { | |||||
| console.log("Pick execution form submitted:", data); | |||||
| // ✅ 调用 API 提交数据 | |||||
| const result = await recordPickExecutionIssue(data); | |||||
| console.log("Pick execution issue recorded:", result); | |||||
| if (result && result.code === "SUCCESS") { | |||||
| console.log("✅ Pick execution issue recorded successfully"); | |||||
| } else { | |||||
| console.error("❌ Failed to record pick execution issue:", result); | |||||
| } | |||||
| setPickExecutionFormOpen(false); | |||||
| setSelectedLotForExecutionForm(null); | |||||
| // ✅ 刷新数据 | |||||
| if (onDataRefresh) { | |||||
| await onDataRefresh(); | |||||
| } | |||||
| if (onLotDataRefresh) { | |||||
| await onLotDataRefresh(); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("Error submitting pick execution form:", error); | |||||
| } | |||||
| }, [onDataRefresh, onLotDataRefresh]); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <TableContainer component={Paper}> | <TableContainer component={Paper}> | ||||
| @@ -526,24 +588,43 @@ const LotTable: React.FC<LotTableProps> = ({ | |||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| paginatedLotTableData.map((lot, index) => ( | paginatedLotTableData.map((lot, index) => ( | ||||
| <TableRow key={lot.id}> | |||||
| <TableRow | |||||
| key={lot.id} | |||||
| 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> | <TableCell> | ||||
| <Checkbox | |||||
| checked={selectedLotRowId === `row_${index}`} | |||||
| onChange={() => onLotSelection(`row_${index}`, lot.lotId)} | |||||
| // ✅ Allow selection of available AND insufficient_stock lots | |||||
| //disabled={lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable'} | |||||
| value={`row_${index}`} | |||||
| name="lot-selection" | |||||
| /> | |||||
| </TableCell> | |||||
| <Checkbox | |||||
| checked={selectedLotRowId === `row_${index}`} | |||||
| onChange={() => onLotSelection(`row_${index}`, lot.lotId)} | |||||
| // ✅ 禁用 rejected、expired 和 status_unavailable 的批次 | |||||
| disabled={lot.lotAvailability === 'expired' || | |||||
| lot.lotAvailability === 'status_unavailable' || | |||||
| lot.lotAvailability === 'rejected'} // ✅ 添加 rejected | |||||
| value={`row_${index}`} | |||||
| name="lot-selection" | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | <TableCell> | ||||
| <Box> | <Box> | ||||
| <Typography>{lot.lotNo}</Typography> | |||||
| <Typography | |||||
| sx={{ | |||||
| color: lot.lotAvailability === 'rejected' ? 'text.disabled' : 'inherit', | |||||
| opacity: lot.lotAvailability === 'rejected' ? 0.6 : 1 | |||||
| }} | |||||
| > | |||||
| {lot.lotNo} | |||||
| </Typography> | |||||
| {lot.lotAvailability !== 'available' && ( | {lot.lotAvailability !== 'available' && ( | ||||
| <Typography variant="caption" color="error" display="block"> | <Typography variant="caption" color="error" display="block"> | ||||
| ({lot.lotAvailability === 'expired' ? 'Expired' : | ({lot.lotAvailability === 'expired' ? 'Expired' : | ||||
| lot.lotAvailability === 'insufficient_stock' ? 'Insufficient' : | lot.lotAvailability === 'insufficient_stock' ? 'Insufficient' : | ||||
| lot.lotAvailability === 'rejected' ? 'Rejected' : // ✅ 添加 rejected 显示 | |||||
| 'Unavailable'}) | 'Unavailable'}) | ||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| @@ -552,174 +633,112 @@ const LotTable: React.FC<LotTableProps> = ({ | |||||
| <TableCell>{lot.expiryDate}</TableCell> | <TableCell>{lot.expiryDate}</TableCell> | ||||
| <TableCell>{lot.location}</TableCell> | <TableCell>{lot.location}</TableCell> | ||||
| <TableCell>{lot.stockUnit}</TableCell> | <TableCell>{lot.stockUnit}</TableCell> | ||||
| <TableCell align="right">{lot.requiredQty.toLocaleString()}</TableCell> | |||||
| <TableCell align="right">{lot.inQty.toLocaleString()??'0'}</TableCell> | |||||
| <TableCell align="center"> | |||||
| {/* Show QR Scan Button if not scanned, otherwise show TextField */} | |||||
| {!lot.stockOutLineId ? ( | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| onClick={() => { | |||||
| setSelectedLotForQr(lot); | |||||
| setQrModalOpen(true); | |||||
| resetScan(); | |||||
| }} | |||||
| // ✅ Disable when: | |||||
| // 1. Lot is expired or unavailable | |||||
| // 2. Not selected (selectedLotRowId doesn't match) | |||||
| disabled={ | |||||
| (lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') || | |||||
| selectedLotRowId !== `row_${index}` | |||||
| } | |||||
| sx={{ | |||||
| fontSize: '0.7rem', | |||||
| py: 0.5, | |||||
| minHeight: '40px', // ✅ Match TextField height | |||||
| whiteSpace: 'nowrap', | |||||
| minWidth: '80px', // ✅ Match TextField width | |||||
| // ✅ Visual feedback | |||||
| opacity: selectedLotRowId === `row_${index}` ? 1 : 0.5 | |||||
| }} | |||||
| startIcon={<QrCodeIcon />} | |||||
| title={ | |||||
| selectedLotRowId !== `row_${index}` | |||||
| ? "Please select this lot first to enable QR scanning" | |||||
| : "Click to scan QR code" | |||||
| } | |||||
| > | |||||
| {t("Scan")} | |||||
| </Button> | |||||
| ) : ( | |||||
| <TextField | |||||
| type="number" | |||||
| value={selectedRowId ? (pickQtyData[selectedRowId]?.[lot.lotId] || '') : ''} | |||||
| onChange={(e) => { | |||||
| if (selectedRowId) { | |||||
| const inputValue = e.target.value; | |||||
| // ✅ Fixed: Handle empty string and prevent leading zeros | |||||
| if (inputValue === '') { | |||||
| // Allow empty input (user can backspace to clear) | |||||
| onPickQtyChange(selectedRowId, lot.lotId, 0); | |||||
| } else { | |||||
| // Parse the number and prevent leading zeros | |||||
| const numValue = parseInt(inputValue, 10); | |||||
| if (!isNaN(numValue)) { | |||||
| onPickQtyChange(selectedRowId, lot.lotId, numValue); | |||||
| } | |||||
| } | |||||
| } | |||||
| }} | |||||
| onBlur={(e) => { | |||||
| // ✅ Fixed: When input loses focus, ensure we have a valid number | |||||
| if (selectedRowId) { | |||||
| const currentValue = pickQtyData[selectedRowId]?.[lot.lotId]; | |||||
| if (currentValue === undefined || currentValue === null) { | |||||
| // Set to 0 if no value | |||||
| onPickQtyChange(selectedRowId, lot.lotId, 0); | |||||
| } | |||||
| } | |||||
| }} | |||||
| inputProps={{ | |||||
| min: 0, | |||||
| max: lot.availableQty, | |||||
| step: 1 // Allow only whole numbers | |||||
| }} | |||||
| // ✅ Allow input for available AND insufficient_stock lots | |||||
| disabled={lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable'} | |||||
| sx={{ | |||||
| width: '80px', | |||||
| '& .MuiInputBase-root': { | |||||
| height: '40px', // ✅ Match table cell height | |||||
| }, | |||||
| '& .MuiInputBase-input': { | |||||
| height: '40px', | |||||
| padding: '8px 12px', // ✅ Adjust padding to center text vertically | |||||
| } | |||||
| }} | |||||
| placeholder="0" // Show placeholder instead of default value | |||||
| /> | |||||
| )} | |||||
| </TableCell> | |||||
| {/*<TableCell align="right">{lot.availableQty.toLocaleString()}</TableCell>*/} | |||||
| <TableCell align="right">{calculateRemainingAvailableQty(lot).toLocaleString()}</TableCell> | |||||
| {/* <TableCell>{lot.stockUnit}</TableCell> */} | |||||
| <TableCell align="right">{calculateRemainingRequiredQty(lot).toLocaleString()}</TableCell> | |||||
| <TableCell align="right"> | |||||
| {(() => { | |||||
| const inQty = lot.inQty || 0; | |||||
| const outQty = lot.outQty || 0; | |||||
| {/* QR Code Scan Button */} | |||||
| {/* | |||||
| <TableCell align="center"> | |||||
| <Box sx={{ textAlign: 'center' }}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| onClick={() => { | |||||
| setSelectedLotForQr(lot); | |||||
| setQrModalOpen(true); | |||||
| resetScan(); | |||||
| }} | |||||
| // ✅ Disable when: | |||||
| // 1. Lot is expired or unavailable | |||||
| // 2. Already scanned (has stockOutLineId) | |||||
| // 3. Not selected (selectedLotRowId doesn't match) | |||||
| disabled={ | |||||
| (lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') || | |||||
| Boolean(lot.stockOutLineId) || | |||||
| selectedLotRowId !== `row_${index}` | |||||
| } | |||||
| sx={{ | |||||
| fontSize: '0.7rem', | |||||
| py: 0.5, | |||||
| minHeight: '28px', | |||||
| whiteSpace: 'nowrap', | |||||
| minWidth: '40px', | |||||
| // ✅ Visual feedback | |||||
| opacity: selectedLotRowId === `row_${index}` ? 1 : 0.5 | |||||
| }} | |||||
| startIcon={<QrCodeIcon />} | |||||
| title={ | |||||
| selectedLotRowId !== `row_${index}` | |||||
| ? "Please select this lot first to enable QR scanning" | |||||
| : lot.stockOutLineId | |||||
| ? "Already scanned" | |||||
| : "Click to scan QR code" | |||||
| } | |||||
| > | |||||
| {lot.stockOutLineId ? t("Scanned") : t("Scan")} | |||||
| </Button> | |||||
| </Box> | |||||
| const result = inQty - outQty; | |||||
| return result.toLocaleString(); | |||||
| })()} | |||||
| </TableCell> | </TableCell> | ||||
| */} | |||||
| {/* QC Check Button */} | |||||
| {/* | |||||
| <TableCell align="center"> | <TableCell align="center"> | ||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| onClick={() => { | |||||
| if (selectedRowId && selectedRow) { | |||||
| onQcCheck(selectedRow, selectedRow.pickOrderCode); | |||||
| } | |||||
| }} | |||||
| // ✅ Enable QC check only when stock out line exists | |||||
| disabled={!lot.stockOutLineId || selectedLotRowId !== `row_${index}`} | |||||
| sx={{ | |||||
| fontSize: '0.7rem', | |||||
| py: 0.5, | |||||
| minHeight: '28px', | |||||
| whiteSpace: 'nowrap', | |||||
| minWidth: '40px' | |||||
| }} | |||||
| > | |||||
| {t("QC")} | |||||
| </Button> | |||||
| */} | |||||
| {/* Lot Actual Pick Qty */} | |||||
| {/* Show QR Scan Button if not scanned, otherwise show TextField + Pick Form */} | |||||
| {!lot.stockOutLineId ? ( | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| onClick={() => { | |||||
| setSelectedLotForQr(lot); | |||||
| setQrModalOpen(true); | |||||
| resetScan(); | |||||
| }} | |||||
| disabled={ | |||||
| (lot.lotAvailability === 'expired' || | |||||
| lot.lotAvailability === 'status_unavailable' || | |||||
| lot.lotAvailability === 'rejected') || | |||||
| selectedLotRowId !== `row_${index}` | |||||
| } | |||||
| sx={{ | |||||
| fontSize: '0.7rem', | |||||
| py: 0.5, | |||||
| minHeight: '40px', | |||||
| whiteSpace: 'nowrap', | |||||
| minWidth: '80px', | |||||
| opacity: selectedLotRowId === `row_${index}` ? 1 : 0.5 | |||||
| }} | |||||
| startIcon={<QrCodeIcon />} | |||||
| title={ | |||||
| selectedLotRowId !== `row_${index}` | |||||
| ? "Please select this lot first to enable QR scanning" | |||||
| : "Click to scan QR code" | |||||
| } | |||||
| > | |||||
| {t("Scan")} | |||||
| </Button> | |||||
| ) : ( | |||||
| // ✅ 当有 stockOutLineId 时,显示 TextField + Pick Form 按钮 | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| {/* ✅ 恢复 TextField 用于正常数量输入 */} | |||||
| <TextField | |||||
| type="number" | |||||
| size="small" | |||||
| value={pickQtyData[selectedRowId!]?.[lot.lotId] || ''} | |||||
| onChange={(e) => { | |||||
| if (selectedRowId) { | |||||
| onPickQtyChange(selectedRowId, lot.lotId, parseFloat(e.target.value) || 0); | |||||
| } | |||||
| }} | |||||
| disabled={ | |||||
| (lot.lotAvailability === 'expired' || | |||||
| lot.lotAvailability === 'status_unavailable' || | |||||
| lot.lotAvailability === 'rejected') || | |||||
| selectedLotRowId !== `row_${index}` || | |||||
| lot.stockOutLineStatus === 'completed' // ✅ 完成时禁用输入 | |||||
| } | |||||
| inputProps={{ | |||||
| min: 0, | |||||
| max: calculateRemainingRequiredQty(lot), | |||||
| step: 0.01 | |||||
| }} | |||||
| sx={{ | |||||
| width: '80px', | |||||
| '& .MuiInputBase-input': { | |||||
| fontSize: '0.75rem', | |||||
| textAlign: 'center', | |||||
| padding: '8px 4px' | |||||
| } | |||||
| }} | |||||
| placeholder="0" | |||||
| /> | |||||
| {/* ✅ 添加 Pick Form 按钮用于问题情况 */} | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| onClick={() => handlePickExecutionForm(lot)} | |||||
| sx={{ | |||||
| fontSize: '0.7rem', | |||||
| py: 0.5, | |||||
| minHeight: '28px', | |||||
| minWidth: '60px', | |||||
| borderColor: 'warning.main', | |||||
| color: 'warning.main' | |||||
| }} | |||||
| title="Report missing or bad items" | |||||
| > | |||||
| {t("Issue")} | |||||
| </Button> | |||||
| </Stack> | |||||
| )} | |||||
| </TableCell> | |||||
| {/*<TableCell align="right">{lot.availableQty.toLocaleString()}</TableCell>*/} | |||||
| <TableCell align="right">{calculateRemainingAvailableQty(lot).toLocaleString()}</TableCell> | |||||
| <TableCell align="center"> | <TableCell align="center"> | ||||
| {/* | |||||
| <Stack direction="column" spacing={1} alignItems="center"> | <Stack direction="column" spacing={1} alignItems="center"> | ||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| @@ -759,6 +778,7 @@ const LotTable: React.FC<LotTableProps> = ({ | |||||
| {t("Reject")} | {t("Reject")} | ||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| */} | |||||
| {/*} | {/*} | ||||
| </TableCell> | </TableCell> | ||||
| @@ -774,10 +794,12 @@ const LotTable: React.FC<LotTableProps> = ({ | |||||
| }} | }} | ||||
| disabled={ | disabled={ | ||||
| (lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') || | |||||
| (lot.lotAvailability === 'expired' || | |||||
| lot.lotAvailability === 'status_unavailable' || | |||||
| lot.lotAvailability === 'rejected') || // ✅ 添加 rejected | |||||
| !pickQtyData[selectedRowId!]?.[lot.lotId] || | !pickQtyData[selectedRowId!]?.[lot.lotId] || | ||||
| !lot.stockOutLineStatus || // Must have stock out line | |||||
| !['pending','checked', 'partially_completed'].includes(lot.stockOutLineStatus.toLowerCase()) // Only these statuses | |||||
| !lot.stockOutLineStatus || | |||||
| !['pending','checked', 'partially_completed'].includes(lot.stockOutLineStatus.toLowerCase()) | |||||
| } | } | ||||
| // ✅ Allow submission for available AND insufficient_stock lots | // ✅ Allow submission for available AND insufficient_stock lots | ||||
| sx={{ | sx={{ | ||||
| @@ -838,6 +860,22 @@ const LotTable: React.FC<LotTableProps> = ({ | |||||
| lot={selectedLotForQr} | lot={selectedLotForQr} | ||||
| onQrCodeSubmit={handleQrCodeSubmit} | onQrCodeSubmit={handleQrCodeSubmit} | ||||
| /> | /> | ||||
| {/* ✅ Pick Execution Form Modal */} | |||||
| {pickExecutionFormOpen && selectedLotForExecutionForm && selectedRow && ( | |||||
| <PickExecutionForm | |||||
| open={pickExecutionFormOpen} | |||||
| onClose={() => { | |||||
| setPickExecutionFormOpen(false); | |||||
| setSelectedLotForExecutionForm(null); | |||||
| }} | |||||
| onSubmit={handlePickExecutionFormSubmit} | |||||
| selectedLot={selectedLotForExecutionForm} | |||||
| selectedPickOrderLine={selectedRow} | |||||
| pickOrderId={selectedRow.pickOrderId} | |||||
| pickOrderCreateDate={new Date()} | |||||
| /> | |||||
| )} | |||||
| </> | </> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -46,6 +46,7 @@ import { | |||||
| createStockOutLine, | createStockOutLine, | ||||
| updateStockOutLineStatus, | updateStockOutLineStatus, | ||||
| resuggestPickOrder, | resuggestPickOrder, | ||||
| checkAndCompletePickOrderByConsoCode, | |||||
| } from "@/app/api/pickOrder/actions"; | } from "@/app/api/pickOrder/actions"; | ||||
| import { EditNote } from "@mui/icons-material"; | import { EditNote } from "@mui/icons-material"; | ||||
| import { fetchNameList, NameList } from "@/app/api/user/actions"; | import { fetchNameList, NameList } from "@/app/api/user/actions"; | ||||
| @@ -69,9 +70,10 @@ import dayjs from "dayjs"; | |||||
| import { dummyQCData } from "../PoDetail/dummyQcTemplate"; | import { dummyQCData } from "../PoDetail/dummyQcTemplate"; | ||||
| import { CreateStockOutLine } from "@/app/api/pickOrder/actions"; | import { CreateStockOutLine } from "@/app/api/pickOrder/actions"; | ||||
| import LotTable from './LotTable'; | import LotTable from './LotTable'; | ||||
| import PickOrderDetailsTable from './PickOrderDetailsTable'; // ✅ Import the new component | |||||
| import { updateInventoryLotLineStatus, updateInventoryStatus, updateInventoryLotLineQuantities } from "@/app/api/inventory/actions"; | import { updateInventoryLotLineStatus, updateInventoryStatus, updateInventoryLotLineQuantities } from "@/app/api/inventory/actions"; | ||||
| import { useSession } from "next-auth/react"; // ✅ Add session import | |||||
| import { SessionWithTokens } from "@/config/authConfig"; // ✅ Add custom session type | |||||
| import { useSession } from "next-auth/react"; | |||||
| import { SessionWithTokens } from "@/config/authConfig"; | |||||
| interface Props { | interface Props { | ||||
| filterArgs: Record<string, any>; | filterArgs: Record<string, any>; | ||||
| @@ -85,11 +87,14 @@ interface LotPickData { | |||||
| location: string; | location: string; | ||||
| stockUnit: string; | stockUnit: string; | ||||
| inQty: number; | inQty: number; | ||||
| outQty: number; | |||||
| holdQty: number; | |||||
| totalPickedByAllPickOrders: number; | |||||
| availableQty: number; | availableQty: number; | ||||
| requiredQty: number; | requiredQty: number; | ||||
| actualPickQty: number; | actualPickQty: number; | ||||
| lotStatus: string; | lotStatus: string; | ||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||||
| stockOutLineId?: number; | stockOutLineId?: number; | ||||
| stockOutLineStatus?: string; | stockOutLineStatus?: string; | ||||
| stockOutLineQty?: number; | stockOutLineQty?: number; | ||||
| @@ -104,9 +109,8 @@ interface PickQtyData { | |||||
| const PickExecution: React.FC<Props> = ({ filterArgs }) => { | const PickExecution: React.FC<Props> = ({ filterArgs }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; // ✅ Add session | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||||
| // ✅ Get current user ID from session with proper typing | |||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const [filteredPickOrders, setFilteredPickOrders] = useState( | const [filteredPickOrders, setFilteredPickOrders] = useState( | ||||
| @@ -141,11 +145,10 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } | null>(null); | } | null>(null); | ||||
| const [selectedLotForQc, setSelectedLotForQc] = useState<LotPickData | null>(null); | const [selectedLotForQc, setSelectedLotForQc] = useState<LotPickData | null>(null); | ||||
| // ✅ Add lot selection state variables | |||||
| const [selectedLotRowId, setSelectedLotRowId] = useState<string | null>(null); | const [selectedLotRowId, setSelectedLotRowId] = useState<string | null>(null); | ||||
| const [selectedLotId, setSelectedLotId] = useState<number | null>(null); | const [selectedLotId, setSelectedLotId] = useState<number | null>(null); | ||||
| // 新增:分页控制器 | |||||
| // ✅ Keep only the main table paging controller | |||||
| const [mainTablePagingController, setMainTablePagingController] = useState({ | const [mainTablePagingController, setMainTablePagingController] = useState({ | ||||
| pageNum: 0, | pageNum: 0, | ||||
| pageSize: 10, | pageSize: 10, | ||||
| @@ -155,7 +158,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| pageSize: 10, | pageSize: 10, | ||||
| }); | }); | ||||
| // Add missing search state variables | |||||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | ||||
| const [originalPickOrderData, setOriginalPickOrderData] = useState<GetPickOrderInfoResponse | null>(null); | const [originalPickOrderData, setOriginalPickOrderData] = useState<GetPickOrderInfoResponse | null>(null); | ||||
| @@ -199,6 +201,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| useEffect(() => { | useEffect(() => { | ||||
| fetchNewPageConsoPickOrder({ limit: 10, offset: 0 }, filterArgs); | fetchNewPageConsoPickOrder({ limit: 10, offset: 0 }, filterArgs); | ||||
| }, [fetchNewPageConsoPickOrder, filterArgs]); | }, [fetchNewPageConsoPickOrder, filterArgs]); | ||||
| const handleUpdateStockOutLineStatus = useCallback(async ( | const handleUpdateStockOutLineStatus = useCallback(async ( | ||||
| stockOutLineId: number, | stockOutLineId: number, | ||||
| status: string, | status: string, | ||||
| @@ -217,16 +220,15 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| if (result) { | if (result) { | ||||
| console.log("Stock out line status updated successfully:", result); | console.log("Stock out line status updated successfully:", result); | ||||
| // Refresh lot data to show updated status | |||||
| if (selectedRowId) { | if (selectedRowId) { | ||||
| handleRowSelect(selectedRowId); | handleRowSelect(selectedRowId); | ||||
| } | } | ||||
| } | } | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error updating stock out line status:", error); | console.error("Error updating stock out line status:", error); | ||||
| } | } | ||||
| }, [selectedRowId]); | }, [selectedRowId]); | ||||
| const isReleasable = useCallback((itemList: ByItemsSummary[]): boolean => { | const isReleasable = useCallback((itemList: ByItemsSummary[]): boolean => { | ||||
| let isReleasable = true; | let isReleasable = true; | ||||
| for (const item of itemList) { | for (const item of itemList) { | ||||
| @@ -260,26 +262,52 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| const handleFetchAllPickOrderDetails = useCallback(async () => { | const handleFetchAllPickOrderDetails = useCallback(async () => { | ||||
| setDetailLoading(true); | setDetailLoading(true); | ||||
| try { | try { | ||||
| // ✅ Use current user ID for filtering | |||||
| const data = await fetchAllPickOrderDetails(currentUserId); | const data = await fetchAllPickOrderDetails(currentUserId); | ||||
| setPickOrderDetails(data); | |||||
| setOriginalPickOrderData(data); // Store original data for filtering | |||||
| console.log("All Pick Order Details for user:", currentUserId, data); | console.log("All Pick Order Details for user:", currentUserId, data); | ||||
| const initialPickQtyData: PickQtyData = {}; | |||||
| data.pickOrders.forEach((pickOrder: any) => { | |||||
| pickOrder.pickOrderLines.forEach((line: any) => { | |||||
| initialPickQtyData[line.id] = {}; | |||||
| if (data && data.pickOrders) { | |||||
| setPickOrderDetails(data); | |||||
| setOriginalPickOrderData(data); | |||||
| const initialPickQtyData: PickQtyData = {}; | |||||
| data.pickOrders.forEach((pickOrder: any) => { | |||||
| pickOrder.pickOrderLines.forEach((line: any) => { | |||||
| initialPickQtyData[line.id] = {}; | |||||
| }); | |||||
| }); | }); | ||||
| }); | |||||
| setPickQtyData(initialPickQtyData); | |||||
| setPickQtyData(initialPickQtyData); | |||||
| } else { | |||||
| console.log("No pick order data returned"); | |||||
| setPickOrderDetails({ | |||||
| consoCode: null, | |||||
| pickOrders: [], | |||||
| items: [] | |||||
| }); | |||||
| setOriginalPickOrderData({ | |||||
| consoCode: null, | |||||
| pickOrders: [], | |||||
| items: [] | |||||
| }); | |||||
| setPickQtyData({}); | |||||
| } | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error fetching all pick order details:", error); | console.error("Error fetching all pick order details:", error); | ||||
| setPickOrderDetails({ | |||||
| consoCode: null, | |||||
| pickOrders: [], | |||||
| items: [] | |||||
| }); | |||||
| setOriginalPickOrderData({ | |||||
| consoCode: null, | |||||
| pickOrders: [], | |||||
| items: [] | |||||
| }); | |||||
| setPickQtyData({}); | |||||
| } finally { | } finally { | ||||
| setDetailLoading(false); | setDetailLoading(false); | ||||
| } | } | ||||
| }, [currentUserId]); // ✅ Add currentUserId as dependency | |||||
| }, [currentUserId]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| handleFetchAllPickOrderDetails(); | handleFetchAllPickOrderDetails(); | ||||
| @@ -331,7 +359,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| const handlePickQtyChange = useCallback((lineId: number, lotId: number, value: number | string) => { | const handlePickQtyChange = useCallback((lineId: number, lotId: number, value: number | string) => { | ||||
| console.log("Changing pick qty:", { lineId, lotId, value }); | console.log("Changing pick qty:", { lineId, lotId, value }); | ||||
| // ✅ Handle both number and string values | |||||
| const numericValue = typeof value === 'string' ? (value === '' ? 0 : parseInt(value, 10)) : value; | const numericValue = typeof value === 'string' ? (value === '' ? 0 : parseInt(value, 10)) : value; | ||||
| setPickQtyData(prev => { | setPickQtyData(prev => { | ||||
| @@ -351,24 +378,28 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| const qty = pickQtyData[lineId]?.[lotId] || 0; | const qty = pickQtyData[lineId]?.[lotId] || 0; | ||||
| console.log(`提交拣货数量: Line ${lineId}, Lot ${lotId}, Qty ${qty}`); | console.log(`提交拣货数量: Line ${lineId}, Lot ${lotId}, Qty ${qty}`); | ||||
| // ✅ Find the stock out line for this lot | |||||
| const selectedLot = lotData.find(lot => lot.lotId === lotId); | const selectedLot = lotData.find(lot => lot.lotId === lotId); | ||||
| if (!selectedLot?.stockOutLineId) { | if (!selectedLot?.stockOutLineId) { | ||||
| return; | return; | ||||
| } | } | ||||
| try { | try { | ||||
| // ✅ Only two statuses: partially_completed or completed | |||||
| let newStatus = 'partially_completed'; // Default status | |||||
| // ✅ FIXED: 计算累计拣货数量 | |||||
| const totalPickedForThisLot = (selectedLot.actualPickQty || 0) + qty; | |||||
| console.log(" DEBUG - Previous picked:", selectedLot.actualPickQty || 0); | |||||
| console.log("🔍 DEBUG - Current submit:", qty); | |||||
| console.log("🔍 DEBUG - Total picked:", totalPickedForThisLot); | |||||
| console.log("�� DEBUG - Required qty:", selectedLot.requiredQty); | |||||
| if (qty >= selectedLot.requiredQty) { | |||||
| newStatus = 'completed'; // Full quantity picked | |||||
| // ✅ FIXED: 状态应该基于累计拣货数量 | |||||
| let newStatus = 'partially_completed'; | |||||
| if (totalPickedForThisLot >= selectedLot.requiredQty) { | |||||
| newStatus = 'completed'; | |||||
| } | } | ||||
| // If qty < requiredQty, stays as 'partially_completed' | |||||
| // ✅ Function 1: Update stock out line with new status and quantity | |||||
| console.log("�� DEBUG - Calculated status:", newStatus); | |||||
| try { | try { | ||||
| // ✅ Function 1: Update stock out line with new status and quantity | |||||
| const stockOutLineUpdate = await updateStockOutLineStatus({ | const stockOutLineUpdate = await updateStockOutLineStatus({ | ||||
| id: selectedLot.stockOutLineId, | id: selectedLot.stockOutLineId, | ||||
| status: newStatus, | status: newStatus, | ||||
| @@ -379,10 +410,9 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("❌ Error updating stock out line:", error); | console.error("❌ Error updating stock out line:", error); | ||||
| return; // Stop execution if this fails | |||||
| return; | |||||
| } | } | ||||
| // ✅ Function 2: Update inventory lot line (balance hold_qty and out_qty) | |||||
| if (qty > 0) { | if (qty > 0) { | ||||
| const inventoryLotLineUpdate = await updateInventoryLotLineQuantities({ | const inventoryLotLineUpdate = await updateInventoryLotLineQuantities({ | ||||
| inventoryLotLineId: lotId, | inventoryLotLineId: lotId, | ||||
| @@ -394,26 +424,74 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| console.log("Inventory lot line updated:", inventoryLotLineUpdate); | console.log("Inventory lot line updated:", inventoryLotLineUpdate); | ||||
| } | } | ||||
| // ✅ Function 3: Handle inventory table onhold if needed | |||||
| // ✅ RE-ENABLE: Check if pick order should be completed | |||||
| if (newStatus === 'completed') { | if (newStatus === 'completed') { | ||||
| // All required quantity picked - might need to update inventory status | |||||
| // Note: We'll handle inventory update in a separate function or after selectedRow is available | |||||
| console.log("Completed status - inventory update needed but selectedRow not available yet"); | |||||
| console.log("✅ Stock out line completed, checking if entire pick order is complete..."); | |||||
| // ✅ 添加调试日志来查看所有 pick orders 的 consoCode | |||||
| console.log("📋 DEBUG - All pick orders and their consoCodes:"); | |||||
| if (pickOrderDetails) { | |||||
| pickOrderDetails.pickOrders.forEach((pickOrder, index) => { | |||||
| console.log(` Pick Order ${index + 1}: ID=${pickOrder.id}, Code=${pickOrder.code}, ConsoCode=${pickOrder.consoCode}`); | |||||
| }); | |||||
| } | |||||
| // ✅ FIXED: 直接查找 consoCode,不依赖 selectedRow | |||||
| if (pickOrderDetails) { | |||||
| let currentConsoCode: string | null = null; | |||||
| // 找到当前选中行所属的 pick order | |||||
| for (const pickOrder of pickOrderDetails.pickOrders) { | |||||
| const foundLine = pickOrder.pickOrderLines.find(line => line.id === selectedRowId); | |||||
| if (foundLine) { | |||||
| // ✅ 直接使用 pickOrder.code 作为 consoCode | |||||
| currentConsoCode = pickOrder.consoCode; | |||||
| console.log(`�� DEBUG - Found consoCode for line ${selectedRowId}: ${currentConsoCode} (from pick order ${pickOrder.id})`); | |||||
| break; | |||||
| } | |||||
| } | |||||
| if (currentConsoCode) { | |||||
| try { | |||||
| console.log(`🔍 Checking completion for consoCode: ${currentConsoCode}`); | |||||
| const completionResponse = await checkAndCompletePickOrderByConsoCode(currentConsoCode); | |||||
| console.log("�� Completion response:", completionResponse); | |||||
| if (completionResponse.message === "completed") { | |||||
| console.log("🎉 Pick order completed successfully!"); | |||||
| await handleFetchAllPickOrderDetails(); | |||||
| // 刷新当前选中的行数据 | |||||
| if (selectedRowId) { | |||||
| await handleRowSelect(selectedRowId, true); | |||||
| } | |||||
| } else if (completionResponse.message === "not completed") { | |||||
| console.log("⏳ Pick order not completed yet, more lines remaining"); | |||||
| } else { | |||||
| console.error("❌ Error checking completion:", completionResponse.message); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("❌ Error checking pick order completion:", error); | |||||
| } | |||||
| } else { | |||||
| console.warn("⚠️ No consoCode found for current pick order, cannot check completion"); | |||||
| } | |||||
| } | |||||
| } | } | ||||
| console.log("All updates completed successfully"); | console.log("All updates completed successfully"); | ||||
| // ✅ Refresh lot data to show updated quantities | |||||
| if (selectedRowId) { | if (selectedRowId) { | ||||
| await handleRowSelect(selectedRowId, true); | await handleRowSelect(selectedRowId, true); | ||||
| // Note: We'll handle refresh after the function is properly defined | |||||
| console.log("Data refresh needed but handleRowSelect not available yet"); | console.log("Data refresh needed but handleRowSelect not available yet"); | ||||
| } | } | ||||
| await handleFetchAllPickOrderDetails(); | await handleFetchAllPickOrderDetails(); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error updating pick quantity:", error); | console.error("Error updating pick quantity:", error); | ||||
| } | } | ||||
| }, [pickQtyData, lotData, selectedRowId]); | |||||
| }, [pickQtyData, lotData, selectedRowId, pickOrderDetails, handleFetchAllPickOrderDetails]); | |||||
| const getTotalPickedQty = useCallback((lineId: number) => { | const getTotalPickedQty = useCallback((lineId: number) => { | ||||
| const lineData = pickQtyData[lineId]; | const lineData = pickQtyData[lineId]; | ||||
| @@ -421,30 +499,22 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| return Object.values(lineData).reduce((sum, qty) => sum + qty, 0); | return Object.values(lineData).reduce((sum, qty) => sum + qty, 0); | ||||
| }, [pickQtyData]); | }, [pickQtyData]); | ||||
| const handleQcCheck = useCallback(async (line: GetPickOrderLineInfo, pickOrderCode: string) => { | const handleQcCheck = useCallback(async (line: GetPickOrderLineInfo, pickOrderCode: string) => { | ||||
| // ✅ Get the selected lot for QC | |||||
| if (!selectedLotId) { | if (!selectedLotId) { | ||||
| return; | return; | ||||
| } | } | ||||
| const selectedLot = lotData.find(lot => lot.lotId === selectedLotId); | const selectedLot = lotData.find(lot => lot.lotId === selectedLotId); | ||||
| if (!selectedLot) { | if (!selectedLot) { | ||||
| //alert("Selected lot not found in lot data"); | |||||
| return; | return; | ||||
| } | } | ||||
| // ✅ Check if stock out line exists | |||||
| if (!selectedLot.stockOutLineId) { | if (!selectedLot.stockOutLineId) { | ||||
| //alert("Please create a stock out line first before performing QC check"); | |||||
| return; | return; | ||||
| } | } | ||||
| setSelectedLotForQc(selectedLot); | setSelectedLotForQc(selectedLot); | ||||
| // ✅ ALWAYS use dummy data for consistent behavior | |||||
| const transformedDummyData = dummyQCData.map(item => ({ | const transformedDummyData = dummyQCData.map(item => ({ | ||||
| id: item.id, | id: item.id, | ||||
| code: item.code, | code: item.code, | ||||
| @@ -453,7 +523,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| lowerLimit: undefined, | lowerLimit: undefined, | ||||
| upperLimit: undefined, | upperLimit: undefined, | ||||
| description: item.qcDescription, | description: item.qcDescription, | ||||
| // ✅ Always reset QC result properties to undefined for fresh start | |||||
| qcPassed: undefined, | qcPassed: undefined, | ||||
| failQty: undefined, | failQty: undefined, | ||||
| remarks: undefined | remarks: undefined | ||||
| @@ -461,7 +530,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| setQcItems(transformedDummyData as QcItemWithChecks[]); | setQcItems(transformedDummyData as QcItemWithChecks[]); | ||||
| // ✅ Get existing QC results if any (for display purposes only) | |||||
| let qcResult: any[] = []; | let qcResult: any[] = []; | ||||
| try { | try { | ||||
| const rawQcResult = await fetchPickOrderQcResult(line.id); | const rawQcResult = await fetchPickOrderQcResult(line.id); | ||||
| @@ -491,7 +559,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| setSelectedItemForQc(item); | setSelectedItemForQc(item); | ||||
| }, []); | }, []); | ||||
| // 新增:处理分页变化 | |||||
| // ✅ Main table pagination handlers | |||||
| const handleMainTablePageChange = useCallback((event: unknown, newPage: number) => { | const handleMainTablePageChange = useCallback((event: unknown, newPage: number) => { | ||||
| setMainTablePagingController(prev => ({ | setMainTablePagingController(prev => ({ | ||||
| ...prev, | ...prev, | ||||
| @@ -522,31 +590,27 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| }); | }); | ||||
| }, []); | }, []); | ||||
| // ✅ Fix lot selection logic | |||||
| const handleLotSelection = useCallback((uniqueLotId: string, lotId: number) => { | const handleLotSelection = useCallback((uniqueLotId: string, lotId: number) => { | ||||
| console.log("=== DEBUG: Lot Selection ==="); | console.log("=== DEBUG: Lot Selection ==="); | ||||
| console.log("uniqueLotId:", uniqueLotId); | console.log("uniqueLotId:", uniqueLotId); | ||||
| console.log("lotId (inventory lot line ID):", lotId); | console.log("lotId (inventory lot line ID):", lotId); | ||||
| // Find the selected lot data | |||||
| const selectedLot = lotData.find(lot => lot.lotId === lotId); | const selectedLot = lotData.find(lot => lot.lotId === lotId); | ||||
| console.log("Selected lot data:", selectedLot); | console.log("Selected lot data:", selectedLot); | ||||
| // If clicking the same lot, unselect it | |||||
| if (selectedLotRowId === uniqueLotId) { | if (selectedLotRowId === uniqueLotId) { | ||||
| setSelectedLotRowId(null); | setSelectedLotRowId(null); | ||||
| setSelectedLotId(null); | setSelectedLotId(null); | ||||
| } else { | } else { | ||||
| // Select the new lot | |||||
| setSelectedLotRowId(uniqueLotId); | setSelectedLotRowId(uniqueLotId); | ||||
| setSelectedLotId(lotId); | setSelectedLotId(lotId); | ||||
| } | } | ||||
| }, [selectedLotRowId]); | }, [selectedLotRowId]); | ||||
| // ✅ Add function to handle row selection that resets lot selection | |||||
| const handleRowSelect = useCallback(async (lineId: number, preserveLotSelection: boolean = false) => { | const handleRowSelect = useCallback(async (lineId: number, preserveLotSelection: boolean = false) => { | ||||
| setSelectedRowId(lineId); | setSelectedRowId(lineId); | ||||
| // ✅ Only reset lot selection if not preserving | |||||
| if (!preserveLotSelection) { | if (!preserveLotSelection) { | ||||
| setSelectedLotRowId(null); | setSelectedLotRowId(null); | ||||
| setSelectedLotId(null); | setSelectedLotId(null); | ||||
| @@ -557,13 +621,16 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| console.log("Lot details from API:", lotDetails); | console.log("Lot details from API:", lotDetails); | ||||
| const realLotData: LotPickData[] = lotDetails.map((lot: any) => ({ | const realLotData: LotPickData[] = lotDetails.map((lot: any) => ({ | ||||
| id: lot.id, // This should be the unique row ID for the table | |||||
| lotId: lot.lotId, // This is the inventory lot line ID | |||||
| id: lot.id, | |||||
| lotId: lot.lotId, | |||||
| lotNo: lot.lotNo, | lotNo: lot.lotNo, | ||||
| expiryDate: lot.expiryDate ? new Date(lot.expiryDate).toLocaleDateString() : 'N/A', | expiryDate: lot.expiryDate ? new Date(lot.expiryDate).toLocaleDateString() : 'N/A', | ||||
| location: lot.location, | location: lot.location, | ||||
| stockUnit: lot.stockUnit, | stockUnit: lot.stockUnit, | ||||
| inQty: lot.inQty, | inQty: lot.inQty, | ||||
| outQty: lot.outQty, | |||||
| holdQty: lot.holdQty, | |||||
| totalPickedByAllPickOrders: lot.totalPickedByAllPickOrders, | |||||
| availableQty: lot.availableQty, | availableQty: lot.availableQty, | ||||
| requiredQty: lot.requiredQty, | requiredQty: lot.requiredQty, | ||||
| actualPickQty: lot.actualPickQty || 0, | actualPickQty: lot.actualPickQty || 0, | ||||
| @@ -581,33 +648,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| const prepareMainTableData = useMemo(() => { | |||||
| if (!pickOrderDetails) return []; | |||||
| return pickOrderDetails.pickOrders.flatMap((pickOrder) => | |||||
| pickOrder.pickOrderLines.map((line) => { | |||||
| // 修复:处理 availableQty 可能为 null 的情况 | |||||
| const availableQty = line.availableQty ?? 0; | |||||
| const balanceToPick = availableQty - line.requiredQty; | |||||
| // ✅ 使用 dayjs 进行一致的日期格式化 | |||||
| const formattedTargetDate = pickOrder.targetDate | |||||
| ? dayjs(pickOrder.targetDate).format('YYYY-MM-DD') | |||||
| : 'N/A'; | |||||
| return { | |||||
| ...line, | |||||
| pickOrderCode: pickOrder.code, | |||||
| targetDate: formattedTargetDate, // ✅ 使用 dayjs 格式化的日期 | |||||
| balanceToPick: balanceToPick, | |||||
| pickedQty: line.pickedQty, | |||||
| // 确保 availableQty 不为 null | |||||
| availableQty: availableQty, | |||||
| }; | |||||
| }) | |||||
| ); | |||||
| }, [pickOrderDetails]); | |||||
| const prepareLotTableData = useMemo(() => { | const prepareLotTableData = useMemo(() => { | ||||
| return lotData.map((lot) => ({ | return lotData.map((lot) => ({ | ||||
| ...lot, | ...lot, | ||||
| @@ -615,13 +655,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| })); | })); | ||||
| }, [lotData]); | }, [lotData]); | ||||
| // 新增:分页数据 | |||||
| const paginatedMainTableData = useMemo(() => { | |||||
| const startIndex = mainTablePagingController.pageNum * mainTablePagingController.pageSize; | |||||
| const endIndex = startIndex + mainTablePagingController.pageSize; | |||||
| return prepareMainTableData.slice(startIndex, endIndex); | |||||
| }, [prepareMainTableData, mainTablePagingController]); | |||||
| const paginatedLotTableData = useMemo(() => { | const paginatedLotTableData = useMemo(() => { | ||||
| const startIndex = lotTablePagingController.pageNum * lotTablePagingController.pageSize; | const startIndex = lotTablePagingController.pageNum * lotTablePagingController.pageSize; | ||||
| const endIndex = startIndex + lotTablePagingController.pageSize; | const endIndex = startIndex + lotTablePagingController.pageSize; | ||||
| @@ -634,11 +667,13 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| for (const pickOrder of pickOrderDetails.pickOrders) { | for (const pickOrder of pickOrderDetails.pickOrders) { | ||||
| const foundLine = pickOrder.pickOrderLines.find(line => line.id === selectedRowId); | const foundLine = pickOrder.pickOrderLines.find(line => line.id === selectedRowId); | ||||
| if (foundLine) { | if (foundLine) { | ||||
| return { ...foundLine, pickOrderCode: pickOrder.code }; | |||||
| return { ...foundLine, pickOrderCode: pickOrder.code, | |||||
| pickOrderId: pickOrder.id }; | |||||
| } | } | ||||
| } | } | ||||
| return null; | return null; | ||||
| }, [selectedRowId, pickOrderDetails]); | }, [selectedRowId, pickOrderDetails]); | ||||
| const handleInventoryUpdate = useCallback(async (itemId: number, lotId: number, qty: number) => { | const handleInventoryUpdate = useCallback(async (itemId: number, lotId: number, qty: number) => { | ||||
| try { | try { | ||||
| const inventoryUpdate = await updateInventoryStatus({ | const inventoryUpdate = await updateInventoryStatus({ | ||||
| @@ -653,16 +688,17 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| console.error("Error updating inventory status:", error); | console.error("Error updating inventory status:", error); | ||||
| } | } | ||||
| }, []); | }, []); | ||||
| const handleLotDataRefresh = useCallback(async () => { | const handleLotDataRefresh = useCallback(async () => { | ||||
| if (selectedRowId) { | if (selectedRowId) { | ||||
| try { | try { | ||||
| await handleRowSelect(selectedRowId, true); // Preserve lot selection | |||||
| await handleRowSelect(selectedRowId, true); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error refreshing lot data:", error); | console.error("Error refreshing lot data:", error); | ||||
| } | } | ||||
| } | } | ||||
| }, [selectedRowId, handleRowSelect]); | }, [selectedRowId, handleRowSelect]); | ||||
| // ✅ Add this function after handleRowSelect is defined | |||||
| const handleDataRefresh = useCallback(async () => { | const handleDataRefresh = useCallback(async () => { | ||||
| if (selectedRowId) { | if (selectedRowId) { | ||||
| try { | try { | ||||
| @@ -672,15 +708,14 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } | } | ||||
| } | } | ||||
| }, [selectedRowId, handleRowSelect]); | }, [selectedRowId, handleRowSelect]); | ||||
| const handleInsufficientStock = useCallback(async () => { | const handleInsufficientStock = useCallback(async () => { | ||||
| console.log("Insufficient stock - testing resuggest API"); | console.log("Insufficient stock - testing resuggest API"); | ||||
| if (!selectedRowId || !pickOrderDetails) { | if (!selectedRowId || !pickOrderDetails) { | ||||
| // alert("Please select a pick order line first"); | |||||
| return; | return; | ||||
| } | } | ||||
| // Find the pick order ID from the selected row | |||||
| let pickOrderId: number | null = null; | let pickOrderId: number | null = null; | ||||
| for (const pickOrder of pickOrderDetails.pickOrders) { | for (const pickOrder of pickOrderDetails.pickOrders) { | ||||
| const foundLine = pickOrder.pickOrderLines.find(line => line.id === selectedRowId); | const foundLine = pickOrder.pickOrderLines.find(line => line.id === selectedRowId); | ||||
| @@ -691,55 +726,41 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } | } | ||||
| if (!pickOrderId) { | if (!pickOrderId) { | ||||
| // alert("Could not find pick order ID for selected line"); | |||||
| return; | return; | ||||
| } | } | ||||
| try { | try { | ||||
| console.log(`Calling resuggest API for pick order ID: ${pickOrderId}`); | console.log(`Calling resuggest API for pick order ID: ${pickOrderId}`); | ||||
| // Call the resuggest API | |||||
| const result = await resuggestPickOrder(pickOrderId); | const result = await resuggestPickOrder(pickOrderId); | ||||
| console.log("Resuggest API result:", result); | console.log("Resuggest API result:", result); | ||||
| if (result.code === "SUCCESS") { | if (result.code === "SUCCESS") { | ||||
| //alert(`✅ Resuggest successful!\n\nMessage: ${result.message}\n\nRemoved: ${result.message?.includes('Removed') ? 'Yes' : 'No'}\nCreated: ${result.message?.includes('created') ? 'Yes' : 'No'}`); | |||||
| // Refresh the lot data to show the new suggestions | |||||
| if (selectedRowId) { | if (selectedRowId) { | ||||
| await handleRowSelect(selectedRowId); | await handleRowSelect(selectedRowId); | ||||
| } | } | ||||
| // Also refresh the main pick order details | |||||
| await handleFetchAllPickOrderDetails(); | await handleFetchAllPickOrderDetails(); | ||||
| } else { | |||||
| //alert(`❌ Resuggest failed!\n\nError: ${result.message}`); | |||||
| } | } | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error calling resuggest API:", error); | console.error("Error calling resuggest API:", error); | ||||
| //alert(`❌ Error calling resuggest API:\n\n${error instanceof Error ? error.message : 'Unknown error'}`); | |||||
| } | } | ||||
| }, [selectedRowId, pickOrderDetails, handleRowSelect, handleFetchAllPickOrderDetails]); | }, [selectedRowId, pickOrderDetails, handleRowSelect, handleFetchAllPickOrderDetails]); | ||||
| // Add this function (around line 350) | |||||
| const hasSelectedLots = useCallback((lineId: number) => { | const hasSelectedLots = useCallback((lineId: number) => { | ||||
| return selectedLotRowId !== null; | return selectedLotRowId !== null; | ||||
| }, [selectedLotRowId]); | }, [selectedLotRowId]); | ||||
| // Add state for showing input body | |||||
| const [showInputBody, setShowInputBody] = useState(false); | const [showInputBody, setShowInputBody] = useState(false); | ||||
| const [selectedLotForInput, setSelectedLotForInput] = useState<LotPickData | null>(null); | const [selectedLotForInput, setSelectedLotForInput] = useState<LotPickData | null>(null); | ||||
| // Add function to handle lot selection for input body display | |||||
| const handleLotSelectForInput = useCallback((lot: LotPickData) => { | const handleLotSelectForInput = useCallback((lot: LotPickData) => { | ||||
| setSelectedLotForInput(lot); | setSelectedLotForInput(lot); | ||||
| setShowInputBody(true); | setShowInputBody(true); | ||||
| }, []); | }, []); | ||||
| // Add function to generate input body | |||||
| const generateInputBody = useCallback((): CreateStockOutLine | null => { | const generateInputBody = useCallback((): CreateStockOutLine | null => { | ||||
| if (!selectedLotForInput || !selectedRowId || !selectedRow || !pickOrderDetails?.consoCode) { | if (!selectedLotForInput || !selectedRowId || !selectedRow || !pickOrderDetails?.consoCode) { | ||||
| return null; | return null; | ||||
| @@ -753,9 +774,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| }; | }; | ||||
| }, [selectedLotForInput, selectedRowId, selectedRow, pickOrderDetails?.consoCode]); | }, [selectedLotForInput, selectedRowId, selectedRow, pickOrderDetails?.consoCode]); | ||||
| // Add function to handle create stock out line | |||||
| const handleCreateStockOutLine = useCallback(async (inventoryLotLineId: number) => { | const handleCreateStockOutLine = useCallback(async (inventoryLotLineId: number) => { | ||||
| if (!selectedRowId || !pickOrderDetails?.consoCode) { | if (!selectedRowId || !pickOrderDetails?.consoCode) { | ||||
| console.error("Missing required data for creating stock out line."); | console.error("Missing required data for creating stock out line."); | ||||
| @@ -763,12 +781,21 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } | } | ||||
| try { | try { | ||||
| // ✅ Store current lot selection before refresh | |||||
| const currentSelectedLotRowId = selectedLotRowId; | const currentSelectedLotRowId = selectedLotRowId; | ||||
| const currentSelectedLotId = selectedLotId; | const currentSelectedLotId = selectedLotId; | ||||
| let correctConsoCode: string | null = null; | |||||
| if (pickOrderDetails && selectedRowId) { | |||||
| for (const pickOrder of pickOrderDetails.pickOrders) { | |||||
| const foundLine = pickOrder.pickOrderLines.find(line => line.id === selectedRowId); | |||||
| if (foundLine) { | |||||
| correctConsoCode = pickOrder.consoCode; | |||||
| console.log(`🔍 Found consoCode for line ${selectedRowId}: ${correctConsoCode} (from pick order ${pickOrder.id})`); | |||||
| break; | |||||
| } | |||||
| } | |||||
| } | |||||
| const stockOutLineData: CreateStockOutLine = { | const stockOutLineData: CreateStockOutLine = { | ||||
| consoCode: pickOrderDetails.consoCode, | |||||
| consoCode: correctConsoCode || pickOrderDetails?.consoCode || "", // ✅ 使用正确的 consoCode | |||||
| pickOrderLineId: selectedRowId, | pickOrderLineId: selectedRowId, | ||||
| inventoryLotLineId: inventoryLotLineId, | inventoryLotLineId: inventoryLotLineId, | ||||
| qty: 0.0 | qty: 0.0 | ||||
| @@ -777,7 +804,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| console.log("=== STOCK OUT LINE CREATION DEBUG ==="); | console.log("=== STOCK OUT LINE CREATION DEBUG ==="); | ||||
| console.log("Input Body:", JSON.stringify(stockOutLineData, null, 2)); | console.log("Input Body:", JSON.stringify(stockOutLineData, null, 2)); | ||||
| // ✅ Use the correct API function | |||||
| const result = await createStockOutLine(stockOutLineData); | const result = await createStockOutLine(stockOutLineData); | ||||
| console.log("Stock Out Line created:", result); | console.log("Stock Out Line created:", result); | ||||
| @@ -785,16 +811,13 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| if (result) { | if (result) { | ||||
| console.log("Stock out line created successfully:", result); | console.log("Stock out line created successfully:", result); | ||||
| // ✅ Auto-refresh data after successful creation | |||||
| console.log("🔄 Refreshing data after stock out line creation..."); | console.log("🔄 Refreshing data after stock out line creation..."); | ||||
| try { | try { | ||||
| // ✅ Refresh lot data for the selected row (maintains selection) | |||||
| if (selectedRowId) { | if (selectedRowId) { | ||||
| await handleRowSelect(selectedRowId, true); // ✅ Preserve lot selection | |||||
| await handleRowSelect(selectedRowId, true); | |||||
| } | } | ||||
| // ✅ Refresh main pick order details | |||||
| await handleFetchAllPickOrderDetails(); | await handleFetchAllPickOrderDetails(); | ||||
| console.log("✅ Data refresh completed - lot selection maintained!"); | console.log("✅ Data refresh completed - lot selection maintained!"); | ||||
| @@ -802,7 +825,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| console.error("❌ Error refreshing data:", refreshError); | console.error("❌ Error refreshing data:", refreshError); | ||||
| } | } | ||||
| setShowInputBody(false); // Hide preview after successful creation | |||||
| setShowInputBody(false); | |||||
| } else { | } else { | ||||
| console.error("Failed to create stock out line: No response"); | console.error("Failed to create stock out line: No response"); | ||||
| } | } | ||||
| @@ -811,22 +834,17 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } | } | ||||
| }, [selectedRowId, pickOrderDetails?.consoCode, handleRowSelect, handleFetchAllPickOrderDetails, selectedLotRowId, selectedLotId]); | }, [selectedRowId, pickOrderDetails?.consoCode, handleRowSelect, handleFetchAllPickOrderDetails, selectedLotRowId, selectedLotId]); | ||||
| // ✅ New function to refresh data while preserving lot selection | |||||
| const handleRefreshDataPreserveSelection = useCallback(async () => { | const handleRefreshDataPreserveSelection = useCallback(async () => { | ||||
| if (!selectedRowId) return; | if (!selectedRowId) return; | ||||
| // ✅ Store current lot selection | |||||
| const currentSelectedLotRowId = selectedLotRowId; | const currentSelectedLotRowId = selectedLotRowId; | ||||
| const currentSelectedLotId = selectedLotId; | const currentSelectedLotId = selectedLotId; | ||||
| try { | try { | ||||
| // ✅ Refresh lot data | |||||
| await handleRowSelect(selectedRowId, true); // ✅ Preserve selection | |||||
| await handleRowSelect(selectedRowId, true); | |||||
| // ✅ Refresh main pick order details | |||||
| await handleFetchAllPickOrderDetails(); | await handleFetchAllPickOrderDetails(); | ||||
| // ✅ Restore lot selection | |||||
| setSelectedLotRowId(currentSelectedLotRowId); | setSelectedLotRowId(currentSelectedLotRowId); | ||||
| setSelectedLotId(currentSelectedLotId); | setSelectedLotId(currentSelectedLotId); | ||||
| @@ -836,106 +854,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } | } | ||||
| }, [selectedRowId, selectedLotRowId, selectedLotId, handleRowSelect, handleFetchAllPickOrderDetails]); | }, [selectedRowId, selectedLotRowId, selectedLotId, handleRowSelect, handleFetchAllPickOrderDetails]); | ||||
| // 自定义主表格组件 | |||||
| const CustomMainTable = () => { | |||||
| return ( | |||||
| <> | |||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Selected")}</TableCell> | |||||
| <TableCell>{t("Pick Order Code")}</TableCell> | |||||
| <TableCell>{t("Item Code")}</TableCell> | |||||
| <TableCell>{t("Item Name")}</TableCell> | |||||
| <TableCell align="right">{t("Order Quantity")}</TableCell> | |||||
| <TableCell align="right">{t("Current Stock")}</TableCell> | |||||
| <TableCell align="right">{t("Qty Already Picked")}</TableCell> | |||||
| <TableCell align="right">{t("Stock Unit")}</TableCell> | |||||
| <TableCell align="right">{t("Target Date")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {paginatedMainTableData.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={7} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data available")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| paginatedMainTableData.map((line) => { | |||||
| // 修复:处理 availableQty 可能为 null 的情况,并确保负值显示为 0 | |||||
| const availableQty = line.availableQty ?? 0; | |||||
| const balanceToPick = Math.max(0, availableQty - line.requiredQty); // 确保不为负数 | |||||
| const totalPickedQty = getTotalPickedQty(line.id); | |||||
| const actualPickedQty = line.pickedQty ?? 0; | |||||
| return ( | |||||
| <TableRow | |||||
| key={line.id} | |||||
| sx={{ | |||||
| "& > *": { borderBottom: "unset" }, | |||||
| color: "black", | |||||
| backgroundColor: selectedRowId === line.id ? "action.selected" : "inherit", | |||||
| cursor: "pointer", | |||||
| "&:hover": { | |||||
| backgroundColor: "action.hover", | |||||
| }, | |||||
| }} | |||||
| > | |||||
| <TableCell align="center" sx={{ width: "60px" }}> | |||||
| <Checkbox | |||||
| checked={selectedRowId === line.id} | |||||
| onChange={(e) => { | |||||
| if (e.target.checked) { | |||||
| handleRowSelect(line.id); | |||||
| } else { | |||||
| setSelectedRowId(null); | |||||
| setLotData([]); | |||||
| } | |||||
| }} | |||||
| onClick={(e) => e.stopPropagation()} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="left">{line.pickOrderCode}</TableCell> | |||||
| <TableCell align="left">{line.itemCode}</TableCell> | |||||
| <TableCell align="left">{line.itemName}</TableCell> | |||||
| <TableCell align="right">{line.requiredQty}</TableCell> | |||||
| <TableCell align="right" sx={{ | |||||
| color: availableQty >= line.requiredQty ? 'success.main' : 'error.main', | |||||
| }}> | |||||
| {availableQty.toLocaleString()} {/* 添加千位分隔符 */} | |||||
| </TableCell> | |||||
| <TableCell align="right">{actualPickedQty}</TableCell> | |||||
| <TableCell align="right">{line.uomDesc}</TableCell> | |||||
| <TableCell align="right">{line.targetDate}</TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={prepareMainTableData.length} | |||||
| page={mainTablePagingController.pageNum} | |||||
| rowsPerPage={mainTablePagingController.pageSize} | |||||
| onPageChange={handleMainTablePageChange} | |||||
| onRowsPerPageChange={handleMainTablePageSizeChange} | |||||
| rowsPerPageOptions={[10, 25, 50]} | |||||
| labelRowsPerPage={t("Rows per page")} | |||||
| labelDisplayedRows={({ from, to, count }) => | |||||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||||
| } | |||||
| /> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| // Add search criteria | |||||
| // ✅ Search criteria | |||||
| const searchCriteria: Criterion<any>[] = useMemo( | const searchCriteria: Criterion<any>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| { | { | ||||
| @@ -963,7 +882,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| [t], | [t], | ||||
| ); | ); | ||||
| // Add search handler | |||||
| // ✅ Search handler | |||||
| const handleSearch = useCallback((query: Record<string, any>) => { | const handleSearch = useCallback((query: Record<string, any>) => { | ||||
| setSearchQuery({ ...query }); | setSearchQuery({ ...query }); | ||||
| console.log("Search query:", query); | console.log("Search query:", query); | ||||
| @@ -971,7 +890,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| if (!originalPickOrderData) return; | if (!originalPickOrderData) return; | ||||
| const filtered = originalPickOrderData.pickOrders.filter((pickOrder) => { | const filtered = originalPickOrderData.pickOrders.filter((pickOrder) => { | ||||
| // Check if any line in this pick order matches the search criteria | |||||
| return pickOrder.pickOrderLines.some((line) => { | return pickOrder.pickOrderLines.some((line) => { | ||||
| const itemCodeMatch = !query.itemCode || | const itemCodeMatch = !query.itemCode || | ||||
| line.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase()); | line.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase()); | ||||
| @@ -982,12 +900,10 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| const pickOrderCodeMatch = !query.pickOrderCode || | const pickOrderCodeMatch = !query.pickOrderCode || | ||||
| pickOrder.code?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase()); | pickOrder.code?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase()); | ||||
| return itemCodeMatch && itemNameMatch && pickOrderCodeMatch ; | |||||
| return itemCodeMatch && itemNameMatch && pickOrderCodeMatch; | |||||
| }); | }); | ||||
| }); | }); | ||||
| // Create filtered data structure | |||||
| const filteredData: GetPickOrderInfoResponse = { | const filteredData: GetPickOrderInfoResponse = { | ||||
| ...originalPickOrderData, | ...originalPickOrderData, | ||||
| pickOrders: filtered | pickOrders: filtered | ||||
| @@ -997,7 +913,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| console.log("Filtered pick orders count:", filtered.length); | console.log("Filtered pick orders count:", filtered.length); | ||||
| }, [originalPickOrderData, t]); | }, [originalPickOrderData, t]); | ||||
| // Add reset handler | |||||
| // ✅ Reset handler | |||||
| const handleReset = useCallback(() => { | const handleReset = useCallback(() => { | ||||
| setSearchQuery({}); | setSearchQuery({}); | ||||
| if (originalPickOrderData) { | if (originalPickOrderData) { | ||||
| @@ -1005,7 +921,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } | } | ||||
| }, [originalPickOrderData]); | }, [originalPickOrderData]); | ||||
| // Add this to debug the lot data | |||||
| // ✅ Debug the lot data | |||||
| useEffect(() => { | useEffect(() => { | ||||
| console.log("Lot data:", lotData); | console.log("Lot data:", lotData); | ||||
| console.log("Pick Qty Data:", pickQtyData); | console.log("Pick Qty Data:", pickQtyData); | ||||
| @@ -1023,56 +939,55 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| /> | /> | ||||
| </Box> | </Box> | ||||
| {/* 主表格 */} | |||||
| {/* ✅ Main table using the new component */} | |||||
| <Box> | <Box> | ||||
| <Typography variant="h6" gutterBottom> | <Typography variant="h6" gutterBottom> | ||||
| {t("Pick Order Details")} | {t("Pick Order Details")} | ||||
| </Typography> | </Typography> | ||||
| {detailLoading ? ( | |||||
| <Box display="flex" justifyContent="center" alignItems="center" minHeight="200px"> | |||||
| <CircularProgress size={40} /> | |||||
| </Box> | |||||
| ) : pickOrderDetails ? ( | |||||
| <CustomMainTable /> | |||||
| ) : ( | |||||
| <Box display="flex" justifyContent="center" alignItems="center" minHeight="200px"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Loading data...")} | |||||
| </Typography> | |||||
| </Box> | |||||
| )} | |||||
| <PickOrderDetailsTable | |||||
| pickOrderDetails={pickOrderDetails} | |||||
| detailLoading={detailLoading} | |||||
| selectedRowId={selectedRowId} | |||||
| onRowSelect={handleRowSelect} | |||||
| onPageChange={handleMainTablePageChange} | |||||
| onPageSizeChange={handleMainTablePageSizeChange} | |||||
| pageNum={mainTablePagingController.pageNum} | |||||
| pageSize={mainTablePagingController.pageSize} | |||||
| /> | |||||
| </Box> | </Box> | ||||
| {/* 批次表格 - 放在主表格下方 */} | |||||
| {/* Lot table - below main table */} | |||||
| {selectedRow && ( | {selectedRow && ( | ||||
| <Box> | <Box> | ||||
| <Typography variant="h6" gutterBottom> | <Typography variant="h6" gutterBottom> | ||||
| {t("Item lot to be Pick:")} {selectedRow.pickOrderCode} - {selectedRow.itemName} | {t("Item lot to be Pick:")} {selectedRow.pickOrderCode} - {selectedRow.itemName} | ||||
| </Typography> | </Typography> | ||||
| {/* 检查是否有可用的批次数据 */} | |||||
| {lotData.length > 0 ? ( | {lotData.length > 0 ? ( | ||||
| <LotTable | <LotTable | ||||
| lotData={lotData} | |||||
| selectedRowId={selectedRowId} | |||||
| selectedRow={selectedRow} | |||||
| pickQtyData={pickQtyData} | |||||
| selectedLotRowId={selectedLotRowId} | |||||
| selectedLotId={selectedLotId} | |||||
| onLotSelection={handleLotSelection} | |||||
| onPickQtyChange={handlePickQtyChange} | |||||
| onSubmitPickQty={handleSubmitPickQty} | |||||
| onCreateStockOutLine={handleCreateStockOutLine} | |||||
| onQcCheck={handleQcCheck} | |||||
| onDataRefresh={handleFetchAllPickOrderDetails} | |||||
| onLotDataRefresh={handleLotDataRefresh} | |||||
| onLotSelectForInput={handleLotSelectForInput} | |||||
| showInputBody={showInputBody} | |||||
| setShowInputBody={setShowInputBody} | |||||
| selectedLotForInput={selectedLotForInput} | |||||
| generateInputBody={generateInputBody} | |||||
| /> | |||||
| lotData={lotData} | |||||
| selectedRowId={selectedRowId} | |||||
| selectedRow={selectedRow} | |||||
| pickQtyData={pickQtyData} | |||||
| selectedLotRowId={selectedLotRowId} | |||||
| selectedLotId={selectedLotId} | |||||
| onLotSelection={handleLotSelection} | |||||
| onPickQtyChange={handlePickQtyChange} | |||||
| onSubmitPickQty={handleSubmitPickQty} | |||||
| onCreateStockOutLine={handleCreateStockOutLine} | |||||
| onQcCheck={handleQcCheck} | |||||
| onDataRefresh={handleFetchAllPickOrderDetails} | |||||
| onLotDataRefresh={handleLotDataRefresh} | |||||
| onLotSelectForInput={handleLotSelectForInput} | |||||
| showInputBody={showInputBody} | |||||
| setShowInputBody={setShowInputBody} | |||||
| selectedLotForInput={selectedLotForInput} | |||||
| generateInputBody={generateInputBody} | |||||
| // ✅ Add missing props | |||||
| totalPickedByAllPickOrders={0} // You can calculate this from lotData if needed | |||||
| outQty={0} // You can calculate this from lotData if needed | |||||
| holdQty={0} // You can calculate this from lotData if needed | |||||
| /> | |||||
| ) : ( | ) : ( | ||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| @@ -0,0 +1,372 @@ | |||||
| // FPSMS-frontend/src/components/PickOrderSearch/PickExecutionForm.tsx | |||||
| "use client"; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| Dialog, | |||||
| DialogActions, | |||||
| DialogContent, | |||||
| DialogTitle, | |||||
| FormControl, | |||||
| Grid, | |||||
| InputLabel, | |||||
| MenuItem, | |||||
| Select, | |||||
| TextField, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import { useCallback, useEffect, useState } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { GetPickOrderLineInfo, PickExecutionIssueData } from "@/app/api/pickOrder/actions"; | |||||
| import { fetchEscalationCombo } from "@/app/api/user/actions"; | |||||
| interface LotPickData { | |||||
| id: number; | |||||
| lotId: number; | |||||
| lotNo: string; | |||||
| expiryDate: string; | |||||
| location: string; | |||||
| stockUnit: string; | |||||
| inQty: number; | |||||
| outQty: number; | |||||
| holdQty: number; | |||||
| totalPickedByAllPickOrders: number; | |||||
| availableQty: number; | |||||
| requiredQty: number; | |||||
| actualPickQty: number; | |||||
| lotStatus: string; | |||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||||
| stockOutLineId?: number; | |||||
| stockOutLineStatus?: string; | |||||
| stockOutLineQty?: number; | |||||
| } | |||||
| interface PickExecutionFormProps { | |||||
| open: boolean; | |||||
| onClose: () => void; | |||||
| onSubmit: (data: PickExecutionIssueData) => Promise<void>; | |||||
| selectedLot: LotPickData | null; | |||||
| selectedPickOrderLine: (GetPickOrderLineInfo & { pickOrderCode: string }) | null; | |||||
| pickOrderId?: number; | |||||
| pickOrderCreateDate: any; | |||||
| // ✅ Remove these props since we're not handling normal cases | |||||
| // onNormalPickSubmit?: (lineId: number, lotId: number, qty: number) => Promise<void>; | |||||
| // selectedRowId?: number | null; | |||||
| } | |||||
| // 定义错误类型 | |||||
| interface FormErrors { | |||||
| actualPickQty?: string; | |||||
| missQty?: string; | |||||
| badItemQty?: string; | |||||
| issueRemark?: string; | |||||
| handledBy?: string; | |||||
| } | |||||
| const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||||
| open, | |||||
| onClose, | |||||
| onSubmit, | |||||
| selectedLot, | |||||
| selectedPickOrderLine, | |||||
| pickOrderId, | |||||
| pickOrderCreateDate, | |||||
| // ✅ Remove these props | |||||
| // onNormalPickSubmit, | |||||
| // selectedRowId, | |||||
| }) => { | |||||
| const { t } = useTranslation(); | |||||
| const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({}); | |||||
| const [errors, setErrors] = useState<FormErrors>({}); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]); | |||||
| // 计算剩余可用数量 | |||||
| const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => { | |||||
| const remainingQty = lot.inQty - lot.outQty; | |||||
| return Math.max(0, remainingQty); | |||||
| }, []); | |||||
| const calculateRequiredQty = useCallback((lot: LotPickData) => { | |||||
| const requiredQty = lot.requiredQty-(lot.actualPickQty||0); | |||||
| return Math.max(0, requiredQty); | |||||
| }, []); | |||||
| // 获取处理人员列表 | |||||
| useEffect(() => { | |||||
| const fetchHandlers = async () => { | |||||
| try { | |||||
| const escalationCombo = await fetchEscalationCombo(); | |||||
| setHandlers(escalationCombo); | |||||
| } catch (error) { | |||||
| console.error("Error fetching handlers:", error); | |||||
| } | |||||
| }; | |||||
| fetchHandlers(); | |||||
| }, []); | |||||
| // 初始化表单数据 - 每次打开时都重新初始化 | |||||
| useEffect(() => { | |||||
| if (open && selectedLot && selectedPickOrderLine && pickOrderId) { | |||||
| const getSafeDate = (dateValue: any): string => { | |||||
| if (!dateValue) return new Date().toISOString().split('T')[0]; | |||||
| try { | |||||
| const date = new Date(dateValue); | |||||
| if (isNaN(date.getTime())) { | |||||
| return new Date().toISOString().split('T')[0]; | |||||
| } | |||||
| return date.toISOString().split('T')[0]; | |||||
| } catch { | |||||
| return new Date().toISOString().split('T')[0]; | |||||
| } | |||||
| }; | |||||
| // 计算剩余可用数量 | |||||
| const remainingAvailableQty = calculateRemainingAvailableQty(selectedLot); | |||||
| const requiredQty = calculateRequiredQty(selectedLot); | |||||
| console.log("=== PickExecutionForm Debug ==="); | |||||
| console.log("selectedLot:", selectedLot); | |||||
| console.log("inQty:", selectedLot.inQty); | |||||
| console.log("outQty:", selectedLot.outQty); | |||||
| console.log("holdQty:", selectedLot.holdQty); | |||||
| console.log("availableQty:", selectedLot.availableQty); | |||||
| console.log("calculated remainingAvailableQty:", remainingAvailableQty); | |||||
| console.log("=== End Debug ==="); | |||||
| setFormData({ | |||||
| pickOrderId: pickOrderId, | |||||
| pickOrderCode: selectedPickOrderLine.pickOrderCode, | |||||
| pickOrderCreateDate: getSafeDate(pickOrderCreateDate), | |||||
| pickExecutionDate: new Date().toISOString().split('T')[0], | |||||
| pickOrderLineId: selectedPickOrderLine.id, | |||||
| itemId: selectedPickOrderLine.itemId, | |||||
| itemCode: selectedPickOrderLine.itemCode, | |||||
| itemDescription: selectedPickOrderLine.itemName, | |||||
| lotId: selectedLot.lotId, | |||||
| lotNo: selectedLot.lotNo, | |||||
| storeLocation: selectedLot.location, | |||||
| requiredQty: selectedLot.requiredQty, | |||||
| actualPickQty: selectedLot.actualPickQty || 0, | |||||
| missQty: 0, | |||||
| badItemQty: 0, // 初始化为 0,用户需要手动输入 | |||||
| issueRemark: '', | |||||
| pickerName: '', | |||||
| handledBy: undefined, | |||||
| }); | |||||
| } | |||||
| }, [open, selectedLot, selectedPickOrderLine, pickOrderId, pickOrderCreateDate, calculateRemainingAvailableQty]); | |||||
| const handleInputChange = useCallback((field: keyof PickExecutionIssueData, value: any) => { | |||||
| setFormData(prev => ({ ...prev, [field]: value })); | |||||
| // 清除错误 | |||||
| if (errors[field as keyof FormErrors]) { | |||||
| setErrors(prev => ({ ...prev, [field]: undefined })); | |||||
| } | |||||
| }, [errors]); | |||||
| // ✅ Update form validation to require either missQty > 0 OR badItemQty > 0 | |||||
| const validateForm = (): boolean => { | |||||
| const newErrors: FormErrors = {}; | |||||
| if (formData.actualPickQty === undefined || formData.actualPickQty < 0) { | |||||
| newErrors.actualPickQty = t('pickOrder.validation.actualPickQtyRequired'); | |||||
| } | |||||
| // ✅ NEW: Require either missQty > 0 OR badItemQty > 0 (at least one issue must be reported) | |||||
| const hasMissQty = formData.missQty && formData.missQty > 0; | |||||
| const hasBadItemQty = formData.badItemQty && formData.badItemQty > 0; | |||||
| if (!hasMissQty && !hasBadItemQty) { | |||||
| newErrors.missQty = t('pickOrder.validation.mustReportMissOrBadItems'); | |||||
| newErrors.badItemQty = t('pickOrder.validation.mustReportMissOrBadItems'); | |||||
| } | |||||
| if (formData.missQty && formData.missQty < 0) { | |||||
| newErrors.missQty = t('pickOrder.validation.missQtyInvalid'); | |||||
| } | |||||
| if (formData.badItemQty && formData.badItemQty < 0) { | |||||
| newErrors.badItemQty = t('pickOrder.validation.badItemQtyInvalid'); | |||||
| } | |||||
| if (formData.badItemQty && formData.badItemQty > 0 && !formData.issueRemark) { | |||||
| newErrors.issueRemark = t('pickOrder.validation.issueRemarkRequired'); | |||||
| } | |||||
| if (formData.badItemQty && formData.badItemQty > 0 && !formData.handledBy) { | |||||
| newErrors.handledBy = t('pickOrder.validation.handlerRequired'); | |||||
| } | |||||
| setErrors(newErrors); | |||||
| return Object.keys(newErrors).length === 0; | |||||
| }; | |||||
| const handleSubmit = async () => { | |||||
| if (!validateForm() || !formData.pickOrderId) { | |||||
| return; | |||||
| } | |||||
| setLoading(true); | |||||
| try { | |||||
| await onSubmit(formData as PickExecutionIssueData); | |||||
| onClose(); | |||||
| } catch (error) { | |||||
| console.error('Error submitting pick execution issue:', error); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }; | |||||
| const handleClose = () => { | |||||
| setFormData({}); | |||||
| setErrors({}); | |||||
| onClose(); | |||||
| }; | |||||
| if (!selectedLot || !selectedPickOrderLine) { | |||||
| return null; | |||||
| } | |||||
| const remainingAvailableQty = calculateRemainingAvailableQty(selectedLot); | |||||
| const requiredQty = calculateRequiredQty(selectedLot); | |||||
| return ( | |||||
| <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> | |||||
| <DialogTitle> | |||||
| {t('Pick Execution Issue Form')} {/* ✅ Always show issue form title */} | |||||
| </DialogTitle> | |||||
| <DialogContent> | |||||
| <Box sx={{ mt: 2 }}> | |||||
| {/* ✅ Add instruction text */} | |||||
| <Grid container spacing={2}> | |||||
| <Grid item xs={12}> | |||||
| <Box sx={{ p: 2, backgroundColor: '#fff3cd', borderRadius: 1, mb: 2 }}> | |||||
| <Typography variant="body2" color="warning.main"> | |||||
| <strong>{t('Note:')}</strong> {t('This form is for reporting issues only. You must report either missing items or bad items.')} | |||||
| </Typography> | |||||
| </Box> | |||||
| </Grid> | |||||
| {/* ✅ Keep the existing form fields */} | |||||
| <Grid item xs={6}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t('requiredQty')} | |||||
| value={requiredQty || 0} | |||||
| disabled | |||||
| variant="outlined" | |||||
| helperText={t('Still need to pick')} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={6}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t('remainingAvailableQty')} | |||||
| value={remainingAvailableQty} | |||||
| disabled | |||||
| variant="outlined" | |||||
| helperText={t('Available in warehouse')} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t('actualPickQty')} | |||||
| type="number" | |||||
| value={formData.actualPickQty || 0} | |||||
| onChange={(e) => handleInputChange('actualPickQty', parseFloat(e.target.value) || 0)} | |||||
| error={!!errors.actualPickQty} | |||||
| helperText={errors.actualPickQty || t('Enter the quantity actually picked')} | |||||
| variant="outlined" | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t('missQty')} | |||||
| type="number" | |||||
| value={formData.missQty || 0} | |||||
| onChange={(e) => handleInputChange('missQty', parseFloat(e.target.value) || 0)} | |||||
| error={!!errors.missQty} | |||||
| helperText={errors.missQty || t('Enter missing quantity (required if no bad items)')} | |||||
| variant="outlined" | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t('badItemQty')} | |||||
| type="number" | |||||
| value={formData.badItemQty || 0} | |||||
| onChange={(e) => handleInputChange('badItemQty', parseFloat(e.target.value) || 0)} | |||||
| error={!!errors.badItemQty} | |||||
| helperText={errors.badItemQty || t('Enter bad item quantity (required if no missing items)')} | |||||
| variant="outlined" | |||||
| /> | |||||
| </Grid> | |||||
| {/* ✅ Show issue description and handler fields when bad items > 0 */} | |||||
| {(formData.badItemQty && formData.badItemQty > 0) && ( | |||||
| <> | |||||
| <Grid item xs={12}> | |||||
| <TextField | |||||
| fullWidth | |||||
| id="issueRemark" | |||||
| label={t('issueRemark')} | |||||
| multiline | |||||
| rows={4} | |||||
| value={formData.issueRemark || ''} | |||||
| onChange={(e) => handleInputChange('issueRemark', e.target.value)} | |||||
| error={!!errors.issueRemark} | |||||
| helperText={errors.issueRemark} | |||||
| placeholder={t('Describe the issue with bad items')} | |||||
| variant="outlined" | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | |||||
| <FormControl fullWidth error={!!errors.handledBy}> | |||||
| <InputLabel>{t('handler')}</InputLabel> | |||||
| <Select | |||||
| value={formData.handledBy ? formData.handledBy.toString() : ''} | |||||
| onChange={(e) => handleInputChange('handledBy', e.target.value ? parseInt(e.target.value) : undefined)} | |||||
| label={t('handler')} | |||||
| > | |||||
| {handlers.map((handler) => ( | |||||
| <MenuItem key={handler.id} value={handler.id.toString()}> | |||||
| {handler.name} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| {errors.handledBy && ( | |||||
| <Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.75 }}> | |||||
| {errors.handledBy} | |||||
| </Typography> | |||||
| )} | |||||
| </FormControl> | |||||
| </Grid> | |||||
| </> | |||||
| )} | |||||
| </Grid> | |||||
| </Box> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={handleClose} disabled={loading}> | |||||
| {t('cancel')} | |||||
| </Button> | |||||
| <Button | |||||
| onClick={handleSubmit} | |||||
| variant="contained" | |||||
| disabled={loading} | |||||
| > | |||||
| {loading ? t('submitting') : t('submit')} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| ); | |||||
| }; | |||||
| export default PickExecutionForm; | |||||
| @@ -0,0 +1,196 @@ | |||||
| "use client"; | |||||
| import { | |||||
| Box, | |||||
| Checkbox, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| TablePagination, | |||||
| Typography, | |||||
| Paper, | |||||
| } from "@mui/material"; | |||||
| import { useMemo, useCallback } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { GetPickOrderInfoResponse, GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | |||||
| import dayjs from "dayjs"; | |||||
| interface PickOrderDetailsTableProps { | |||||
| pickOrderDetails: GetPickOrderInfoResponse | null; | |||||
| detailLoading: boolean; | |||||
| selectedRowId: number | null; | |||||
| onRowSelect: (lineId: number) => void; | |||||
| onPageChange: (event: unknown, newPage: number) => void; | |||||
| onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void; | |||||
| pageNum: number; | |||||
| pageSize: number; | |||||
| } | |||||
| const PickOrderDetailsTable: React.FC<PickOrderDetailsTableProps> = ({ | |||||
| pickOrderDetails, | |||||
| detailLoading, | |||||
| selectedRowId, | |||||
| onRowSelect, | |||||
| onPageChange, | |||||
| onPageSizeChange, | |||||
| pageNum, | |||||
| pageSize, | |||||
| }) => { | |||||
| const { t } = useTranslation("pickOrder"); | |||||
| const prepareMainTableData = useMemo(() => { | |||||
| if (!pickOrderDetails) return []; | |||||
| return pickOrderDetails.pickOrders.flatMap((pickOrder) => | |||||
| pickOrder.pickOrderLines.map((line) => { | |||||
| const availableQty = line.availableQty ?? 0; | |||||
| const balanceToPick = availableQty - line.requiredQty; | |||||
| // ✅ Handle both string and array date formats from the optimized API | |||||
| let formattedTargetDate = 'N/A'; | |||||
| if (pickOrder.targetDate) { | |||||
| if (typeof pickOrder.targetDate === 'string') { | |||||
| formattedTargetDate = dayjs(pickOrder.targetDate).format('YYYY-MM-DD'); | |||||
| } else if (Array.isArray(pickOrder.targetDate)) { | |||||
| // Handle array format [2025, 9, 29, 0, 0] from optimized API | |||||
| const [year, month, day] = pickOrder.targetDate; | |||||
| formattedTargetDate = dayjs(`${year}-${month}-${day}`).format('YYYY-MM-DD'); | |||||
| } | |||||
| } | |||||
| return { | |||||
| ...line, | |||||
| pickOrderCode: pickOrder.code, | |||||
| targetDate: formattedTargetDate, | |||||
| balanceToPick: balanceToPick, | |||||
| pickedQty: line.pickedQty, // ✅ This now comes from the optimized API | |||||
| availableQty: availableQty, | |||||
| }; | |||||
| }) | |||||
| ); | |||||
| }, [pickOrderDetails]); | |||||
| // ✅ Paginated data | |||||
| const paginatedMainTableData = useMemo(() => { | |||||
| const startIndex = pageNum * pageSize; | |||||
| const endIndex = startIndex + pageSize; | |||||
| return prepareMainTableData.slice(startIndex, endIndex); | |||||
| }, [prepareMainTableData, pageNum, pageSize]); | |||||
| if (detailLoading) { | |||||
| return ( | |||||
| <Box display="flex" justifyContent="center" alignItems="center" minHeight="200px"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Loading data...")} | |||||
| </Typography> | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| if (!pickOrderDetails) { | |||||
| return ( | |||||
| <Box display="flex" justifyContent="center" alignItems="center" minHeight="200px"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data available")} | |||||
| </Typography> | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| return ( | |||||
| <> | |||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Selected")}</TableCell> | |||||
| <TableCell>{t("Pick Order Code")}</TableCell> | |||||
| <TableCell>{t("Item Code")}</TableCell> | |||||
| <TableCell>{t("Item Name")}</TableCell> | |||||
| <TableCell align="right">{t("Order Quantity")}</TableCell> | |||||
| <TableCell align="right">{t("Current Stock")}</TableCell> | |||||
| <TableCell align="right">{t("Qty Already Picked")}</TableCell> | |||||
| <TableCell align="right">{t("Stock Unit")}</TableCell> | |||||
| <TableCell align="right">{t("Target Date")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {paginatedMainTableData.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={9} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data available")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| paginatedMainTableData.map((line) => { | |||||
| const availableQty = line.availableQty ?? 0; | |||||
| const balanceToPick = Math.max(0, availableQty - line.requiredQty); | |||||
| const actualPickedQty = line.pickedQty ?? 0; | |||||
| return ( | |||||
| <TableRow | |||||
| key={line.id} | |||||
| sx={{ | |||||
| "& > *": { borderBottom: "unset" }, | |||||
| color: "black", | |||||
| backgroundColor: selectedRowId === line.id ? "action.selected" : "inherit", | |||||
| cursor: "pointer", | |||||
| "&:hover": { | |||||
| backgroundColor: "action.hover", | |||||
| }, | |||||
| }} | |||||
| > | |||||
| <TableCell align="center" sx={{ width: "60px" }}> | |||||
| <Checkbox | |||||
| checked={selectedRowId === line.id} | |||||
| onChange={(e) => { | |||||
| if (e.target.checked) { | |||||
| onRowSelect(line.id); | |||||
| } | |||||
| }} | |||||
| onClick={(e) => e.stopPropagation()} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="left">{line.pickOrderCode}</TableCell> | |||||
| <TableCell align="left">{line.itemCode}</TableCell> | |||||
| <TableCell align="left">{line.itemName}</TableCell> | |||||
| <TableCell align="right">{line.requiredQty}</TableCell> | |||||
| <TableCell align="right" sx={{ | |||||
| color: availableQty >= line.requiredQty ? 'success.main' : 'error.main', | |||||
| }}> | |||||
| {availableQty.toLocaleString()} | |||||
| </TableCell> | |||||
| <TableCell align="right">{actualPickedQty}</TableCell> | |||||
| <TableCell align="right">{line.uomDesc}</TableCell> | |||||
| <TableCell align="right">{line.targetDate}</TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={prepareMainTableData.length} | |||||
| page={pageNum} | |||||
| rowsPerPage={pageSize} | |||||
| onPageChange={onPageChange} | |||||
| onRowsPerPageChange={onPageSizeChange} | |||||
| rowsPerPageOptions={[10, 25, 50]} | |||||
| labelRowsPerPage={t("Rows per page")} | |||||
| labelDisplayedRows={({ from, to, count }) => | |||||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||||
| } | |||||
| /> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default PickOrderDetailsTable; | |||||
| @@ -120,7 +120,7 @@ interface LotPickData { | |||||
| requiredQty: number; | requiredQty: number; | ||||
| actualPickQty: number; | actualPickQty: number; | ||||
| lotStatus: string; | lotStatus: string; | ||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||||
| stockOutLineId?: number; | stockOutLineId?: number; | ||||
| stockOutLineStatus?: string; | stockOutLineStatus?: string; | ||||
| stockOutLineQty?: number; | stockOutLineQty?: number; | ||||
| @@ -687,7 +687,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||||
| formProps.reset(); | formProps.reset(); | ||||
| setHasSearched(false); | setHasSearched(false); | ||||
| setFilteredItems([]); | setFilteredItems([]); | ||||
| alert(t("All pick orders created successfully")); | |||||
| // alert(t("All pick orders created successfully")); | |||||
| // 通知父组件切换到 Assign & Release 标签页 | // 通知父组件切换到 Assign & Release 标签页 | ||||
| if (onPickOrderCreated) { | if (onPickOrderCreated) { | ||||
| @@ -175,7 +175,7 @@ | |||||
| "Pick Order Details": "提料單詳情", | "Pick Order Details": "提料單詳情", | ||||
| "Partial quantity submitted. Please submit more or complete the order.": "已提料部分數量。請提交更多或完成訂單。", | "Partial quantity submitted. Please submit more or complete the order.": "已提料部分數量。請提交更多或完成訂單。", | ||||
| "Pick order completed successfully!": "提料單完成成功!", | "Pick order completed successfully!": "提料單完成成功!", | ||||
| "QC check failed. Lot has been rejected and marked as unavailable.": "QC 檢查失敗。批號已拒絕並標記為不可用。", | |||||
| "Lot has been rejected and marked as unavailable.": "批號已拒絕並標記為不可用。", | |||||
| "This order is insufficient, please pick another lot.": "此訂單不足,請選擇其他批號。", | "This order is insufficient, please pick another lot.": "此訂單不足,請選擇其他批號。", | ||||
| "Please finish QR code scan, QC check and pick order.": "請完成 QR 碼掃描、QC 檢查和提料。", | "Please finish QR code scan, QC check and pick order.": "請完成 QR 碼掃描、QC 檢查和提料。", | ||||
| "No data available": "沒有資料", | "No data available": "沒有資料", | ||||