|
|
|
@@ -0,0 +1,403 @@ |
|
|
|
// 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 [verifiedQty, setVerifiedQty] = useState<number>(0); |
|
|
|
// 计算剩余可用数量 |
|
|
|
const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => { |
|
|
|
const remainingQty = lot.inQty - lot.outQty; |
|
|
|
return Math.max(0, remainingQty); |
|
|
|
}, []); |
|
|
|
const calculateRequiredQty = useCallback((lot: LotPickData) => { |
|
|
|
// ✅ Use the original required quantity, not subtracting actualPickQty |
|
|
|
// The actualPickQty in the form should be independent of the database value |
|
|
|
return lot.requiredQty || 0; |
|
|
|
}, []); |
|
|
|
|
|
|
|
// 获取处理人员列表 |
|
|
|
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]; |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
// ✅ Initialize verified quantity to the received quantity (actualPickQty) |
|
|
|
const initialVerifiedQty = selectedLot.actualPickQty || 0; |
|
|
|
setVerifiedQty(initialVerifiedQty); |
|
|
|
|
|
|
|
console.log("=== PickExecutionForm Debug ==="); |
|
|
|
console.log("selectedLot:", selectedLot); |
|
|
|
console.log("initialVerifiedQty:", initialVerifiedQty); |
|
|
|
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: initialVerifiedQty, // ✅ Use the initial value |
|
|
|
missQty: 0, |
|
|
|
badItemQty: 0, |
|
|
|
issueRemark: '', |
|
|
|
pickerName: '', |
|
|
|
handledBy: undefined, |
|
|
|
}); |
|
|
|
} |
|
|
|
}, [open, selectedLot, selectedPickOrderLine, pickOrderId, pickOrderCreateDate]); |
|
|
|
|
|
|
|
const handleInputChange = useCallback((field: keyof PickExecutionIssueData, value: any) => { |
|
|
|
setFormData(prev => ({ ...prev, [field]: value })); |
|
|
|
|
|
|
|
// ✅ Update verified quantity state when actualPickQty changes |
|
|
|
if (field === 'actualPickQty') { |
|
|
|
setVerifiedQty(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 = {}; |
|
|
|
|
|
|
|
// ✅ 使用原始的接收数量,而不是 formData 中的 |
|
|
|
const receivedQty = selectedLot?.actualPickQty || 0; |
|
|
|
const requiredQty = selectedLot?.requiredQty || 0; |
|
|
|
const badItemQty = formData.badItemQty || 0; |
|
|
|
const missQty = formData.missQty || 0; |
|
|
|
|
|
|
|
if (verifiedQty === undefined || verifiedQty < 0) { |
|
|
|
newErrors.actualPickQty = t('Qty is required'); |
|
|
|
} |
|
|
|
|
|
|
|
// ✅ 验证数量不能超过原始接收数量 |
|
|
|
if (verifiedQty > receivedQty) { |
|
|
|
newErrors.actualPickQty = t('Verified quantity cannot exceed received quantity'); |
|
|
|
} |
|
|
|
|
|
|
|
// ✅ 只检查总和是否等于需求数量 |
|
|
|
const totalQty = verifiedQty + badItemQty + missQty; |
|
|
|
if (totalQty !== requiredQty) { |
|
|
|
newErrors.actualPickQty = t('Total (Verified + Bad + Missing) must equal Required quantity'); |
|
|
|
} |
|
|
|
|
|
|
|
// ✅ Require either missQty > 0 OR badItemQty > 0 |
|
|
|
const hasMissQty = formData.missQty && formData.missQty > 0; |
|
|
|
const hasBadItemQty = formData.badItemQty && formData.badItemQty > 0; |
|
|
|
|
|
|
|
if (!hasMissQty && !hasBadItemQty) { |
|
|
|
newErrors.missQty = t('At least one issue must be reported'); |
|
|
|
newErrors.badItemQty = t('At least one issue must be reported'); |
|
|
|
} |
|
|
|
|
|
|
|
setErrors(newErrors); |
|
|
|
return Object.keys(newErrors).length === 0; |
|
|
|
}; |
|
|
|
const handleSubmit = async () => { |
|
|
|
if (!validateForm() || !formData.pickOrderId) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
setLoading(true); |
|
|
|
try { |
|
|
|
// ✅ Use the verified quantity in the submission |
|
|
|
const submissionData = { |
|
|
|
...formData, |
|
|
|
actualPickQty: verifiedQty |
|
|
|
} as PickExecutionIssueData; |
|
|
|
|
|
|
|
await onSubmit(submissionData); |
|
|
|
onClose(); |
|
|
|
} catch (error) { |
|
|
|
console.error('Error submitting pick execution issue:', error); |
|
|
|
} finally { |
|
|
|
setLoading(false); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
const handleClose = () => { |
|
|
|
setFormData({}); |
|
|
|
setErrors({}); |
|
|
|
setVerifiedQty(0); |
|
|
|
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('Required Qty')} |
|
|
|
value={selectedLot?.requiredQty || 0} |
|
|
|
disabled |
|
|
|
variant="outlined" |
|
|
|
// helperText={t('Still need to pick')} |
|
|
|
/> |
|
|
|
</Grid> |
|
|
|
|
|
|
|
<Grid item xs={6}> |
|
|
|
<TextField |
|
|
|
fullWidth |
|
|
|
label={t('Received Qty')} |
|
|
|
value={selectedLot?.actualPickQty || 0} |
|
|
|
disabled |
|
|
|
variant="outlined" |
|
|
|
// helperText={t('Available in warehouse')} |
|
|
|
/> |
|
|
|
</Grid> |
|
|
|
|
|
|
|
<Grid item xs={12}> |
|
|
|
<TextField |
|
|
|
fullWidth |
|
|
|
label={t('Verified Qty')} |
|
|
|
type="number" |
|
|
|
value={verifiedQty} |
|
|
|
onChange={(e) => { |
|
|
|
const newValue = parseFloat(e.target.value) || 0; |
|
|
|
setVerifiedQty(newValue); |
|
|
|
handleInputChange('actualPickQty', newValue); |
|
|
|
}} |
|
|
|
error={!!errors.actualPickQty} |
|
|
|
helperText={errors.actualPickQty || `${t('Max')}: ${selectedLot?.actualPickQty || 0}`} // ✅ 使用原始接收数量 |
|
|
|
variant="outlined" |
|
|
|
/> |
|
|
|
</Grid> |
|
|
|
|
|
|
|
<Grid item xs={12}> |
|
|
|
<TextField |
|
|
|
fullWidth |
|
|
|
label={t('Missing item Qty')} |
|
|
|
type="number" |
|
|
|
value={formData.missQty || 0} |
|
|
|
onChange={(e) => { |
|
|
|
const newMissQty = parseFloat(e.target.value) || 0; |
|
|
|
handleInputChange('missQty', newMissQty); |
|
|
|
// ✅ 不要自动修改其他字段 |
|
|
|
}} |
|
|
|
error={!!errors.missQty} |
|
|
|
helperText={errors.missQty} |
|
|
|
variant="outlined" |
|
|
|
/> |
|
|
|
</Grid> |
|
|
|
|
|
|
|
<Grid item xs={12}> |
|
|
|
<TextField |
|
|
|
fullWidth |
|
|
|
label={t('Bad Item Qty')} |
|
|
|
type="number" |
|
|
|
value={formData.badItemQty || 0} |
|
|
|
onChange={(e) => { |
|
|
|
const newBadItemQty = parseFloat(e.target.value) || 0; |
|
|
|
handleInputChange('badItemQty', newBadItemQty); |
|
|
|
// ✅ 不要自动修改其他字段 |
|
|
|
}} |
|
|
|
error={!!errors.badItemQty} |
|
|
|
helperText={errors.badItemQty} |
|
|
|
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('Issue Remark')} |
|
|
|
multiline |
|
|
|
rows={4} |
|
|
|
value={formData.issueRemark || ''} |
|
|
|
onChange={(e) => { |
|
|
|
handleInputChange('issueRemark', e.target.value); |
|
|
|
// ✅ Don't reset badItemQty when typing in issue remark |
|
|
|
}} |
|
|
|
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); |
|
|
|
// ✅ Don't reset badItemQty when selecting handler |
|
|
|
}} |
|
|
|
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; |