FPSMS-frontend
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

GoodPickExecutionForm.tsx 14 KiB

3ヶ月前
2ヶ月前
3ヶ月前
1ヶ月前
3ヶ月前
1ヶ月前
3ヶ月前
1ヶ月前
2ヶ月前
3ヶ月前
2ヶ月前
1ヶ月前
2ヶ月前
2ヶ月前
3ヶ月前
2ヶ月前
3ヶ月前
2ヶ月前
3ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
3ヶ月前
1ヶ月前
1ヶ月前
3ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
3ヶ月前
1ヶ月前
1ヶ月前
3ヶ月前
1ヶ月前
1ヶ月前
3ヶ月前
1ヶ月前
3ヶ月前
1ヶ月前
3ヶ月前
1ヶ月前
3ヶ月前
3ヶ月前
2ヶ月前
3ヶ月前
3ヶ月前
3ヶ月前
3ヶ月前
3ヶ月前
3ヶ月前
3ヶ月前
2ヶ月前
3ヶ月前
2ヶ月前
3ヶ月前
2ヶ月前
3ヶ月前
1ヶ月前
3ヶ月前
3ヶ月前
3ヶ月前
3ヶ月前
3ヶ月前
3ヶ月前
3ヶ月前
3ヶ月前
2ヶ月前
3ヶ月前
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. // FPSMS-frontend/src/components/PickOrderSearch/PickExecutionForm.tsx
  2. "use client";
  3. import {
  4. Box,
  5. Button,
  6. Dialog,
  7. DialogActions,
  8. DialogContent,
  9. DialogTitle,
  10. FormControl,
  11. Grid,
  12. InputLabel,
  13. MenuItem,
  14. Select,
  15. TextField,
  16. Typography,
  17. } from "@mui/material";
  18. import { useCallback, useEffect, useState } from "react";
  19. import { useTranslation } from "react-i18next";
  20. import { GetPickOrderLineInfo, PickExecutionIssueData } from "@/app/api/pickOrder/actions";
  21. import { fetchEscalationCombo } from "@/app/api/user/actions";
  22. import { useRef } from "react";
  23. import dayjs from 'dayjs';
  24. import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
  25. interface LotPickData {
  26. id: number;
  27. lotId: number;
  28. lotNo: string;
  29. expiryDate: string;
  30. location: string;
  31. stockUnit: string;
  32. inQty: number;
  33. outQty: number;
  34. holdQty: number;
  35. totalPickedByAllPickOrders: number;
  36. availableQty: number;
  37. requiredQty: number;
  38. actualPickQty: number;
  39. lotStatus: string;
  40. lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected';
  41. stockOutLineId?: number;
  42. stockOutLineStatus?: string;
  43. stockOutLineQty?: number;
  44. }
  45. interface PickExecutionFormProps {
  46. open: boolean;
  47. onClose: () => void;
  48. onSubmit: (data: PickExecutionIssueData) => Promise<void>;
  49. selectedLot: LotPickData | null;
  50. selectedPickOrderLine: (GetPickOrderLineInfo & { pickOrderCode: string }) | null;
  51. pickOrderId?: number;
  52. pickOrderCreateDate: any;
  53. // Remove these props since we're not handling normal cases
  54. // onNormalPickSubmit?: (lineId: number, lotId: number, qty: number) => Promise<void>;
  55. // selectedRowId?: number | null;
  56. }
  57. // 定义错误类型
  58. interface FormErrors {
  59. actualPickQty?: string;
  60. missQty?: string;
  61. badItemQty?: string;
  62. issueRemark?: string;
  63. handledBy?: string;
  64. }
  65. const PickExecutionForm: React.FC<PickExecutionFormProps> = ({
  66. open,
  67. onClose,
  68. onSubmit,
  69. selectedLot,
  70. selectedPickOrderLine,
  71. pickOrderId,
  72. pickOrderCreateDate,
  73. // Remove these props
  74. // onNormalPickSubmit,
  75. // selectedRowId,
  76. }) => {
  77. const { t } = useTranslation();
  78. const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({});
  79. const [errors, setErrors] = useState<FormErrors>({});
  80. const [loading, setLoading] = useState(false);
  81. const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]);
  82. // 计算剩余可用数量
  83. const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => {
  84. // 直接使用 availableQty,因为 API 没有返回 inQty 和 outQty
  85. return lot.availableQty || 0;
  86. }, []);
  87. const calculateRequiredQty = useCallback((lot: LotPickData) => {
  88. // Use the original required quantity, not subtracting actualPickQty
  89. // The actualPickQty in the form should be independent of the database value
  90. return lot.requiredQty || 0;
  91. }, []);
  92. const remaining = selectedLot ? calculateRemainingAvailableQty(selectedLot) : 0;
  93. const req = selectedLot ? calculateRequiredQty(selectedLot) : 0;
  94. const ap = Number(formData.actualPickQty) || 0;
  95. const miss = Number(formData.missQty) || 0;
  96. const bad = Number(formData.badItemQty) || 0;
  97. // Max the user can type
  98. const maxPick = Math.min(remaining, req);
  99. const maxIssueTotal = Math.max(0, req - ap); // remaining room for miss+bad
  100. const clamp0 = (v: any) => Math.max(0, Number(v) || 0);
  101. // 获取处理人员列表
  102. useEffect(() => {
  103. const fetchHandlers = async () => {
  104. try {
  105. const escalationCombo = await fetchEscalationCombo();
  106. setHandlers(escalationCombo);
  107. } catch (error) {
  108. console.error("Error fetching handlers:", error);
  109. }
  110. };
  111. fetchHandlers();
  112. }, []);
  113. const initKeyRef = useRef<string | null>(null);
  114. useEffect(() => {
  115. if (!open || !selectedLot || !selectedPickOrderLine || !pickOrderId) return;
  116. // Only initialize once per (pickOrderLineId + lotId) while dialog open
  117. const key = `${selectedPickOrderLine.id}-${selectedLot.lotId}`;
  118. if (initKeyRef.current === key) return;
  119. const getSafeDate = (dateValue: any): string => {
  120. if (!dateValue) return dayjs().format(INPUT_DATE_FORMAT);
  121. try {
  122. const date = dayjs(dateValue);
  123. if (!date.isValid()) {
  124. return dayjs().format(INPUT_DATE_FORMAT);
  125. }
  126. return date.format(INPUT_DATE_FORMAT);
  127. } catch {
  128. return dayjs().format(INPUT_DATE_FORMAT);
  129. }
  130. };
  131. setFormData({
  132. pickOrderId: pickOrderId,
  133. pickOrderCode: selectedPickOrderLine.pickOrderCode,
  134. pickOrderCreateDate: getSafeDate(pickOrderCreateDate),
  135. pickExecutionDate: dayjs().format(INPUT_DATE_FORMAT),
  136. pickOrderLineId: selectedPickOrderLine.id,
  137. itemId: selectedPickOrderLine.itemId,
  138. itemCode: selectedPickOrderLine.itemCode,
  139. itemDescription: selectedPickOrderLine.itemName,
  140. lotId: selectedLot.lotId,
  141. lotNo: selectedLot.lotNo,
  142. storeLocation: selectedLot.location,
  143. requiredQty: selectedLot.requiredQty,
  144. actualPickQty: selectedLot.actualPickQty || 0,
  145. missQty: 0,
  146. badItemQty: 0,
  147. issueRemark: '',
  148. pickerName: '',
  149. handledBy: undefined,
  150. });
  151. initKeyRef.current = key;
  152. }, [open, selectedPickOrderLine?.id, selectedLot?.lotId, pickOrderId, pickOrderCreateDate]);
  153. // Mutually exclusive inputs: picking vs reporting issues
  154. const handleInputChange = useCallback((field: keyof PickExecutionIssueData, value: any) => {
  155. setFormData(prev => ({ ...prev, [field]: value }));
  156. // 清除错误
  157. if (errors[field as keyof FormErrors]) {
  158. setErrors(prev => ({ ...prev, [field]: undefined }));
  159. }
  160. }, [errors]);
  161. // Update form validation to require either missQty > 0 OR badItemQty > 0
  162. const validateForm = (): boolean => {
  163. const newErrors: FormErrors = {};
  164. const req = selectedLot?.requiredQty || 0;
  165. const ap = Number(formData.actualPickQty) || 0;
  166. const miss = Number(formData.missQty) || 0;
  167. const bad = Number(formData.badItemQty) || 0;
  168. const total = ap + miss + bad;
  169. // 1. 检查 actualPickQty 不能为负数
  170. if (ap < 0) {
  171. newErrors.actualPickQty = t('Qty cannot be negative');
  172. }
  173. // 2. 检查 actualPickQty 不能超过可用数量或需求数量
  174. if (ap > Math.min(req)) {
  175. newErrors.actualPickQty = t('Qty is not allowed to be greater than required/available qty');
  176. }
  177. // 3. 检查 missQty 和 badItemQty 不能为负数
  178. if (miss < 0) {
  179. newErrors.missQty = t('Invalid qty');
  180. }
  181. if (bad < 0) {
  182. newErrors.badItemQty = t('Invalid qty');
  183. }
  184. // 4. 🔥 关键验证:总和必须等于 Required Qty(不能多也不能少)
  185. if (total !== req) {
  186. const diff = req - total;
  187. const errorMsg = diff > 0
  188. ? t('Total must equal Required Qty. Missing: {{diff}}', { diff })
  189. : t('Total must equal Required Qty. Exceeds by: {{diff}}', { diff: Math.abs(diff) });
  190. newErrors.actualPickQty = errorMsg;
  191. newErrors.missQty = errorMsg;
  192. newErrors.badItemQty = errorMsg;
  193. }
  194. // 5. 🔥 关键验证:如果只有 actualPickQty 有值,而 missQty 和 badItemQty 都为 0,不允许提交
  195. // 这意味着如果 actualPickQty < requiredQty,必须报告问题(missQty 或 badItemQty > 0)
  196. if (ap > 0 && miss === 0 && bad === 0 && ap < req) {
  197. newErrors.missQty = t('If Actual Pick Qty is less than Required Qty, you must report Missing Qty or Bad Item Qty');
  198. newErrors.badItemQty = t('If Actual Pick Qty is less than Required Qty, you must report Missing Qty or Bad Item Qty');
  199. }
  200. // 6. 如果所有值都为 0,不允许提交
  201. if (ap === 0 && miss === 0 && bad === 0) {
  202. newErrors.actualPickQty = t('Enter pick qty or issue qty');
  203. newErrors.missQty = t('Enter pick qty or issue qty');
  204. }
  205. // 7. 如果 actualPickQty = requiredQty,missQty 和 badItemQty 必须都为 0
  206. if (ap === req && (miss > 0 || bad > 0)) {
  207. newErrors.missQty = t('If Actual Pick Qty equals Required Qty, Missing Qty and Bad Item Qty must be 0');
  208. newErrors.badItemQty = t('If Actual Pick Qty equals Required Qty, Missing Qty and Bad Item Qty must be 0');
  209. }
  210. setErrors(newErrors);
  211. return Object.keys(newErrors).length === 0;
  212. };
  213. const handleSubmit = async () => {
  214. // First validate the form
  215. if (!validateForm()) {
  216. console.error('Form validation failed:', errors);
  217. return; // Prevent submission, show validation errors
  218. }
  219. if (!formData.pickOrderId) {
  220. console.error('Missing pickOrderId');
  221. return;
  222. }
  223. setLoading(true);
  224. try {
  225. await onSubmit(formData as PickExecutionIssueData);
  226. // Automatically closed when successful (handled by onClose)
  227. } catch (error: any) {
  228. console.error('Error submitting pick execution issue:', error);
  229. // Show error message (can be passed to parent component via props or state)
  230. // 或者在这里显示 toast/alert
  231. alert(t('Failed to submit issue. Please try again.') + (error.message ? `: ${error.message}` : ''));
  232. } finally {
  233. setLoading(false);
  234. }
  235. };
  236. const handleClose = () => {
  237. setFormData({});
  238. setErrors({});
  239. onClose();
  240. };
  241. if (!selectedLot || !selectedPickOrderLine) {
  242. return null;
  243. }
  244. const remainingAvailableQty = calculateRemainingAvailableQty(selectedLot);
  245. const requiredQty = calculateRequiredQty(selectedLot);
  246. return (
  247. <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
  248. <DialogTitle>
  249. {t('Pick Execution Issue Form')} {/* Always show issue form title */}
  250. </DialogTitle>
  251. <DialogContent>
  252. <Box sx={{ mt: 2 }}>
  253. {/* Add instruction text */}
  254. <Grid container spacing={2}>
  255. <Grid item xs={12}>
  256. <Box sx={{ p: 2, backgroundColor: '#fff3cd', borderRadius: 1, mb: 2 }}>
  257. <Typography variant="body2" color="warning.main">
  258. <strong>{t('Note:')}</strong> {t('This form is for reporting issues only. You must report either missing items or bad items.')}
  259. </Typography>
  260. </Box>
  261. </Grid>
  262. {/* Keep the existing form fields */}
  263. <Grid item xs={6}>
  264. <TextField
  265. fullWidth
  266. label={t('Required Qty')}
  267. value={selectedLot?.requiredQty || 0}
  268. disabled
  269. variant="outlined"
  270. // helperText={t('Still need to pick')}
  271. />
  272. </Grid>
  273. <Grid item xs={6}>
  274. <TextField
  275. fullWidth
  276. label={t('Remaining Available Qty')}
  277. value={remainingAvailableQty}
  278. disabled
  279. variant="outlined"
  280. // helperText={t('Available in warehouse')}
  281. />
  282. </Grid>
  283. <Grid item xs={12}>
  284. <TextField
  285. fullWidth
  286. label={t('Actual Pick Qty')}
  287. type="number"
  288. value={formData.actualPickQty ?? ''}
  289. onChange={(e) => handleInputChange('actualPickQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))}
  290. error={!!errors.actualPickQty}
  291. helperText={errors.actualPickQty || `${t('Max')}: ${Math.min(remainingAvailableQty, selectedLot?.requiredQty || 0)}`}
  292. variant="outlined"
  293. />
  294. </Grid>
  295. <Grid item xs={12}>
  296. <TextField
  297. fullWidth
  298. label={t('Missing item Qty')}
  299. type="number"
  300. value={formData.missQty || 0}
  301. onChange={(e) => handleInputChange('missQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))}
  302. error={!!errors.missQty}
  303. variant="outlined"
  304. //disabled={(formData.actualPickQty || 0) > 0}
  305. />
  306. </Grid>
  307. <Grid item xs={12}>
  308. <TextField
  309. fullWidth
  310. label={t('Bad Item Qty')}
  311. type="number"
  312. value={formData.badItemQty || 0}
  313. onChange={(e) => handleInputChange('badItemQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))}
  314. error={!!errors.badItemQty}
  315. variant="outlined"
  316. //disabled={(formData.actualPickQty || 0) > 0}
  317. />
  318. </Grid>
  319. {/* Show issue description and handler fields when bad items > 0 */}
  320. {(formData.badItemQty && formData.badItemQty > 0) ? (
  321. <>
  322. <Grid item xs={12}>
  323. <TextField
  324. fullWidth
  325. id="issueRemark"
  326. label={t('Issue Remark')}
  327. multiline
  328. rows={4}
  329. value={formData.issueRemark || ''}
  330. onChange={(e) => handleInputChange('issueRemark', e.target.value)}
  331. error={!!errors.issueRemark}
  332. helperText={errors.issueRemark}
  333. //placeholder={t('Describe the issue with bad items')}
  334. variant="outlined"
  335. />
  336. </Grid>
  337. <Grid item xs={12}>
  338. <FormControl fullWidth error={!!errors.handledBy}>
  339. <InputLabel>{t('handler')}</InputLabel>
  340. <Select
  341. value={formData.handledBy ? formData.handledBy.toString() : ''}
  342. onChange={(e) => handleInputChange('handledBy', e.target.value ? parseInt(e.target.value) : undefined)}
  343. label={t('handler')}
  344. >
  345. {handlers.map((handler) => (
  346. <MenuItem key={handler.id} value={handler.id.toString()}>
  347. {handler.name}
  348. </MenuItem>
  349. ))}
  350. </Select>
  351. {errors.handledBy && (
  352. <Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.75 }}>
  353. {errors.handledBy}
  354. </Typography>
  355. )}
  356. </FormControl>
  357. </Grid>
  358. </>
  359. ) : (<></>)}
  360. </Grid>
  361. </Box>
  362. </DialogContent>
  363. <DialogActions>
  364. <Button onClick={handleClose} disabled={loading}>
  365. {t('Cancel')}
  366. </Button>
  367. <Button
  368. onClick={handleSubmit}
  369. variant="contained"
  370. disabled={loading}
  371. >
  372. {loading ? t('submitting') : t('submit')}
  373. </Button>
  374. </DialogActions>
  375. </Dialog>
  376. );
  377. };
  378. export default PickExecutionForm;