FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

1693 regels
62 KiB

  1. "use client";
  2. import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
  3. import {
  4. Alert,
  5. Box,
  6. Button,
  7. Checkbox,
  8. CircularProgress,
  9. Grid,
  10. Modal,
  11. Paper,
  12. Stack,
  13. Table,
  14. TableBody,
  15. TableCell,
  16. TableContainer,
  17. TableHead,
  18. TablePagination,
  19. TableRow,
  20. TextField,
  21. Typography,
  22. } from "@mui/material";
  23. import { useSession } from "next-auth/react";
  24. import { useTranslation } from "react-i18next";
  25. import dayjs from "dayjs";
  26. import arraySupport from "dayjs/plugin/arraySupport";
  27. import SearchBox, { Criterion } from "../SearchBox";
  28. import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil";
  29. import { SessionWithTokens } from "@/config/authConfig";
  30. import {
  31. fetchPickOrderWithStockClient,
  32. fetchWorkbenchPickOrderLineDetailV2,
  33. confirmLotSubstitution,
  34. suggestPickOrderWorkbenchV2,
  35. type PickOrderLotDetailResponse,
  36. } from "@/app/api/pickOrder/actions";
  37. import { workbenchScanPick } from "@/app/api/doworkbench/actions";
  38. import { workbenchScanPickResponseNeedsFullRefresh } from "@/app/api/doworkbench/workbenchScanPickUtils";
  39. import { fetchStockInLineInfo } from "@/app/api/po/actions";
  40. import WorkbenchLotLabelPrintModal from "@/components/DoWorkbench/WorkbenchLotLabelPrintModal";
  41. import TestQrCodeProvider from "../QrCodeScannerProvider/TestQrCodeProvider";
  42. import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider";
  43. import ScanStatusAlert from "@/components/common/ScanStatusAlert";
  44. dayjs.extend(arraySupport);
  45. type TopRow = {
  46. rowKey: string;
  47. pickOrderId: number;
  48. pickOrderLineId: number;
  49. pickOrderCode: string;
  50. itemCode: string;
  51. itemName: string;
  52. requiredQty: number;
  53. currentStock: number;
  54. pickedQty: number;
  55. stockUnit: string;
  56. targetDate: string | number[];
  57. status: string;
  58. };
  59. type LotRow = {
  60. key: string;
  61. pickOrderId: number;
  62. pickOrderLineId: number;
  63. pickOrderCode: string;
  64. itemCode: string;
  65. itemName: string;
  66. uomDesc: string;
  67. requiredQty: number;
  68. availableQty: number;
  69. itemTotalAvailableQty?: number | null;
  70. stockOutLineId: number;
  71. status: string;
  72. pickedQty: number;
  73. lotNo: string;
  74. location: string;
  75. itemId?: number;
  76. stockInLineId?: number;
  77. suggestedPickLotId?: number;
  78. lotAvailability?: string;
  79. lotStatus?: string;
  80. expiryDate?: string;
  81. stockOutLineRejectMessage?: string;
  82. };
  83. type ConfirmLotState = {
  84. lotNo: string;
  85. itemCode: string;
  86. itemName: string;
  87. stockInLineId?: number;
  88. row: LotRow;
  89. };
  90. type LotRowIndexes = {
  91. byItemId: Map<number, LotRow[]>;
  92. byStockInLineId: Map<number, LotRow[]>;
  93. activeLotsByItemId: Map<number, LotRow[]>;
  94. };
  95. const ManualLotConfirmationModal: React.FC<{
  96. open: boolean;
  97. onClose: () => void;
  98. onConfirm: (expectedLotNo: string, scannedLotNo: string) => void;
  99. expectedLot: { lotNo: string; itemCode: string; itemName: string } | null;
  100. scannedLot: { lotNo: string; itemCode: string; itemName: string } | null;
  101. isLoading?: boolean;
  102. }> = ({ open, onClose, onConfirm, expectedLot, scannedLot, isLoading = false }) => {
  103. const { t } = useTranslation("pickOrder");
  104. const [expectedLotInput, setExpectedLotInput] = useState("");
  105. const [scannedLotInput, setScannedLotInput] = useState("");
  106. const [error, setError] = useState("");
  107. useEffect(() => {
  108. if (!open) return;
  109. setExpectedLotInput(expectedLot?.lotNo || "");
  110. setScannedLotInput(scannedLot?.lotNo || "");
  111. setError("");
  112. }, [expectedLot, open, scannedLot]);
  113. return (
  114. <Modal open={open} onClose={onClose}>
  115. <Box sx={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", bgcolor: "background.paper", p: 3, borderRadius: 2, minWidth: 500 }}>
  116. <Typography variant="h6" gutterBottom color="warning.main">
  117. {t("Manual Lot Confirmation")}
  118. </Typography>
  119. <TextField
  120. fullWidth
  121. size="small"
  122. label={t("Expected Lot Number")}
  123. value={expectedLotInput}
  124. onChange={(e) => {
  125. setExpectedLotInput(e.target.value);
  126. setError("");
  127. }}
  128. sx={{ mb: 2 }}
  129. />
  130. <TextField
  131. fullWidth
  132. size="small"
  133. label={t("Scanned Lot Number")}
  134. value={scannedLotInput}
  135. onChange={(e) => {
  136. setScannedLotInput(e.target.value);
  137. setError("");
  138. }}
  139. />
  140. {error ? (
  141. <Box sx={{ mt: 2, p: 1, borderRadius: 1, bgcolor: "#ffebee" }}>
  142. <Typography variant="body2" color="error">
  143. {error}
  144. </Typography>
  145. </Box>
  146. ) : null}
  147. <Box sx={{ mt: 2, display: "flex", justifyContent: "flex-end", gap: 2 }}>
  148. <Button onClick={onClose} variant="outlined" disabled={isLoading}>
  149. {t("Cancel")}
  150. </Button>
  151. <Button
  152. onClick={() => {
  153. if (!expectedLotInput.trim() || !scannedLotInput.trim()) {
  154. setError(t("Please enter both expected and scanned lot numbers."));
  155. return;
  156. }
  157. if (expectedLotInput.trim() === scannedLotInput.trim()) {
  158. setError(t("Expected and scanned lot numbers cannot be the same."));
  159. return;
  160. }
  161. onConfirm(expectedLotInput.trim(), scannedLotInput.trim());
  162. }}
  163. variant="contained"
  164. color="warning"
  165. disabled={isLoading}
  166. >
  167. {isLoading ? t("Processing...") : t("Confirm")}
  168. </Button>
  169. </Box>
  170. </Box>
  171. </Modal>
  172. );
  173. };
  174. interface Props {
  175. filterArgs?: Record<string, unknown>;
  176. }
  177. const toNum = (v: unknown, d = 0): number => {
  178. const n = Number(v);
  179. return Number.isFinite(n) ? n : d;
  180. };
  181. const toStr = (v: unknown): string => (typeof v === "string" ? v : "");
  182. const isCompletedStatus = (status: string | undefined): boolean => {
  183. const s = String(status || "").toLowerCase();
  184. return s === "completed" || s === "partially_completed" || s === "partially_complete";
  185. };
  186. const isCheckedStatus = (status: string | undefined): boolean =>
  187. String(status || "").toLowerCase() === "checked";
  188. const isRejectedStatus = (status: string | undefined): boolean =>
  189. String(status || "").toLowerCase() === "rejected";
  190. const isInventoryLotLineUnavailable = (row: LotRow): boolean => {
  191. const solSt = String(row.status || "").toLowerCase();
  192. if (solSt === "completed" || solSt === "partially_completed" || solSt === "partially_complete") return false;
  193. if (String(row.lotAvailability || "").toLowerCase() === "status_unavailable") return true;
  194. return String(row.lotStatus || "").toLowerCase() === "unavailable";
  195. };
  196. const isLotExpired = (row: LotRow): boolean => {
  197. if (String(row.lotAvailability || "").toLowerCase() === "expired") return true;
  198. if (!row.expiryDate) return false;
  199. const d = dayjs(row.expiryDate).startOf("day");
  200. return d.isValid() && d.isBefore(dayjs().startOf("day"));
  201. };
  202. const isNonBlockingSwitchLotReject = (code: unknown, message: unknown): boolean => {
  203. const c = String(code || "").toUpperCase();
  204. const m = String(message || "");
  205. if (c === "SUCCESS_UNAVAILABLE" || c === "BOUND_UNAVAILABLE") return true;
  206. if (/^Reject switch lot:/i.test(m)) return true;
  207. if (/available\s*=\s*\d+(\.\d+)?\s*<\s*required\s*=\s*\d+(\.\d+)?/i.test(m)) return true;
  208. return false;
  209. };
  210. function safeDisplayTargetDate(targetDate: string | number[]): string {
  211. try {
  212. if (Array.isArray(targetDate) && targetDate.length >= 3) {
  213. return arrayToDayjs(targetDate).format(OUTPUT_DATE_FORMAT);
  214. }
  215. const value = typeof targetDate === "string" ? targetDate : String(targetDate ?? "");
  216. const d = dayjs(value);
  217. return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : "-";
  218. } catch {
  219. return "-";
  220. }
  221. }
  222. function lineHasStockOutOrSuggestion(details: PickOrderLotDetailResponse[]): boolean {
  223. if (!details.length) return false;
  224. return details.some((d) => {
  225. const sol = toNum(d.stockOutLineId);
  226. const spl = toNum(d.suggestedPickLotId);
  227. return sol > 0 || spl > 0 || d.noLot === true;
  228. });
  229. }
  230. function mapLotDetailsToRows(
  231. details: PickOrderLotDetailResponse[],
  232. ctx: {
  233. pickOrderId: number;
  234. pickOrderLineId: number;
  235. pickOrderCode: string;
  236. itemCode: string;
  237. itemName: string;
  238. totalAvailableQty?: number | null;
  239. },
  240. ): LotRow[] {
  241. return details.map((d, i) => {
  242. const solId = toNum(d.stockOutLineId);
  243. const lotId = toNum(d.lotId, i);
  244. const stockInLineId = toNum(d.stockInLineId);
  245. return {
  246. key: solId > 0 ? `sol:${solId}` : `lot:${lotId}:${i}`,
  247. pickOrderId: ctx.pickOrderId,
  248. pickOrderLineId: ctx.pickOrderLineId,
  249. pickOrderCode: ctx.pickOrderCode,
  250. itemCode: ctx.itemCode,
  251. itemName: ctx.itemName,
  252. uomDesc: toStr(d.stockUnit),
  253. requiredQty: toNum(d.requiredQty),
  254. availableQty: toNum(d.remainingAfterAllPickOrders ?? d.availableQty),
  255. itemTotalAvailableQty: toNum(ctx.totalAvailableQty),
  256. stockOutLineId: solId,
  257. status: toStr(d.stockOutLineStatus ?? "pending"),
  258. pickedQty: toNum(d.actualPickQty ?? d.stockOutLineQty),
  259. lotNo: toStr(d.lotNo),
  260. location: toStr(d.location),
  261. itemId: toNum(d.itemId) || undefined,
  262. stockInLineId: stockInLineId > 0 ? stockInLineId : undefined,
  263. suggestedPickLotId: toNum(d.suggestedPickLotId) || undefined,
  264. lotAvailability: toStr((d as any).lotAvailability),
  265. lotStatus: toStr((d as any).lotStatus),
  266. expiryDate: toStr((d as any).expiryDate),
  267. stockOutLineRejectMessage: toStr((d as any).stockOutLineRejectMessage),
  268. };
  269. });
  270. }
  271. const WorkbenchPickExecution: React.FC<Props> = ({ filterArgs }) => {
  272. const { t } = useTranslation("pickOrder");
  273. const { data: session } = useSession() as { data: SessionWithTokens | null };
  274. const userId = session?.id ? parseInt(session.id, 10) : 0;
  275. const [originalTopRows, setOriginalTopRows] = useState<TopRow[]>([]);
  276. const [filteredTopRows, setFilteredTopRows] = useState<TopRow[]>([]);
  277. const [pickOrderLoading, setPickOrderLoading] = useState(false);
  278. const [pagingController, setPagingController] = useState({ pageNum: 1, pageSize: 10 });
  279. const [totalCountItems, setTotalCountItems] = useState(0);
  280. const localizeBackendMessage = (msg: unknown, fallbackKey: string) => {
  281. const text = typeof msg === "string" ? msg.trim() : "";
  282. if (!text) return t(fallbackKey);
  283. return t(text, { defaultValue: text });
  284. };
  285. const [selectedPickOrderLineId, setSelectedPickOrderLineId] = useState<number | null>(null);
  286. const [selectedPickOrderId, setSelectedPickOrderId] = useState<number | null>(null);
  287. const [selectedTopMeta, setSelectedTopMeta] = useState<{
  288. pickOrderCode: string;
  289. itemCode: string;
  290. itemName: string;
  291. totalAvailableQty?: number;
  292. } | null>(null);
  293. const [lotRows, setLotRows] = useState<LotRow[]>([]);
  294. const [qtyBySolId, setQtyBySolId] = useState<Record<number, number>>({});
  295. const [qtyEditableBySolId, setQtyEditableBySolId] = useState<Record<number, boolean>>({});
  296. const [lotPagingController, setLotPagingController] = useState({ pageNum: 0, pageSize: 10 });
  297. const [loading, setLoading] = useState(false);
  298. const [submittingSolId, setSubmittingSolId] = useState<number | null>(null);
  299. const [message, setMessage] = useState("");
  300. const [error, setError] = useState("");
  301. const [workbenchLotLabelModalOpen, setWorkbenchLotLabelModalOpen] = useState(false);
  302. const [workbenchLotLabelContextLot, setWorkbenchLotLabelContextLot] = useState<LotRow | null>(null);
  303. const [workbenchLotLabelInitialPayload, setWorkbenchLotLabelInitialPayload] =
  304. useState<{ itemId: number; stockInLineId: number } | null>(null);
  305. const [lotConfirmationOpen, setLotConfirmationOpen] = useState(false);
  306. const [lotConfirmationError, setLotConfirmationError] = useState<string | null>(null);
  307. const [expectedLotData, setExpectedLotData] = useState<ConfirmLotState | null>(null);
  308. const [scannedLotData, setScannedLotData] = useState<ConfirmLotState | null>(null);
  309. const [isConfirmingLot, setIsConfirmingLot] = useState(false);
  310. const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false);
  311. const [qrScanError, setQrScanError] = useState(false);
  312. const [qrScanSuccess, setQrScanSuccess] = useState(false);
  313. const [qrScanErrorMsg, setQrScanErrorMsg] = useState("");
  314. const [qrScanSuccessMsg, setQrScanSuccessMsg] = useState("");
  315. const lastProcessedQrRef = useRef<string>("");
  316. const processedQrCodesRef = useRef<Set<string>>(new Set());
  317. const lotConfirmLastQrRef = useRef<string>("");
  318. const lotConfirmSkipNextScanRef = useRef<boolean>(false);
  319. const lotConfirmOpenedAtRef = useRef<number>(0);
  320. const { values: qrValues, isScanning, startScan, resetScan } = useQrCodeScannerContext();
  321. const paginatedTopRows = useMemo(() => {
  322. const start = (pagingController.pageNum - 1) * pagingController.pageSize;
  323. return filteredTopRows.slice(start, start + pagingController.pageSize);
  324. }, [filteredTopRows, pagingController]);
  325. const paginatedLotRows = useMemo(() => {
  326. const start = lotPagingController.pageNum * lotPagingController.pageSize;
  327. return lotRows.slice(start, start + lotPagingController.pageSize);
  328. }, [lotRows, lotPagingController]);
  329. const lotRowIndexes = useMemo<LotRowIndexes>(() => {
  330. const byItemId = new Map<number, LotRow[]>();
  331. const byStockInLineId = new Map<number, LotRow[]>();
  332. const activeLotsByItemId = new Map<number, LotRow[]>();
  333. for (const row of lotRows) {
  334. const itemId = Number(row.itemId);
  335. const stockInLineId = Number(row.stockInLineId);
  336. const isActive =
  337. row.stockOutLineId > 0 &&
  338. !isCompletedStatus(row.status) &&
  339. !isCheckedStatus(row.status);
  340. if (Number.isFinite(itemId) && itemId > 0) {
  341. if (!byItemId.has(itemId)) byItemId.set(itemId, []);
  342. byItemId.get(itemId)!.push(row);
  343. if (isActive) {
  344. if (!activeLotsByItemId.has(itemId)) activeLotsByItemId.set(itemId, []);
  345. activeLotsByItemId.get(itemId)!.push(row);
  346. }
  347. }
  348. if (Number.isFinite(stockInLineId) && stockInLineId > 0) {
  349. if (!byStockInLineId.has(stockInLineId)) byStockInLineId.set(stockInLineId, []);
  350. byStockInLineId.get(stockInLineId)!.push(row);
  351. }
  352. }
  353. return { byItemId, byStockInLineId, activeLotsByItemId };
  354. }, [lotRows]);
  355. const fetchNewPageItems = useCallback(
  356. async (paging: { pageNum: number; pageSize: number }, extra: Record<string, unknown>) => {
  357. if (!userId) return;
  358. setPickOrderLoading(true);
  359. setError("");
  360. try {
  361. const params = {
  362. ...extra,
  363. pageNum: 0,
  364. pageSize: 9999,
  365. status: "released",
  366. type: "consumable",
  367. assignTo: userId,
  368. };
  369. const res = await fetchPickOrderWithStockClient(params);
  370. const records = Array.isArray(res?.records) ? res.records : [];
  371. const rows: TopRow[] = records.flatMap((r: any) => {
  372. const pickOrderId = toNum(r?.id);
  373. const code = toStr(r?.code);
  374. const status = toStr(r?.status);
  375. const targetDate = r?.targetDate;
  376. const lines = Array.isArray(r?.pickOrderLines) ? r.pickOrderLines : [];
  377. return lines.map((line: any, idx: number) => ({
  378. rowKey: `po:${pickOrderId}:line:${toNum(line?.id, idx)}`,
  379. pickOrderId,
  380. pickOrderLineId: toNum(line?.id),
  381. pickOrderCode: code,
  382. itemCode: toStr(line?.itemCode),
  383. itemName: toStr(line?.itemName),
  384. requiredQty: toNum(line?.requiredQty),
  385. currentStock: toNum(line?.availableQty),
  386. pickedQty: toNum(line?.pickedQty),
  387. stockUnit: toStr(line?.uomDesc ?? line?.uomShortDesc),
  388. targetDate: targetDate ?? "",
  389. status,
  390. }));
  391. });
  392. setOriginalTopRows(rows);
  393. setFilteredTopRows(rows);
  394. const pageSize = paging.pageSize || 10;
  395. const pageNum = paging.pageNum || 1;
  396. setTotalCountItems(rows.length);
  397. setPagingController({ pageNum, pageSize });
  398. return rows;
  399. } catch (e) {
  400. console.error(e);
  401. setError(t("Load released pick orders failed"));
  402. setOriginalTopRows([]);
  403. setFilteredTopRows([]);
  404. setTotalCountItems(0);
  405. return [] as TopRow[];
  406. } finally {
  407. setPickOrderLoading(false);
  408. }
  409. },
  410. [t, userId],
  411. );
  412. const refreshReleasedTopRowsAfterMutation = useCallback(async () => {
  413. const latestRows =
  414. (await fetchNewPageItems(
  415. pagingController,
  416. (filterArgs || {}) as Record<string, unknown>,
  417. )) || [];
  418. if (
  419. selectedPickOrderLineId != null &&
  420. !latestRows.some((r) => r.pickOrderLineId === selectedPickOrderLineId)
  421. ) {
  422. setSelectedPickOrderLineId(null);
  423. setSelectedPickOrderId(null);
  424. setSelectedTopMeta(null);
  425. setLotRows([]);
  426. setQtyBySolId({});
  427. setQtyEditableBySolId({});
  428. setLotPagingController({ pageNum: 0, pageSize: 10 });
  429. }
  430. }, [fetchNewPageItems, filterArgs, pagingController, selectedPickOrderLineId]);
  431. const searchCriteria: Criterion<any>[] = useMemo(
  432. () => [
  433. { label: t("Item Code"), paramName: "itemCode", type: "text" },
  434. { label: t("Pick Order Code"), paramName: "pickOrderCode", type: "text" },
  435. { label: t("Item Name"), paramName: "itemName", type: "text" },
  436. { label: t("Target Date From"), label2: t("Target Date To"), paramName: "targetDate", type: "dateRange" },
  437. ],
  438. [t],
  439. );
  440. const handleSearch = useCallback(
  441. (query: Record<string, string>) => {
  442. const filtered = originalTopRows.filter((row) => {
  443. const itemCodeMatch = !query.itemCode || row.itemCode.toLowerCase().includes(query.itemCode.toLowerCase());
  444. const pickOrderCodeMatch =
  445. !query.pickOrderCode || row.pickOrderCode.toLowerCase().includes(query.pickOrderCode.toLowerCase());
  446. const itemNameMatch = !query.itemName || row.itemName.toLowerCase().includes(query.itemName.toLowerCase());
  447. const targetDate = Array.isArray(row.targetDate)
  448. ? arrayToDayjs(row.targetDate)
  449. : dayjs(typeof row.targetDate === "string" ? row.targetDate : "");
  450. let dateMatch = true;
  451. if (query.targetDate || query.targetDateTo) {
  452. const fromDate = query.targetDate ? dayjs(query.targetDate) : null;
  453. const toDate = query.targetDateTo ? dayjs(query.targetDateTo) : null;
  454. if (targetDate.isValid()) {
  455. if (fromDate && fromDate.isValid()) dateMatch = dateMatch && (targetDate.isSame(fromDate, "day") || targetDate.isAfter(fromDate, "day"));
  456. if (toDate && toDate.isValid()) dateMatch = dateMatch && (targetDate.isSame(toDate, "day") || targetDate.isBefore(toDate, "day"));
  457. }
  458. }
  459. return itemCodeMatch && pickOrderCodeMatch && itemNameMatch && dateMatch;
  460. });
  461. setFilteredTopRows(filtered);
  462. setTotalCountItems(filtered.length);
  463. setPagingController((prev) => ({ ...prev, pageNum: 1 }));
  464. },
  465. [originalTopRows],
  466. );
  467. const handleReset = useCallback(() => {
  468. setFilteredTopRows(originalTopRows);
  469. setTotalCountItems(originalTopRows.length);
  470. setPagingController((prev) => ({ ...prev, pageNum: 1 }));
  471. }, [originalTopRows]);
  472. useEffect(() => {
  473. if (userId) fetchNewPageItems(pagingController, (filterArgs || {}) as Record<string, unknown>);
  474. // eslint-disable-next-line react-hooks/exhaustive-deps
  475. }, [userId, filterArgs, fetchNewPageItems]);
  476. const loadLineDetailV2 = useCallback(
  477. async (
  478. pickOrderId: number,
  479. pickOrderLineId: number,
  480. meta: {
  481. pickOrderCode: string;
  482. itemCode: string;
  483. itemName: string;
  484. totalAvailableQty?: number;
  485. },
  486. ) => {
  487. if (!userId || pickOrderLineId <= 0) return;
  488. setLoading(true);
  489. setError("");
  490. setMessage("");
  491. try {
  492. let details = await fetchWorkbenchPickOrderLineDetailV2(pickOrderLineId);
  493. let list = Array.isArray(details) ? details : [];
  494. if (!lineHasStockOutOrSuggestion(list)) {
  495. const suggestRes = await suggestPickOrderWorkbenchV2(pickOrderId, userId);
  496. if (suggestRes.code !== "SUCCESS") {
  497. setError(t("Suggest pick failed"));
  498. setLotRows([]);
  499. return;
  500. }
  501. details = await fetchWorkbenchPickOrderLineDetailV2(pickOrderLineId);
  502. list = Array.isArray(details) ? details : [];
  503. setMessage(t("Suggestion success"));
  504. }
  505. setLotRows(
  506. mapLotDetailsToRows(list, {
  507. pickOrderId,
  508. pickOrderLineId,
  509. pickOrderCode: meta.pickOrderCode,
  510. itemCode: meta.itemCode,
  511. itemName: meta.itemName,
  512. totalAvailableQty: meta.totalAvailableQty,
  513. }),
  514. );
  515. setQtyEditableBySolId({});
  516. } catch (e) {
  517. console.error(e);
  518. setError(t("Load workbench data failed"));
  519. setLotRows([]);
  520. } finally {
  521. setLoading(false);
  522. }
  523. },
  524. [t, userId],
  525. );
  526. const submitRow = useCallback(
  527. async (row: LotRow, forceQty?: number) => {
  528. if (!userId) return;
  529. if (!row.stockOutLineId) {
  530. setError(t("No stock out line for this lot"));
  531. return;
  532. }
  533. const qtyInput = qtyBySolId[row.stockOutLineId];
  534. const qtyValue = forceQty ?? (typeof qtyInput === "number" && Number.isFinite(qtyInput) ? qtyInput : undefined);
  535. setSubmittingSolId(row.stockOutLineId);
  536. setError("");
  537. setMessage("");
  538. try {
  539. const res = await workbenchScanPick({
  540. stockOutLineId: row.stockOutLineId,
  541. lotNo: row.lotNo.trim(),
  542. ...(Number.isFinite(Number(row.stockInLineId)) && Number(row.stockInLineId) > 0
  543. ? { stockInLineId: Number(row.stockInLineId) }
  544. : {}),
  545. ...(typeof qtyValue === "number" && Number.isFinite(qtyValue) ? { qty: qtyValue } : {}),
  546. userId,
  547. });
  548. const errMsg = localizeBackendMessage(res.message, "Scan pick failed");
  549. setError(errMsg);
  550. setQrScanErrorMsg(errMsg);
  551. const okMsg = localizeBackendMessage(res.message, "Scan pick success");
  552. setMessage(okMsg);
  553. setQrScanSuccessMsg(okMsg);
  554. if (workbenchScanPickResponseNeedsFullRefresh(res)) {
  555. if (selectedPickOrderId != null && selectedPickOrderLineId != null && selectedTopMeta) {
  556. await loadLineDetailV2(selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta);
  557. }
  558. } else {
  559. const entity = res.entity as any;
  560. setLotRows((prev) =>
  561. prev.map((r) =>
  562. r.stockOutLineId === row.stockOutLineId
  563. ? { ...r, status: toStr(entity?.status || r.status), pickedQty: toNum(entity?.qty, r.pickedQty) }
  564. : r,
  565. ),
  566. );
  567. }
  568. setWorkbenchLotLabelModalOpen(false);
  569. setWorkbenchLotLabelContextLot(null);
  570. setWorkbenchLotLabelInitialPayload(null);
  571. await refreshReleasedTopRowsAfterMutation();
  572. } catch (e) {
  573. console.error(e);
  574. setError(t("Scan pick failed"));
  575. startTransition(() => {
  576. setQrScanError(true);
  577. setQrScanSuccess(false);
  578. setQrScanErrorMsg(t("Scan pick failed"));
  579. });
  580. } finally {
  581. setSubmittingSolId(null);
  582. }
  583. },
  584. [qtyBySolId, loadLineDetailV2, refreshReleasedTopRowsAfterMutation, selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta, t, userId],
  585. );
  586. const hasQtyOverrideBySolId = useCallback(
  587. (stockOutLineId: number) => Object.prototype.hasOwnProperty.call(qtyBySolId, stockOutLineId),
  588. [qtyBySolId],
  589. );
  590. const resolveSingleSubmitQty = useCallback(
  591. (lot: LotRow): number => {
  592. const override = qtyBySolId[lot.stockOutLineId];
  593. if (typeof override === "number" && Number.isFinite(override) && override >= 0) {
  594. return override;
  595. }
  596. return Number(lot.requiredQty) || 0;
  597. },
  598. [qtyBySolId],
  599. );
  600. const workbenchScanPickQtyFromLot = useCallback(
  601. (lot: LotRow) => {
  602. const hasExplicitOverride = hasQtyOverrideBySolId(lot.stockOutLineId);
  603. const n = Number(resolveSingleSubmitQty(lot));
  604. if (hasExplicitOverride && Number.isFinite(n) && n === 0) return { qty: 0 } as const;
  605. if (!Number.isFinite(n) || n <= 0) return {};
  606. return { qty: n } as const;
  607. },
  608. [hasQtyOverrideBySolId, resolveSingleSubmitQty],
  609. );
  610. const handleJustComplete = useCallback(
  611. async (row: LotRow) => {
  612. if (!row.stockOutLineId) {
  613. setError(t("No stock out line for this lot"));
  614. return;
  615. }
  616. const lotNo = String(row.lotNo || "").trim();
  617. const isUnavailable = isInventoryLotLineUnavailable(row);
  618. const isExpired = isLotExpired(row);
  619. const hasExplicitOverride = hasQtyOverrideBySolId(row.stockOutLineId);
  620. const explicitQty = hasExplicitOverride ? Number(qtyBySolId[row.stockOutLineId]) : NaN;
  621. const qtyPayload = workbenchScanPickQtyFromLot(row);
  622. const wbJustQty = qtyPayload.qty;
  623. const canPostScanPick =
  624. isUnavailable ||
  625. (lotNo !== "" &&
  626. ((hasExplicitOverride && Number.isFinite(explicitQty) && explicitQty === 0) ||
  627. (wbJustQty != null && wbJustQty > 0)));
  628. if (!canPostScanPick) {
  629. const msg = t(
  630. "Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.",
  631. );
  632. setError(msg);
  633. startTransition(() => {
  634. setQrScanError(true);
  635. setQrScanSuccess(false);
  636. setQrScanErrorMsg(msg);
  637. });
  638. return;
  639. }
  640. if (isExpired && !isUnavailable) {
  641. const msg = t(
  642. "Just Completed (workbench): requires a valid lot number and quantity; expired rows must not use this button.",
  643. );
  644. setError(msg);
  645. startTransition(() => {
  646. setQrScanError(true);
  647. setQrScanSuccess(false);
  648. setQrScanErrorMsg(msg);
  649. });
  650. return;
  651. }
  652. const qtyToSend =
  653. isUnavailable || (hasExplicitOverride && Number.isFinite(explicitQty) && explicitQty === 0)
  654. ? 0
  655. : Number(wbJustQty);
  656. await submitRow(row, qtyToSend);
  657. },
  658. [hasQtyOverrideBySolId, qtyBySolId, submitRow, t, workbenchScanPickQtyFromLot],
  659. );
  660. const handleLineSelect = useCallback(
  661. async (row: TopRow, checked: boolean) => {
  662. if (!checked) {
  663. if (selectedPickOrderLineId === row.pickOrderLineId) {
  664. setSelectedPickOrderLineId(null);
  665. setSelectedPickOrderId(null);
  666. setSelectedTopMeta(null);
  667. setLotRows([]);
  668. setQtyBySolId({});
  669. setQtyEditableBySolId({});
  670. setLotPagingController({ pageNum: 0, pageSize: 10 });
  671. }
  672. return;
  673. }
  674. setSelectedPickOrderLineId(row.pickOrderLineId);
  675. setSelectedPickOrderId(row.pickOrderId);
  676. setSelectedTopMeta({
  677. pickOrderCode: row.pickOrderCode,
  678. itemCode: row.itemCode,
  679. itemName: row.itemName,
  680. totalAvailableQty: row.currentStock,
  681. });
  682. setLotRows([]);
  683. setQtyBySolId({});
  684. setQtyEditableBySolId({});
  685. setLotPagingController({ pageNum: 0, pageSize: 10 });
  686. setMessage("");
  687. await loadLineDetailV2(row.pickOrderId, row.pickOrderLineId, {
  688. pickOrderCode: row.pickOrderCode,
  689. itemCode: row.itemCode,
  690. itemName: row.itemName,
  691. totalAvailableQty: row.currentStock,
  692. });
  693. },
  694. [loadLineDetailV2, selectedPickOrderLineId],
  695. );
  696. const openWorkbenchLotLabelModalForLot = useCallback((lot: LotRow) => {
  697. const itemId = Number(lot.itemId);
  698. const stockInLineId = Number(lot.stockInLineId);
  699. setWorkbenchLotLabelContextLot(lot);
  700. if (Number.isFinite(itemId) && itemId > 0 && Number.isFinite(stockInLineId) && stockInLineId > 0) {
  701. setWorkbenchLotLabelInitialPayload({ itemId, stockInLineId });
  702. } else {
  703. setWorkbenchLotLabelInitialPayload(null);
  704. }
  705. setWorkbenchLotLabelModalOpen(true);
  706. }, []);
  707. const handleWorkbenchLotLabelScanPick = useCallback(
  708. async ({ inventoryLotLineId, lotNo, qty }: { inventoryLotLineId: number; lotNo: string; qty?: number }) => {
  709. if (!userId) throw new Error(t("User not found"));
  710. if (!workbenchLotLabelContextLot?.stockOutLineId) {
  711. throw new Error(t("No stock out line for this lot"));
  712. }
  713. const fallbackQty = Number(
  714. resolveSingleSubmitQty(workbenchLotLabelContextLot),
  715. );
  716. const res = await workbenchScanPick({
  717. stockOutLineId: workbenchLotLabelContextLot.stockOutLineId,
  718. inventoryLotLineId,
  719. lotNo,
  720. ...(typeof qty === "number" && Number.isFinite(qty)
  721. ? { qty }
  722. : Number.isFinite(fallbackQty) && fallbackQty >= 0
  723. ? { qty: fallbackQty }
  724. : {}),
  725. userId,
  726. });
  727. if (res.code !== "SUCCESS") {
  728. throw new Error((res.message as string) || t("Scan pick failed"));
  729. }
  730. if (selectedPickOrderId != null && selectedPickOrderLineId != null && selectedTopMeta) {
  731. await loadLineDetailV2(selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta);
  732. }
  733. setWorkbenchLotLabelModalOpen(false);
  734. setWorkbenchLotLabelContextLot(null);
  735. setWorkbenchLotLabelInitialPayload(null);
  736. },
  737. [
  738. loadLineDetailV2,
  739. qtyBySolId,
  740. selectedPickOrderId,
  741. selectedPickOrderLineId,
  742. selectedTopMeta,
  743. t,
  744. userId,
  745. workbenchLotLabelContextLot,
  746. ],
  747. );
  748. const handleScanLotByLotNo = useCallback(
  749. async (lotNo: string) => {
  750. const normalized = String(lotNo || "").trim();
  751. if (!normalized) return;
  752. const target = lotRows.find(
  753. (r) =>
  754. String(r.lotNo || "").trim() === normalized &&
  755. r.stockOutLineId > 0 &&
  756. !isCompletedStatus(r.status) &&
  757. !isCheckedStatus(r.status),
  758. );
  759. if (!target) {
  760. setError(t("Lot not found in current line"));
  761. return;
  762. }
  763. await submitRow(target);
  764. },
  765. [lotRows, submitRow, t],
  766. );
  767. const resolveScanCandidate = useCallback(
  768. (rawQr: string): ConfirmLotState | null => {
  769. const latest = String(rawQr || "").trim();
  770. if (!latest) return null;
  771. try {
  772. const parsed = JSON.parse(latest);
  773. const stockInLineId = toNum(parsed?.stockInLineId);
  774. if (stockInLineId > 0) {
  775. const row = lotRows.find((r) => Number(r.stockInLineId) === stockInLineId && r.stockOutLineId > 0);
  776. if (!row) return null;
  777. return {
  778. lotNo: String(row.lotNo || "").trim(),
  779. itemCode: row.itemCode,
  780. itemName: row.itemName,
  781. stockInLineId,
  782. row,
  783. };
  784. }
  785. } catch {
  786. // non-json; fallback to lotNo match
  787. }
  788. const lotNo = latest.replace(/[{}]/g, "").trim();
  789. if (!lotNo) return null;
  790. const row = lotRows.find((r) => String(r.lotNo || "").trim() === lotNo && r.stockOutLineId > 0);
  791. if (!row) return null;
  792. return {
  793. lotNo,
  794. itemCode: row.itemCode,
  795. itemName: row.itemName,
  796. stockInLineId: row.stockInLineId,
  797. row,
  798. };
  799. },
  800. [lotRows],
  801. );
  802. const toConfirmLotState = useCallback((row: LotRow): ConfirmLotState => {
  803. return {
  804. lotNo: String(row.lotNo || "").trim(),
  805. itemCode: row.itemCode,
  806. itemName: row.itemName,
  807. stockInLineId: row.stockInLineId,
  808. row,
  809. };
  810. }, []);
  811. const toConfirmLotStateWithOverrides = useCallback(
  812. (row: LotRow, override: { lotNo?: string; stockInLineId?: number }): ConfirmLotState => {
  813. return {
  814. lotNo: String(override.lotNo ?? row.lotNo ?? "").trim(),
  815. itemCode: row.itemCode,
  816. itemName: row.itemName,
  817. stockInLineId: override.stockInLineId ?? row.stockInLineId,
  818. row,
  819. };
  820. },
  821. [],
  822. );
  823. const pickExpectedRowForSubstitution = useCallback((rows: LotRow[]): LotRow | null => {
  824. if (!rows.length) return null;
  825. const withLotNo = rows.filter((r) => String(r.lotNo || "").trim() !== "");
  826. if (withLotNo.length === 1) return withLotNo[0];
  827. if (withLotNo.length > 1) {
  828. const pending = withLotNo.find((r) => String(r.status || "").toLowerCase() === "pending");
  829. return pending || withLotNo[0];
  830. }
  831. return rows[0];
  832. }, []);
  833. const clearLotConfirmationState = useCallback((clearProcessedRefs = false) => {
  834. setLotConfirmationOpen(false);
  835. setLotConfirmationError(null);
  836. setExpectedLotData(null);
  837. setScannedLotData(null);
  838. lotConfirmLastQrRef.current = "";
  839. lotConfirmSkipNextScanRef.current = false;
  840. lotConfirmOpenedAtRef.current = 0;
  841. if (clearProcessedRefs) {
  842. setTimeout(() => {
  843. lastProcessedQrRef.current = "";
  844. processedQrCodesRef.current.clear();
  845. }, 100);
  846. }
  847. }, []);
  848. const handleLotConfirmation = useCallback(
  849. async (overrideScanned?: ConfirmLotState, overrideExpected?: ConfirmLotState) => {
  850. const expected = overrideExpected ?? expectedLotData;
  851. const scanned = overrideScanned ?? scannedLotData;
  852. if (!expected || !scanned) return;
  853. setIsConfirmingLot(true);
  854. setLotConfirmationError(null);
  855. setError("");
  856. setMessage("");
  857. try {
  858. const originalSuggestedPickLotId = Number(expected.row.suggestedPickLotId || 0);
  859. let switchedToUnavailable = false;
  860. if (originalSuggestedPickLotId > 0) {
  861. const res = await confirmLotSubstitution({
  862. pickOrderLineId: expected.row.pickOrderLineId,
  863. stockOutLineId: expected.row.stockOutLineId,
  864. originalSuggestedPickLotId,
  865. newInventoryLotNo: scanned.lotNo,
  866. newStockInLineId: Number(scanned.stockInLineId ?? 0),
  867. });
  868. switchedToUnavailable = res.code === "SUCCESS_UNAVAILABLE" || res.code === "BOUND_UNAVAILABLE";
  869. const nonBlockingReject = isNonBlockingSwitchLotReject(res.code, res.message);
  870. if (res.code !== "SUCCESS" && !switchedToUnavailable && !nonBlockingReject) {
  871. const msg = (res.message as string) || t("Lot switch failed");
  872. setLotConfirmationError(msg);
  873. setError(msg);
  874. startTransition(() => {
  875. setQrScanError(true);
  876. setQrScanSuccess(false);
  877. setQrScanErrorMsg(msg);
  878. });
  879. return;
  880. }
  881. if (nonBlockingReject && !switchedToUnavailable) {
  882. const warnMsg = (res.message as string) || t("Lot switch rejected. Continue with scan-pick.");
  883. setMessage(warnMsg);
  884. }
  885. }
  886. if (!switchedToUnavailable) {
  887. const res = await workbenchScanPick({
  888. stockOutLineId: expected.row.stockOutLineId,
  889. lotNo: scanned.lotNo,
  890. ...(Number.isFinite(Number(scanned.stockInLineId)) && Number(scanned.stockInLineId) > 0
  891. ? { stockInLineId: Number(scanned.stockInLineId) }
  892. : {}),
  893. ...workbenchScanPickQtyFromLot(expected.row),
  894. userId,
  895. });
  896. if (res.code !== "SUCCESS") {
  897. const msg = (res.message as string) || t("Workbench scan-pick failed.");
  898. setLotConfirmationError(msg);
  899. setError(msg);
  900. startTransition(() => {
  901. setQrScanError(true);
  902. setQrScanSuccess(false);
  903. setQrScanErrorMsg(msg);
  904. });
  905. return;
  906. }
  907. }
  908. setMessage(t("Scan pick success"));
  909. startTransition(() => {
  910. setQrScanError(false);
  911. setQrScanSuccess(true);
  912. setQrScanSuccessMsg(t("Scan pick success"));
  913. });
  914. if (selectedPickOrderId != null && selectedPickOrderLineId != null && selectedTopMeta) {
  915. await loadLineDetailV2(selectedPickOrderId, selectedPickOrderLineId, selectedTopMeta);
  916. }
  917. await refreshReleasedTopRowsAfterMutation();
  918. clearLotConfirmationState(true);
  919. } catch (e) {
  920. console.error(e);
  921. const msg = t("Lot confirmation failed. Please try again.");
  922. setLotConfirmationError(msg);
  923. setError(msg);
  924. startTransition(() => {
  925. setQrScanError(true);
  926. setQrScanSuccess(false);
  927. setQrScanErrorMsg(msg);
  928. });
  929. } finally {
  930. setIsConfirmingLot(false);
  931. }
  932. },
  933. [
  934. clearLotConfirmationState,
  935. expectedLotData,
  936. loadLineDetailV2,
  937. refreshReleasedTopRowsAfterMutation,
  938. scannedLotData,
  939. selectedPickOrderId,
  940. selectedPickOrderLineId,
  941. selectedTopMeta,
  942. t,
  943. userId,
  944. workbenchScanPickQtyFromLot,
  945. ],
  946. );
  947. const handleLotConfirmationByRescan = useCallback(
  948. async (rawQr: string): Promise<boolean> => {
  949. if (!lotConfirmationOpen || !expectedLotData || !scannedLotData) return false;
  950. const latest = String(rawQr || "").trim();
  951. if (!latest) return false;
  952. let parsed: any;
  953. try {
  954. parsed = JSON.parse(latest);
  955. } catch {
  956. return false;
  957. }
  958. const rescannedItemId = toNum(parsed?.itemId);
  959. const rescannedStockInLineId = toNum(parsed?.stockInLineId);
  960. if (rescannedItemId <= 0 || rescannedStockInLineId <= 0) return false;
  961. const expectedItemId = Number(expectedLotData.row.itemId || 0);
  962. if (expectedItemId > 0 && rescannedItemId !== expectedItemId) return false;
  963. const expectedStockInLineId = Number(expectedLotData.stockInLineId || expectedLotData.row.stockInLineId || 0);
  964. const scannedStockInLineId = Number(scannedLotData.stockInLineId || scannedLotData.row.stockInLineId || 0);
  965. if (expectedStockInLineId > 0 && rescannedStockInLineId === expectedStockInLineId) {
  966. clearLotConfirmationState(false);
  967. await submitRow(expectedLotData.row);
  968. return true;
  969. }
  970. if (scannedStockInLineId > 0 && rescannedStockInLineId === scannedStockInLineId) {
  971. await handleLotConfirmation();
  972. return true;
  973. }
  974. const itemRows = lotRowIndexes.byItemId.get(rescannedItemId) || [];
  975. const rowByStockInLineId = itemRows.find(
  976. (r) =>
  977. Number(r.stockInLineId) === rescannedStockInLineId &&
  978. r.stockOutLineId > 0 &&
  979. !isCompletedStatus(r.status) &&
  980. !isCheckedStatus(r.status),
  981. );
  982. if (rowByStockInLineId) {
  983. await handleLotConfirmation(toConfirmLotState(rowByStockInLineId));
  984. return true;
  985. }
  986. try {
  987. const info = await fetchStockInLineInfo(rescannedStockInLineId);
  988. const rescannedLotNo = String(info?.lotNo || "").trim();
  989. if (!rescannedLotNo) return false;
  990. await handleLotConfirmation(
  991. toConfirmLotStateWithOverrides(expectedLotData.row, {
  992. lotNo: rescannedLotNo,
  993. stockInLineId: rescannedStockInLineId,
  994. }),
  995. );
  996. } catch {
  997. return false;
  998. }
  999. return true;
  1000. },
  1001. [
  1002. clearLotConfirmationState,
  1003. expectedLotData,
  1004. handleLotConfirmation,
  1005. lotConfirmationOpen,
  1006. lotRowIndexes,
  1007. scannedLotData,
  1008. submitRow,
  1009. toConfirmLotState,
  1010. toConfirmLotStateWithOverrides,
  1011. ],
  1012. );
  1013. const processOutsideQrCode = useCallback(
  1014. async (rawQr: string) => {
  1015. const latest = String(rawQr || "").trim();
  1016. if (!latest) return;
  1017. setError("");
  1018. setMessage("");
  1019. startTransition(() => {
  1020. setQrScanError(false);
  1021. setQrScanSuccess(false);
  1022. });
  1023. if (latest === "{2fic}") {
  1024. setManualLotConfirmationOpen(true);
  1025. return;
  1026. }
  1027. if (lotConfirmationOpen) {
  1028. const handled = await handleLotConfirmationByRescan(latest);
  1029. if (handled) return;
  1030. }
  1031. let parsed: any;
  1032. try {
  1033. parsed = JSON.parse(latest);
  1034. } catch {
  1035. setError(t("Invalid QR format. Expected JSON with itemId and stockInLineId."));
  1036. startTransition(() => {
  1037. setQrScanError(true);
  1038. setQrScanSuccess(false);
  1039. setQrScanErrorMsg(t("Invalid QR format. Expected JSON with itemId and stockInLineId."));
  1040. });
  1041. resetScan();
  1042. return;
  1043. }
  1044. const scannedItemId = toNum(parsed?.itemId);
  1045. const scannedStockInLineId = toNum(parsed?.stockInLineId);
  1046. if (scannedItemId <= 0 || scannedStockInLineId <= 0) {
  1047. setError(t("Invalid QR data. itemId and stockInLineId are required."));
  1048. startTransition(() => {
  1049. setQrScanError(true);
  1050. setQrScanSuccess(false);
  1051. setQrScanErrorMsg(t("Invalid QR data. itemId and stockInLineId are required."));
  1052. });
  1053. resetScan();
  1054. return;
  1055. }
  1056. const activeSuggestedLots = lotRowIndexes.activeLotsByItemId.get(scannedItemId) || [];
  1057. const allLotsForItem = lotRowIndexes.byItemId.get(scannedItemId) || [];
  1058. if (allLotsForItem.length === 0) {
  1059. setError(t("Scanned item is not found in current line"));
  1060. startTransition(() => {
  1061. setQrScanError(true);
  1062. setQrScanSuccess(false);
  1063. setQrScanErrorMsg(t("Scanned item is not found in current line"));
  1064. });
  1065. resetScan();
  1066. return;
  1067. }
  1068. const expectedPool = activeSuggestedLots.length > 0 ? activeSuggestedLots : allLotsForItem;
  1069. const expectedRow = pickExpectedRowForSubstitution(expectedPool) || allLotsForItem[0];
  1070. const scannedRows = lotRowIndexes.byStockInLineId.get(scannedStockInLineId) || [];
  1071. const scannedRowInItem =
  1072. scannedRows.find(
  1073. (r) =>
  1074. Number(r.itemId) === scannedItemId &&
  1075. r.stockOutLineId > 0,
  1076. ) ||
  1077. null;
  1078. if (scannedRowInItem && isRejectedStatus(scannedRowInItem.status)) {
  1079. const rejectMsg =
  1080. scannedRowInItem.stockOutLineRejectMessage ||
  1081. t("This lot is rejected. Please scan another lot.");
  1082. setError(rejectMsg);
  1083. startTransition(() => {
  1084. setQrScanError(true);
  1085. setQrScanSuccess(false);
  1086. setQrScanErrorMsg(rejectMsg);
  1087. });
  1088. return;
  1089. }
  1090. if (scannedRowInItem && isInventoryLotLineUnavailable(scannedRowInItem)) {
  1091. startTransition(() => {
  1092. setQrScanError(false);
  1093. setQrScanSuccess(false);
  1094. });
  1095. setMessage(t("This lot is unavailable, please scan another lot."));
  1096. openWorkbenchLotLabelModalForLot(scannedRowInItem);
  1097. return;
  1098. }
  1099. if (scannedRowInItem && isLotExpired(scannedRowInItem)) {
  1100. const expiredMsg = t("Lot is expired");
  1101. setError(expiredMsg);
  1102. startTransition(() => {
  1103. setQrScanError(true);
  1104. setQrScanSuccess(false);
  1105. setQrScanErrorMsg(
  1106. scannedRowInItem.expiryDate
  1107. ? `${expiredMsg} (expiry=${scannedRowInItem.expiryDate})`
  1108. : expiredMsg,
  1109. );
  1110. });
  1111. openWorkbenchLotLabelModalForLot(scannedRowInItem);
  1112. return;
  1113. }
  1114. if (scannedRowInItem && (isCompletedStatus(scannedRowInItem.status) || isCheckedStatus(scannedRowInItem.status))) {
  1115. setError(t("Scanned lot is already completed or checked"));
  1116. startTransition(() => {
  1117. setQrScanError(true);
  1118. setQrScanSuccess(false);
  1119. setQrScanErrorMsg(t("Scanned lot is already completed or checked"));
  1120. });
  1121. return;
  1122. }
  1123. let scannedState: ConfirmLotState | null = null;
  1124. if (scannedRowInItem) {
  1125. scannedState = toConfirmLotState(scannedRowInItem);
  1126. } else {
  1127. try {
  1128. const info = await fetchStockInLineInfo(scannedStockInLineId);
  1129. const scannedLotNo = String(info?.lotNo || "").trim();
  1130. if (!scannedLotNo) {
  1131. setError(t("Scanned lot is not found for current item"));
  1132. startTransition(() => {
  1133. setQrScanError(true);
  1134. setQrScanSuccess(false);
  1135. setQrScanErrorMsg(t("Scanned lot is not found for current item"));
  1136. });
  1137. resetScan();
  1138. return;
  1139. }
  1140. scannedState = toConfirmLotStateWithOverrides(expectedRow, {
  1141. lotNo: scannedLotNo,
  1142. stockInLineId: scannedStockInLineId,
  1143. });
  1144. } catch {
  1145. setError(t("Scanned lot is not found for current item"));
  1146. startTransition(() => {
  1147. setQrScanError(true);
  1148. setQrScanSuccess(false);
  1149. setQrScanErrorMsg(t("Scanned lot is not found for current item"));
  1150. });
  1151. resetScan();
  1152. return;
  1153. }
  1154. }
  1155. if (
  1156. Number(expectedRow.stockInLineId) > 0 &&
  1157. Number(scannedState.stockInLineId) > 0 &&
  1158. Number(expectedRow.stockInLineId) === Number(scannedState.stockInLineId)
  1159. ) {
  1160. await submitRow(expectedRow);
  1161. return;
  1162. }
  1163. await handleLotConfirmation(scannedState, toConfirmLotState(expectedRow));
  1164. },
  1165. [
  1166. handleLotConfirmation,
  1167. handleLotConfirmationByRescan,
  1168. lotConfirmationOpen,
  1169. pickExpectedRowForSubstitution,
  1170. lotRowIndexes,
  1171. resetScan,
  1172. submitRow,
  1173. t,
  1174. toConfirmLotState,
  1175. toConfirmLotStateWithOverrides,
  1176. ],
  1177. );
  1178. useEffect(() => {
  1179. if (!userId) return;
  1180. if (!isScanning) startScan();
  1181. }, [isScanning, startScan, userId]);
  1182. useEffect(() => {
  1183. if (!selectedPickOrderLineId) {
  1184. lastProcessedQrRef.current = "";
  1185. processedQrCodesRef.current.clear();
  1186. }
  1187. }, [selectedPickOrderLineId]);
  1188. useEffect(() => {
  1189. if (!qrValues.length || lotRows.length === 0) return;
  1190. const latest = String(qrValues[qrValues.length - 1] || "");
  1191. if (!latest) return;
  1192. if (lotConfirmationOpen) {
  1193. if (isConfirmingLot) return;
  1194. if (lotConfirmSkipNextScanRef.current) {
  1195. lotConfirmSkipNextScanRef.current = false;
  1196. lotConfirmLastQrRef.current = latest;
  1197. return;
  1198. }
  1199. const sameQr = latest === lotConfirmLastQrRef.current;
  1200. const justOpened =
  1201. lotConfirmOpenedAtRef.current > 0 &&
  1202. Date.now() - lotConfirmOpenedAtRef.current < 800;
  1203. if (sameQr && justOpened) return;
  1204. lotConfirmLastQrRef.current = latest;
  1205. void (async () => {
  1206. try {
  1207. const handled = await handleLotConfirmationByRescan(latest);
  1208. if (handled) resetScan();
  1209. } catch (e) {
  1210. console.error("Lot confirmation rescan failed:", e);
  1211. }
  1212. })();
  1213. return;
  1214. }
  1215. if (latest === lastProcessedQrRef.current || processedQrCodesRef.current.has(latest)) return;
  1216. lastProcessedQrRef.current = latest;
  1217. processedQrCodesRef.current.add(latest);
  1218. if (processedQrCodesRef.current.size > 100) {
  1219. const firstValue = processedQrCodesRef.current.values().next().value;
  1220. if (firstValue !== undefined) processedQrCodesRef.current.delete(firstValue);
  1221. }
  1222. const run = async () => {
  1223. try {
  1224. // JO shortcut: {2fitestx,y} -> simulate JSON qr
  1225. if (
  1226. (latest.startsWith("{2fitest") || latest.startsWith("{2fittest")) &&
  1227. latest.endsWith("}")
  1228. ) {
  1229. let content = "";
  1230. if (latest.startsWith("{2fittest")) content = latest.substring(9, latest.length - 1);
  1231. else content = latest.substring(8, latest.length - 1);
  1232. const parts = content.split(",");
  1233. if (parts.length === 2) {
  1234. const itemId = parseInt(parts[0].trim(), 10);
  1235. const stockInLineId = parseInt(parts[1].trim(), 10);
  1236. if (!Number.isNaN(itemId) && !Number.isNaN(stockInLineId)) {
  1237. await processOutsideQrCode(JSON.stringify({ itemId, stockInLineId }));
  1238. return;
  1239. }
  1240. }
  1241. }
  1242. await processOutsideQrCode(latest);
  1243. } finally {
  1244. resetScan();
  1245. }
  1246. };
  1247. void run();
  1248. }, [
  1249. handleLotConfirmationByRescan,
  1250. isConfirmingLot,
  1251. lotConfirmationOpen,
  1252. lotRows.length,
  1253. processOutsideQrCode,
  1254. qrValues,
  1255. resetScan,
  1256. ]);
  1257. return (
  1258. <TestQrCodeProvider
  1259. lotData={lotRows}
  1260. onScanLot={handleScanLotByLotNo}
  1261. filterActive={(lot) =>
  1262. lot.stockOutLineId > 0 &&
  1263. !isCompletedStatus(lot.status) &&
  1264. !isCheckedStatus(lot.status) &&
  1265. String(lot.lotNo || "").trim() !== ""
  1266. }
  1267. >
  1268. <Stack spacing={2}>
  1269. <Paper variant="outlined" sx={{ p: 2 }}>
  1270. <Stack spacing={1}>
  1271. <SearchBox criteria={searchCriteria} onSearch={handleSearch} onReset={handleReset} />
  1272. <Grid container rowGap={1}>
  1273. <Grid item xs={12}>
  1274. {pickOrderLoading ? (
  1275. <CircularProgress size={40} />
  1276. ) : (
  1277. <TableContainer component={Paper}>
  1278. <Table size="small">
  1279. <TableHead>
  1280. <TableRow>
  1281. <TableCell>{t("Selected")}</TableCell>
  1282. <TableCell>{t("Pick Order Code")}</TableCell>
  1283. <TableCell>{t("Item Code")}</TableCell>
  1284. <TableCell>{t("Item Name")}</TableCell>
  1285. <TableCell align="right">{t("Order Quantity")}</TableCell>
  1286. <TableCell align="right">{t("Current Stock")}</TableCell>
  1287. <TableCell align="right">{t("Picked Qty")}</TableCell>
  1288. <TableCell>{t("Stock Unit")}</TableCell>
  1289. <TableCell>{t("Target Date")}</TableCell>
  1290. <TableCell>{t("Status")}</TableCell>
  1291. </TableRow>
  1292. </TableHead>
  1293. <TableBody>
  1294. {paginatedTopRows.length === 0 ? (
  1295. <TableRow>
  1296. <TableCell colSpan={10}>
  1297. <Typography variant="body2" color="text.secondary">
  1298. {t("No data available")}
  1299. </Typography>
  1300. </TableCell>
  1301. </TableRow>
  1302. ) : (
  1303. paginatedTopRows.map((row) => (
  1304. <TableRow key={row.rowKey}>
  1305. <TableCell padding="checkbox">
  1306. <Checkbox
  1307. checked={selectedPickOrderLineId === row.pickOrderLineId}
  1308. onChange={(_, checked) => void handleLineSelect(row, checked)}
  1309. />
  1310. </TableCell>
  1311. <TableCell>{row.pickOrderCode || "-"}</TableCell>
  1312. <TableCell>{row.itemCode || "-"}</TableCell>
  1313. <TableCell>{row.itemName || "-"}</TableCell>
  1314. <TableCell align="right">{row.requiredQty.toLocaleString()}</TableCell>
  1315. <TableCell align="right">{row.currentStock.toLocaleString()}</TableCell>
  1316. <TableCell align="right">{row.pickedQty.toLocaleString()}</TableCell>
  1317. <TableCell>{row.stockUnit || "-"}</TableCell>
  1318. <TableCell>{safeDisplayTargetDate(row.targetDate)}</TableCell>
  1319. <TableCell>{t(row.status || "-")}</TableCell>
  1320. </TableRow>
  1321. ))
  1322. )}
  1323. </TableBody>
  1324. </Table>
  1325. </TableContainer>
  1326. )}
  1327. </Grid>
  1328. <Grid item xs={12}>
  1329. <TablePagination
  1330. component="div"
  1331. count={totalCountItems}
  1332. page={pagingController.pageNum - 1}
  1333. rowsPerPage={pagingController.pageSize}
  1334. onPageChange={(_e, newPage) => setPagingController((prev) => ({ ...prev, pageNum: newPage + 1 }))}
  1335. onRowsPerPageChange={(e) =>
  1336. setPagingController({
  1337. pageNum: 1,
  1338. pageSize: parseInt(e.target.value, 10),
  1339. })
  1340. }
  1341. rowsPerPageOptions={[10, 25, 50, 100]}
  1342. labelRowsPerPage={t("Rows per page")}
  1343. />
  1344. </Grid>
  1345. </Grid>
  1346. </Stack>
  1347. </Paper>
  1348. {loading ? (
  1349. <Stack direction="row" alignItems="center" spacing={1}>
  1350. <CircularProgress size={24} />
  1351. <Typography variant="body2">{t("Loading")}</Typography>
  1352. </Stack>
  1353. ) : null}
  1354. <ScanStatusAlert
  1355. error={qrScanError}
  1356. success={qrScanSuccess}
  1357. errorMessage={qrScanErrorMsg || t("QR code does not match any item in current orders.")}
  1358. successMessage={qrScanSuccessMsg || t("QR code verified.")}
  1359. />
  1360. {error ? <Alert severity="error">{error}</Alert> : null}
  1361. {message ? <Alert severity="success">{message}</Alert> : null}
  1362. <Paper variant="outlined">
  1363. <TableContainer>
  1364. <Table size="small">
  1365. <TableHead>
  1366. <TableRow>
  1367. <TableCell>{t("Index")}</TableCell>
  1368. <TableCell>{t("Item Code")}</TableCell>
  1369. <TableCell>{t("Route")}</TableCell>
  1370. <TableCell>{t("Lot No")}</TableCell>
  1371. <TableCell align="right">{t("Lot Required Pick Qty")}</TableCell>
  1372. <TableCell align="right">{t("Available Qty")}</TableCell>
  1373. <TableCell align="center">{t("Scan Result")}</TableCell>
  1374. <TableCell align="center">{t("Qty will submit")}</TableCell>
  1375. <TableCell align="center">{t("Submit Required Pick Qty")}</TableCell>
  1376. </TableRow>
  1377. </TableHead>
  1378. <TableBody>
  1379. {paginatedLotRows.map((r, idx) => (
  1380. <TableRow key={r.key}>
  1381. <TableCell>{idx === 0 ? lotPagingController.pageNum * lotPagingController.pageSize + 1 : ""}</TableCell>
  1382. <TableCell>
  1383. {idx === 0 ? (
  1384. <>
  1385. {r.itemCode || "-"} <br />
  1386. {r.itemName || "-"} <br />
  1387. {r.uomDesc || "-"}
  1388. </>
  1389. ) : (
  1390. ""
  1391. )}
  1392. </TableCell>
  1393. <TableCell>{r.location || "-"}</TableCell>
  1394. <TableCell>
  1395. <Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
  1396. <Typography variant="body2">{r.lotNo || "-"}</Typography>
  1397. {r.stockOutLineId > 0 ? (
  1398. <Button
  1399. variant="outlined"
  1400. size="small"
  1401. onClick={() => openWorkbenchLotLabelModalForLot(r)}
  1402. sx={{ flexShrink: 0, fontSize: "0.7rem", py: 0.25, minWidth: "auto", px: 1, whiteSpace: "nowrap" }}
  1403. >
  1404. {t("挑號 QR 碼")}
  1405. </Button>
  1406. ) : null}
  1407. </Stack>
  1408. </TableCell>
  1409. <TableCell align="right">{`${r.requiredQty.toLocaleString()}(${r.uomDesc || ""})`}</TableCell>
  1410. <TableCell align="right">
  1411. {`${Number(
  1412. r.itemTotalAvailableQty ?? r.availableQty ?? 0,
  1413. ).toLocaleString()}(${r.uomDesc || ""})`}
  1414. </TableCell>
  1415. <TableCell align="center">
  1416. <Checkbox
  1417. checked={isCompletedStatus(r.status) || isCheckedStatus(r.status)}
  1418. disabled
  1419. size="small"
  1420. sx={{
  1421. color: isCompletedStatus(r.status) ? "success.main" : isCheckedStatus(r.status) ? "warning.main" : "action.disabled",
  1422. "&.Mui-checked": {
  1423. color: isCompletedStatus(r.status) ? "success.main" : isCheckedStatus(r.status) ? "warning.main" : "action.disabled",
  1424. },
  1425. }}
  1426. />
  1427. </TableCell>
  1428. <TableCell align="center">
  1429. <Stack direction="row" spacing={1} justifyContent="center" alignItems="center">
  1430. <TextField
  1431. size="small"
  1432. type="number"
  1433. value={qtyBySolId[r.stockOutLineId] ?? Number(r.requiredQty)}
  1434. onKeyDown={(e) => {
  1435. const editable = qtyEditableBySolId[r.stockOutLineId] === true;
  1436. if (!editable) return;
  1437. if (e.key !== "{") return;
  1438. e.preventDefault();
  1439. setQtyEditableBySolId((prev) => ({
  1440. ...prev,
  1441. [r.stockOutLineId]: false,
  1442. }));
  1443. (e.currentTarget as HTMLInputElement).blur();
  1444. }}
  1445. onChange={(e) => {
  1446. const v = e.target.value;
  1447. setQtyBySolId((prev) => {
  1448. if (v === "" || v == null) {
  1449. if (!Object.prototype.hasOwnProperty.call(prev, r.stockOutLineId)) return prev;
  1450. const next = { ...prev };
  1451. delete next[r.stockOutLineId];
  1452. return next;
  1453. }
  1454. const n = Number(v);
  1455. if (!Number.isFinite(n) || n < 0) {
  1456. if (!Object.prototype.hasOwnProperty.call(prev, r.stockOutLineId)) return prev;
  1457. const next = { ...prev };
  1458. delete next[r.stockOutLineId];
  1459. return next;
  1460. }
  1461. return { ...prev, [r.stockOutLineId]: n };
  1462. });
  1463. }}
  1464. sx={{ width: 96 }}
  1465. disabled={!r.stockOutLineId || qtyEditableBySolId[r.stockOutLineId] !== true}
  1466. inputProps={{ min: 0, step: 1 }}
  1467. />
  1468. <Button
  1469. variant="outlined"
  1470. size="small"
  1471. onClick={() =>
  1472. setQtyEditableBySolId((prev) => ({
  1473. ...prev,
  1474. [r.stockOutLineId]: !(prev[r.stockOutLineId] === true),
  1475. }))
  1476. }
  1477. disabled={!r.stockOutLineId || isCompletedStatus(r.status)}
  1478. sx={{ fontSize: "0.7rem", py: 0.5, minHeight: "28px", minWidth: "60px", borderColor: "warning.main", color: "warning.main" }}
  1479. >
  1480. {t("Edit")}
  1481. </Button>
  1482. </Stack>
  1483. </TableCell>
  1484. <TableCell align="center">
  1485. <Stack direction="row" spacing={1} justifyContent="center">
  1486. <Button
  1487. size="small"
  1488. variant="outlined"
  1489. disabled={!r.stockOutLineId || isCompletedStatus(r.status) || isCheckedStatus(r.status)}
  1490. onClick={() => void handleJustComplete(r)}
  1491. >
  1492. {t("Just Complete")}
  1493. </Button>
  1494. </Stack>
  1495. </TableCell>
  1496. </TableRow>
  1497. ))}
  1498. {lotRows.length === 0 ? (
  1499. <TableRow>
  1500. <TableCell colSpan={9}>
  1501. <Typography variant="body2" color="text.secondary">
  1502. {t("No lot rows. Select a line in the table above.")}
  1503. </Typography>
  1504. </TableCell>
  1505. </TableRow>
  1506. ) : null}
  1507. </TableBody>
  1508. </Table>
  1509. </TableContainer>
  1510. <TablePagination
  1511. component="div"
  1512. count={lotRows.length}
  1513. page={lotPagingController.pageNum}
  1514. rowsPerPage={lotPagingController.pageSize}
  1515. onPageChange={(_e, newPage) => setLotPagingController((prev) => ({ ...prev, pageNum: newPage }))}
  1516. onRowsPerPageChange={(e) =>
  1517. setLotPagingController({
  1518. pageNum: 0,
  1519. pageSize: parseInt(e.target.value, 10),
  1520. })
  1521. }
  1522. rowsPerPageOptions={[10, 25, 50]}
  1523. labelRowsPerPage={t("Rows per page")}
  1524. />
  1525. </Paper>
  1526. <WorkbenchLotLabelPrintModal
  1527. open={workbenchLotLabelModalOpen}
  1528. onClose={() => {
  1529. setWorkbenchLotLabelModalOpen(false);
  1530. setWorkbenchLotLabelContextLot(null);
  1531. setWorkbenchLotLabelInitialPayload(null);
  1532. }}
  1533. initialPayload={workbenchLotLabelInitialPayload}
  1534. initialItemId={workbenchLotLabelContextLot?.itemId ?? null}
  1535. hideScanSection={workbenchLotLabelInitialPayload != null || workbenchLotLabelContextLot != null}
  1536. triggerLotAvailableQty={workbenchLotLabelContextLot?.availableQty ?? null}
  1537. triggerLotUom={workbenchLotLabelContextLot?.uomDesc ?? null}
  1538. submitQty={
  1539. workbenchLotLabelContextLot?.stockOutLineId
  1540. ? Number(resolveSingleSubmitQty(workbenchLotLabelContextLot))
  1541. : null
  1542. }
  1543. onSubmitQtyChange={(qty) => {
  1544. const solId = Number(workbenchLotLabelContextLot?.stockOutLineId);
  1545. if (!Number.isFinite(solId) || solId <= 0) return;
  1546. if (!Number.isFinite(qty) || qty < 0) {
  1547. setQtyBySolId((prev) => {
  1548. if (!Object.prototype.hasOwnProperty.call(prev, solId)) return prev;
  1549. const next = { ...prev };
  1550. delete next[solId];
  1551. return next;
  1552. });
  1553. return;
  1554. }
  1555. setQtyBySolId((prev) => ({ ...prev, [solId]: qty }));
  1556. }}
  1557. onWorkbenchScanPick={handleWorkbenchLotLabelScanPick}
  1558. />
  1559. <ManualLotConfirmationModal
  1560. open={manualLotConfirmationOpen}
  1561. onClose={() => setManualLotConfirmationOpen(false)}
  1562. onConfirm={(expectedLotNo, scannedLotNo) => {
  1563. const expected = resolveScanCandidate(expectedLotNo);
  1564. const scanned = resolveScanCandidate(scannedLotNo);
  1565. if (!expected || !scanned) {
  1566. setError(t("Lot not found in current line"));
  1567. return;
  1568. }
  1569. setManualLotConfirmationOpen(false);
  1570. void handleLotConfirmation(scanned, expected);
  1571. }}
  1572. expectedLot={
  1573. expectedLotData
  1574. ? {
  1575. lotNo: expectedLotData.lotNo,
  1576. itemCode: expectedLotData.itemCode,
  1577. itemName: expectedLotData.itemName,
  1578. }
  1579. : null
  1580. }
  1581. scannedLot={
  1582. scannedLotData
  1583. ? {
  1584. lotNo: scannedLotData.lotNo,
  1585. itemCode: scannedLotData.itemCode,
  1586. itemName: scannedLotData.itemName,
  1587. }
  1588. : null
  1589. }
  1590. isLoading={isConfirmingLot}
  1591. />
  1592. </Stack>
  1593. </TestQrCodeProvider>
  1594. );
  1595. };
  1596. export default WorkbenchPickExecution;