| @@ -94,10 +94,12 @@ export interface GetPickOrderInfoResponse { | |||
| export interface GetPickOrderInfo { | |||
| id: number; | |||
| code: string; | |||
| targetDate: string; | |||
| consoCode: string | null; // ✅ 添加 consoCode 属性 | |||
| targetDate: string | number[]; // ✅ Support both formats | |||
| type: string; | |||
| status: string; | |||
| assignTo: number; | |||
| groupName: string; // ✅ Add this field | |||
| pickOrderLines: GetPickOrderLineInfo[]; | |||
| } | |||
| @@ -157,9 +159,126 @@ export interface LotDetailWithStockOutLine { | |||
| stockOutLineStatus?: string; | |||
| 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) => { | |||
| console.log("Resuggesting pick order:", pickOrderId); | |||
| const result = await serverFetchJson<PostPickOrderResponse>( | |||
| @@ -286,7 +405,7 @@ export interface PickOrderLotDetailResponse { | |||
| actualPickQty: number; | |||
| suggestedPickLotId: number; | |||
| lotStatus: string; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||
| } | |||
| interface ALLPickOrderLotDetailResponse { | |||
| // Pick Order Information | |||
| @@ -315,24 +434,62 @@ interface ALLPickOrderLotDetailResponse { | |||
| lotNo: string; | |||
| expiryDate: string; | |||
| location: string; | |||
| outQty: number; | |||
| holdQty: number; | |||
| stockUnit: string; | |||
| availableQty: number; | |||
| requiredQty: number; | |||
| actualPickQty: number; | |||
| totalPickedByAllPickOrders: number; | |||
| suggestedPickLotId: number; | |||
| lotStatus: string; | |||
| stockOutLineId?: number; | |||
| stockOutLineStatus?: string; | |||
| stockOutLineQty?: number; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||
| 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 | |||
| ? `${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, | |||
| { | |||
| 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>( | |||
| url, | |||
| { | |||
| @@ -271,8 +271,6 @@ const PickOrderSearch: React.FC<Props> = ({ pickOrders }) => { | |||
| borderBottom: '1px solid #e0e0e0' | |||
| }}> | |||
| <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" /> | |||
| </Tabs> | |||
| </Box> | |||
| @@ -281,9 +279,7 @@ const PickOrderSearch: React.FC<Props> = ({ pickOrders }) => { | |||
| <Box sx={{ | |||
| p: 2 | |||
| }}> | |||
| {tabIndex === 2 && <PickExecution filterArgs={filterArgs} />} | |||
| {tabIndex === 0 && <AssignAndRelease filterArgs={filterArgs} />} | |||
| {tabIndex === 1 && <AssignTo filterArgs={filterArgs} />} | |||
| {tabIndex === 0 && <PickExecution filterArgs={filterArgs} />} | |||
| </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; | |||
| actualPickQty: number; | |||
| lotStatus: string; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||
| stockOutLineId?: number; | |||
| stockOutLineStatus?: string; | |||
| stockOutLineQty?: number; | |||
| @@ -687,7 +687,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| formProps.reset(); | |||
| setHasSearched(false); | |||
| setFilteredItems([]); | |||
| alert(t("All pick orders created successfully")); | |||
| // alert(t("All pick orders created successfully")); | |||
| // 通知父组件切换到 Assign & Release 标签页 | |||
| if (onPickOrderCreated) { | |||
| @@ -20,11 +20,12 @@ import { | |||
| 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 { GetPickOrderLineInfo, recordPickExecutionIssue } from "@/app/api/pickOrder/actions"; | |||
| import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider'; | |||
| import { updateInventoryLotLineStatus } from "@/app/api/inventory/actions"; | |||
| import { updateStockOutLineStatus } from "@/app/api/pickOrder/actions"; | |||
| import { fetchStockInLineInfo } from "@/app/api/po/actions"; // ✅ Add this import | |||
| import PickExecutionForm from "./PickExecutionForm"; | |||
| interface LotPickData { | |||
| id: number; | |||
| lotId: number; | |||
| @@ -37,7 +38,10 @@ interface LotPickData { | |||
| requiredQty: number; | |||
| actualPickQty: number; | |||
| 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; | |||
| stockOutLineStatus?: string; | |||
| stockOutLineQty?: number; | |||
| @@ -52,7 +56,7 @@ interface PickQtyData { | |||
| interface LotTableProps { | |||
| lotData: LotPickData[]; | |||
| selectedRowId: number | null; | |||
| selectedRow: (GetPickOrderLineInfo & { pickOrderCode: string }) | null; | |||
| selectedRow: (GetPickOrderLineInfo & { pickOrderCode: string; pickOrderId: number }) | null; // ✅ 添加 pickOrderId | |||
| pickQtyData: PickQtyData; | |||
| selectedLotRowId: string | null; | |||
| selectedLotId: number | null; | |||
| @@ -63,6 +67,9 @@ interface LotTableProps { | |||
| onQcCheck: (line: GetPickOrderLineInfo, pickOrderCode: string) => void; | |||
| onLotSelectForInput: (lot: LotPickData) => void; | |||
| showInputBody: boolean; | |||
| totalPickedByAllPickOrders: number; | |||
| outQty: number; | |||
| holdQty: number; | |||
| setShowInputBody: (show: boolean) => void; | |||
| selectedLotForInput: LotPickData | null; | |||
| generateInputBody: () => any; | |||
| @@ -383,7 +390,11 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| onLotDataRefresh, | |||
| }) => { | |||
| 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 | |||
| const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||
| @@ -414,7 +425,7 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| case 'completed': | |||
| return t("Pick order completed successfully!"); | |||
| 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': | |||
| return t("This order is insufficient, please pick another lot."); | |||
| default: | |||
| @@ -455,7 +466,7 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| if (!selectedRowId) return lot.availableQty; | |||
| const lactualPickQty = lot.actualPickQty || 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 | |||
| return Math.max(0, remainingQty); | |||
| @@ -490,6 +501,57 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| } | |||
| }, [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 ( | |||
| <> | |||
| <TableContainer component={Paper}> | |||
| @@ -526,24 +588,43 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| </TableRow> | |||
| ) : ( | |||
| 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> | |||
| <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> | |||
| <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' && ( | |||
| <Typography variant="caption" color="error" display="block"> | |||
| ({lot.lotAvailability === 'expired' ? 'Expired' : | |||
| lot.lotAvailability === 'insufficient_stock' ? 'Insufficient' : | |||
| lot.lotAvailability === 'rejected' ? 'Rejected' : // ✅ 添加 rejected 显示 | |||
| 'Unavailable'}) | |||
| </Typography> | |||
| )} | |||
| @@ -552,174 +633,112 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| <TableCell>{lot.expiryDate}</TableCell> | |||
| <TableCell>{lot.location}</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> | |||
| */} | |||
| {/* 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 */} | |||
| {/* 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"> | |||
| {/* | |||
| <Stack direction="column" spacing={1} alignItems="center"> | |||
| <Button | |||
| variant="outlined" | |||
| @@ -759,6 +778,7 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| {t("Reject")} | |||
| </Button> | |||
| </Stack> | |||
| */} | |||
| {/*} | |||
| </TableCell> | |||
| @@ -774,10 +794,12 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| }} | |||
| disabled={ | |||
| (lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') || | |||
| (lot.lotAvailability === 'expired' || | |||
| lot.lotAvailability === 'status_unavailable' || | |||
| lot.lotAvailability === 'rejected') || // ✅ 添加 rejected | |||
| !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 | |||
| sx={{ | |||
| @@ -838,6 +860,22 @@ const LotTable: React.FC<LotTableProps> = ({ | |||
| lot={selectedLotForQr} | |||
| 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, | |||
| updateStockOutLineStatus, | |||
| resuggestPickOrder, | |||
| checkAndCompletePickOrderByConsoCode, | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { EditNote } from "@mui/icons-material"; | |||
| import { fetchNameList, NameList } from "@/app/api/user/actions"; | |||
| @@ -69,9 +70,10 @@ import dayjs from "dayjs"; | |||
| import { dummyQCData } from "../PoDetail/dummyQcTemplate"; | |||
| import { CreateStockOutLine } from "@/app/api/pickOrder/actions"; | |||
| import LotTable from './LotTable'; | |||
| import PickOrderDetailsTable from './PickOrderDetailsTable'; // ✅ Import the new component | |||
| 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 { | |||
| filterArgs: Record<string, any>; | |||
| @@ -85,11 +87,14 @@ interface LotPickData { | |||
| 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'; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||
| stockOutLineId?: number; | |||
| stockOutLineStatus?: string; | |||
| stockOutLineQty?: number; | |||
| @@ -104,9 +109,8 @@ interface PickQtyData { | |||
| const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| 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 [filteredPickOrders, setFilteredPickOrders] = useState( | |||
| @@ -141,11 +145,10 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| } | null>(null); | |||
| const [selectedLotForQc, setSelectedLotForQc] = useState<LotPickData | null>(null); | |||
| // ✅ Add lot selection state variables | |||
| const [selectedLotRowId, setSelectedLotRowId] = useState<string | null>(null); | |||
| const [selectedLotId, setSelectedLotId] = useState<number | null>(null); | |||
| // 新增:分页控制器 | |||
| // ✅ Keep only the main table paging controller | |||
| const [mainTablePagingController, setMainTablePagingController] = useState({ | |||
| pageNum: 0, | |||
| pageSize: 10, | |||
| @@ -155,7 +158,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| pageSize: 10, | |||
| }); | |||
| // Add missing search state variables | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| const [originalPickOrderData, setOriginalPickOrderData] = useState<GetPickOrderInfoResponse | null>(null); | |||
| @@ -199,6 +201,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| useEffect(() => { | |||
| fetchNewPageConsoPickOrder({ limit: 10, offset: 0 }, filterArgs); | |||
| }, [fetchNewPageConsoPickOrder, filterArgs]); | |||
| const handleUpdateStockOutLineStatus = useCallback(async ( | |||
| stockOutLineId: number, | |||
| status: string, | |||
| @@ -217,16 +220,15 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| if (result) { | |||
| console.log("Stock out line status updated successfully:", result); | |||
| // Refresh lot data to show updated status | |||
| if (selectedRowId) { | |||
| handleRowSelect(selectedRowId); | |||
| } | |||
| } | |||
| } catch (error) { | |||
| console.error("Error updating stock out line status:", error); | |||
| } | |||
| }, [selectedRowId]); | |||
| const isReleasable = useCallback((itemList: ByItemsSummary[]): boolean => { | |||
| let isReleasable = true; | |||
| for (const item of itemList) { | |||
| @@ -260,26 +262,52 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const handleFetchAllPickOrderDetails = useCallback(async () => { | |||
| setDetailLoading(true); | |||
| try { | |||
| // ✅ Use current user ID for filtering | |||
| const data = await fetchAllPickOrderDetails(currentUserId); | |||
| setPickOrderDetails(data); | |||
| setOriginalPickOrderData(data); // Store original data for filtering | |||
| 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) { | |||
| console.error("Error fetching all pick order details:", error); | |||
| setPickOrderDetails({ | |||
| consoCode: null, | |||
| pickOrders: [], | |||
| items: [] | |||
| }); | |||
| setOriginalPickOrderData({ | |||
| consoCode: null, | |||
| pickOrders: [], | |||
| items: [] | |||
| }); | |||
| setPickQtyData({}); | |||
| } finally { | |||
| setDetailLoading(false); | |||
| } | |||
| }, [currentUserId]); // ✅ Add currentUserId as dependency | |||
| }, [currentUserId]); | |||
| useEffect(() => { | |||
| handleFetchAllPickOrderDetails(); | |||
| @@ -331,7 +359,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const handlePickQtyChange = useCallback((lineId: number, lotId: number, value: number | string) => { | |||
| 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; | |||
| setPickQtyData(prev => { | |||
| @@ -351,24 +378,28 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const qty = pickQtyData[lineId]?.[lotId] || 0; | |||
| console.log(`提交拣货数量: Line ${lineId}, Lot ${lotId}, Qty ${qty}`); | |||
| // ✅ Find the stock out line for this lot | |||
| const selectedLot = lotData.find(lot => lot.lotId === lotId); | |||
| if (!selectedLot?.stockOutLineId) { | |||
| return; | |||
| } | |||
| 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 { | |||
| // ✅ Function 1: Update stock out line with new status and quantity | |||
| const stockOutLineUpdate = await updateStockOutLineStatus({ | |||
| id: selectedLot.stockOutLineId, | |||
| status: newStatus, | |||
| @@ -379,10 +410,9 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| } catch (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) { | |||
| const inventoryLotLineUpdate = await updateInventoryLotLineQuantities({ | |||
| inventoryLotLineId: lotId, | |||
| @@ -394,26 +424,74 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| 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') { | |||
| // 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"); | |||
| // ✅ Refresh lot data to show updated quantities | |||
| if (selectedRowId) { | |||
| 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"); | |||
| } | |||
| await handleFetchAllPickOrderDetails(); | |||
| } catch (error) { | |||
| console.error("Error updating pick quantity:", error); | |||
| } | |||
| }, [pickQtyData, lotData, selectedRowId]); | |||
| }, [pickQtyData, lotData, selectedRowId, pickOrderDetails, handleFetchAllPickOrderDetails]); | |||
| const getTotalPickedQty = useCallback((lineId: number) => { | |||
| const lineData = pickQtyData[lineId]; | |||
| @@ -421,30 +499,22 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| return Object.values(lineData).reduce((sum, qty) => sum + qty, 0); | |||
| }, [pickQtyData]); | |||
| const handleQcCheck = useCallback(async (line: GetPickOrderLineInfo, pickOrderCode: string) => { | |||
| // ✅ Get the selected lot for QC | |||
| if (!selectedLotId) { | |||
| return; | |||
| } | |||
| const selectedLot = lotData.find(lot => lot.lotId === selectedLotId); | |||
| if (!selectedLot) { | |||
| //alert("Selected lot not found in lot data"); | |||
| return; | |||
| } | |||
| // ✅ Check if stock out line exists | |||
| if (!selectedLot.stockOutLineId) { | |||
| //alert("Please create a stock out line first before performing QC check"); | |||
| return; | |||
| } | |||
| setSelectedLotForQc(selectedLot); | |||
| // ✅ ALWAYS use dummy data for consistent behavior | |||
| const transformedDummyData = dummyQCData.map(item => ({ | |||
| id: item.id, | |||
| code: item.code, | |||
| @@ -453,7 +523,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| lowerLimit: undefined, | |||
| upperLimit: undefined, | |||
| description: item.qcDescription, | |||
| // ✅ Always reset QC result properties to undefined for fresh start | |||
| qcPassed: undefined, | |||
| failQty: undefined, | |||
| remarks: undefined | |||
| @@ -461,7 +530,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| setQcItems(transformedDummyData as QcItemWithChecks[]); | |||
| // ✅ Get existing QC results if any (for display purposes only) | |||
| let qcResult: any[] = []; | |||
| try { | |||
| const rawQcResult = await fetchPickOrderQcResult(line.id); | |||
| @@ -491,7 +559,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| setSelectedItemForQc(item); | |||
| }, []); | |||
| // 新增:处理分页变化 | |||
| // ✅ Main table pagination handlers | |||
| const handleMainTablePageChange = useCallback((event: unknown, newPage: number) => { | |||
| setMainTablePagingController(prev => ({ | |||
| ...prev, | |||
| @@ -522,31 +590,27 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| }); | |||
| }, []); | |||
| // ✅ Fix lot selection logic | |||
| const handleLotSelection = useCallback((uniqueLotId: string, lotId: number) => { | |||
| console.log("=== DEBUG: Lot Selection ==="); | |||
| console.log("uniqueLotId:", uniqueLotId); | |||
| console.log("lotId (inventory lot line ID):", lotId); | |||
| // Find the selected lot data | |||
| const selectedLot = lotData.find(lot => lot.lotId === lotId); | |||
| console.log("Selected lot data:", selectedLot); | |||
| // If clicking the same lot, unselect it | |||
| if (selectedLotRowId === uniqueLotId) { | |||
| setSelectedLotRowId(null); | |||
| setSelectedLotId(null); | |||
| } else { | |||
| // Select the new lot | |||
| setSelectedLotRowId(uniqueLotId); | |||
| setSelectedLotId(lotId); | |||
| } | |||
| }, [selectedLotRowId]); | |||
| // ✅ Add function to handle row selection that resets lot selection | |||
| const handleRowSelect = useCallback(async (lineId: number, preserveLotSelection: boolean = false) => { | |||
| setSelectedRowId(lineId); | |||
| // ✅ Only reset lot selection if not preserving | |||
| if (!preserveLotSelection) { | |||
| setSelectedLotRowId(null); | |||
| setSelectedLotId(null); | |||
| @@ -557,13 +621,16 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| console.log("Lot details from API:", lotDetails); | |||
| 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, | |||
| expiryDate: lot.expiryDate ? new Date(lot.expiryDate).toLocaleDateString() : 'N/A', | |||
| location: lot.location, | |||
| stockUnit: lot.stockUnit, | |||
| inQty: lot.inQty, | |||
| outQty: lot.outQty, | |||
| holdQty: lot.holdQty, | |||
| totalPickedByAllPickOrders: lot.totalPickedByAllPickOrders, | |||
| availableQty: lot.availableQty, | |||
| requiredQty: lot.requiredQty, | |||
| 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(() => { | |||
| return lotData.map((lot) => ({ | |||
| ...lot, | |||
| @@ -615,13 +655,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| })); | |||
| }, [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 startIndex = lotTablePagingController.pageNum * lotTablePagingController.pageSize; | |||
| const endIndex = startIndex + lotTablePagingController.pageSize; | |||
| @@ -634,11 +667,13 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| for (const pickOrder of pickOrderDetails.pickOrders) { | |||
| const foundLine = pickOrder.pickOrderLines.find(line => line.id === selectedRowId); | |||
| if (foundLine) { | |||
| return { ...foundLine, pickOrderCode: pickOrder.code }; | |||
| return { ...foundLine, pickOrderCode: pickOrder.code, | |||
| pickOrderId: pickOrder.id }; | |||
| } | |||
| } | |||
| return null; | |||
| }, [selectedRowId, pickOrderDetails]); | |||
| const handleInventoryUpdate = useCallback(async (itemId: number, lotId: number, qty: number) => { | |||
| try { | |||
| const inventoryUpdate = await updateInventoryStatus({ | |||
| @@ -653,16 +688,17 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| console.error("Error updating inventory status:", error); | |||
| } | |||
| }, []); | |||
| const handleLotDataRefresh = useCallback(async () => { | |||
| if (selectedRowId) { | |||
| try { | |||
| await handleRowSelect(selectedRowId, true); // Preserve lot selection | |||
| await handleRowSelect(selectedRowId, true); | |||
| } catch (error) { | |||
| console.error("Error refreshing lot data:", error); | |||
| } | |||
| } | |||
| }, [selectedRowId, handleRowSelect]); | |||
| // ✅ Add this function after handleRowSelect is defined | |||
| const handleDataRefresh = useCallback(async () => { | |||
| if (selectedRowId) { | |||
| try { | |||
| @@ -672,15 +708,14 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| } | |||
| } | |||
| }, [selectedRowId, handleRowSelect]); | |||
| const handleInsufficientStock = useCallback(async () => { | |||
| console.log("Insufficient stock - testing resuggest API"); | |||
| if (!selectedRowId || !pickOrderDetails) { | |||
| // alert("Please select a pick order line first"); | |||
| return; | |||
| } | |||
| // Find the pick order ID from the selected row | |||
| let pickOrderId: number | null = null; | |||
| for (const pickOrder of pickOrderDetails.pickOrders) { | |||
| const foundLine = pickOrder.pickOrderLines.find(line => line.id === selectedRowId); | |||
| @@ -691,55 +726,41 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| } | |||
| if (!pickOrderId) { | |||
| // alert("Could not find pick order ID for selected line"); | |||
| return; | |||
| } | |||
| try { | |||
| console.log(`Calling resuggest API for pick order ID: ${pickOrderId}`); | |||
| // Call the resuggest API | |||
| const result = await resuggestPickOrder(pickOrderId); | |||
| console.log("Resuggest API result:", result); | |||
| 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) { | |||
| await handleRowSelect(selectedRowId); | |||
| } | |||
| // Also refresh the main pick order details | |||
| await handleFetchAllPickOrderDetails(); | |||
| } else { | |||
| //alert(`❌ Resuggest failed!\n\nError: ${result.message}`); | |||
| } | |||
| } catch (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]); | |||
| // Add this function (around line 350) | |||
| const hasSelectedLots = useCallback((lineId: number) => { | |||
| return selectedLotRowId !== null; | |||
| }, [selectedLotRowId]); | |||
| // Add state for showing input body | |||
| const [showInputBody, setShowInputBody] = useState(false); | |||
| const [selectedLotForInput, setSelectedLotForInput] = useState<LotPickData | null>(null); | |||
| // Add function to handle lot selection for input body display | |||
| const handleLotSelectForInput = useCallback((lot: LotPickData) => { | |||
| setSelectedLotForInput(lot); | |||
| setShowInputBody(true); | |||
| }, []); | |||
| // Add function to generate input body | |||
| const generateInputBody = useCallback((): CreateStockOutLine | null => { | |||
| if (!selectedLotForInput || !selectedRowId || !selectedRow || !pickOrderDetails?.consoCode) { | |||
| return null; | |||
| @@ -753,9 +774,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| }; | |||
| }, [selectedLotForInput, selectedRowId, selectedRow, pickOrderDetails?.consoCode]); | |||
| // Add function to handle create stock out line | |||
| const handleCreateStockOutLine = useCallback(async (inventoryLotLineId: number) => { | |||
| if (!selectedRowId || !pickOrderDetails?.consoCode) { | |||
| console.error("Missing required data for creating stock out line."); | |||
| @@ -763,12 +781,21 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| } | |||
| try { | |||
| // ✅ Store current lot selection before refresh | |||
| const currentSelectedLotRowId = selectedLotRowId; | |||
| 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 = { | |||
| consoCode: pickOrderDetails.consoCode, | |||
| consoCode: correctConsoCode || pickOrderDetails?.consoCode || "", // ✅ 使用正确的 consoCode | |||
| pickOrderLineId: selectedRowId, | |||
| inventoryLotLineId: inventoryLotLineId, | |||
| qty: 0.0 | |||
| @@ -777,7 +804,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| console.log("=== STOCK OUT LINE CREATION DEBUG ==="); | |||
| console.log("Input Body:", JSON.stringify(stockOutLineData, null, 2)); | |||
| // ✅ Use the correct API function | |||
| const result = await createStockOutLine(stockOutLineData); | |||
| console.log("Stock Out Line created:", result); | |||
| @@ -785,16 +811,13 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| if (result) { | |||
| console.log("Stock out line created successfully:", result); | |||
| // ✅ Auto-refresh data after successful creation | |||
| console.log("🔄 Refreshing data after stock out line creation..."); | |||
| try { | |||
| // ✅ Refresh lot data for the selected row (maintains selection) | |||
| if (selectedRowId) { | |||
| await handleRowSelect(selectedRowId, true); // ✅ Preserve lot selection | |||
| await handleRowSelect(selectedRowId, true); | |||
| } | |||
| // ✅ Refresh main pick order details | |||
| await handleFetchAllPickOrderDetails(); | |||
| console.log("✅ Data refresh completed - lot selection maintained!"); | |||
| @@ -802,7 +825,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| console.error("❌ Error refreshing data:", refreshError); | |||
| } | |||
| setShowInputBody(false); // Hide preview after successful creation | |||
| setShowInputBody(false); | |||
| } else { | |||
| 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]); | |||
| // ✅ New function to refresh data while preserving lot selection | |||
| const handleRefreshDataPreserveSelection = useCallback(async () => { | |||
| if (!selectedRowId) return; | |||
| // ✅ Store current lot selection | |||
| const currentSelectedLotRowId = selectedLotRowId; | |||
| const currentSelectedLotId = selectedLotId; | |||
| try { | |||
| // ✅ Refresh lot data | |||
| await handleRowSelect(selectedRowId, true); // ✅ Preserve selection | |||
| await handleRowSelect(selectedRowId, true); | |||
| // ✅ Refresh main pick order details | |||
| await handleFetchAllPickOrderDetails(); | |||
| // ✅ Restore lot selection | |||
| setSelectedLotRowId(currentSelectedLotRowId); | |||
| setSelectedLotId(currentSelectedLotId); | |||
| @@ -836,106 +854,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| } | |||
| }, [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( | |||
| () => [ | |||
| { | |||
| @@ -963,7 +882,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| [t], | |||
| ); | |||
| // Add search handler | |||
| // ✅ Search handler | |||
| const handleSearch = useCallback((query: Record<string, any>) => { | |||
| setSearchQuery({ ...query }); | |||
| console.log("Search query:", query); | |||
| @@ -971,7 +890,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| if (!originalPickOrderData) return; | |||
| const filtered = originalPickOrderData.pickOrders.filter((pickOrder) => { | |||
| // Check if any line in this pick order matches the search criteria | |||
| return pickOrder.pickOrderLines.some((line) => { | |||
| const itemCodeMatch = !query.itemCode || | |||
| line.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase()); | |||
| @@ -982,12 +900,10 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const pickOrderCodeMatch = !query.pickOrderCode || | |||
| pickOrder.code?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase()); | |||
| return itemCodeMatch && itemNameMatch && pickOrderCodeMatch ; | |||
| return itemCodeMatch && itemNameMatch && pickOrderCodeMatch; | |||
| }); | |||
| }); | |||
| // Create filtered data structure | |||
| const filteredData: GetPickOrderInfoResponse = { | |||
| ...originalPickOrderData, | |||
| pickOrders: filtered | |||
| @@ -997,7 +913,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| console.log("Filtered pick orders count:", filtered.length); | |||
| }, [originalPickOrderData, t]); | |||
| // Add reset handler | |||
| // ✅ Reset handler | |||
| const handleReset = useCallback(() => { | |||
| setSearchQuery({}); | |||
| if (originalPickOrderData) { | |||
| @@ -1005,7 +921,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| } | |||
| }, [originalPickOrderData]); | |||
| // Add this to debug the lot data | |||
| // ✅ Debug the lot data | |||
| useEffect(() => { | |||
| console.log("Lot data:", lotData); | |||
| console.log("Pick Qty Data:", pickQtyData); | |||
| @@ -1023,56 +939,55 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| /> | |||
| </Box> | |||
| {/* 主表格 */} | |||
| {/* ✅ Main table using the new component */} | |||
| <Box> | |||
| <Typography variant="h6" gutterBottom> | |||
| {t("Pick Order Details")} | |||
| </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> | |||
| {/* 批次表格 - 放在主表格下方 */} | |||
| {/* Lot table - below main table */} | |||
| {selectedRow && ( | |||
| <Box> | |||
| <Typography variant="h6" gutterBottom> | |||
| {t("Item lot to be Pick:")} {selectedRow.pickOrderCode} - {selectedRow.itemName} | |||
| </Typography> | |||
| {/* 检查是否有可用的批次数据 */} | |||
| {lotData.length > 0 ? ( | |||
| <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 | |||
| 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; | |||
| actualPickQty: number; | |||
| lotStatus: string; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||
| stockOutLineId?: number; | |||
| stockOutLineStatus?: string; | |||
| stockOutLineQty?: number; | |||
| @@ -687,7 +687,7 @@ const handleQtyBlur = useCallback((itemId: number) => { | |||
| formProps.reset(); | |||
| setHasSearched(false); | |||
| setFilteredItems([]); | |||
| alert(t("All pick orders created successfully")); | |||
| // alert(t("All pick orders created successfully")); | |||
| // 通知父组件切换到 Assign & Release 标签页 | |||
| if (onPickOrderCreated) { | |||
| @@ -175,7 +175,7 @@ | |||
| "Pick Order Details": "提料單詳情", | |||
| "Partial quantity submitted. Please submit more or complete the order.": "已提料部分數量。請提交更多或完成訂單。", | |||
| "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.": "此訂單不足,請選擇其他批號。", | |||
| "Please finish QR code scan, QC check and pick order.": "請完成 QR 碼掃描、QC 檢查和提料。", | |||
| "No data available": "沒有資料", | |||