FPSMS-frontend
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

311 строки
9.4 KiB

  1. "use client";
  2. import { PurchaseOrderLine } from "@/app/api/po";
  3. import {
  4. Box,
  5. Button,
  6. Radio,
  7. TableCell,
  8. TableRow,
  9. TextField,
  10. alpha,
  11. } from "@mui/material";
  12. import { memo, useCallback, useEffect, useRef, useState } from "react";
  13. import { useTranslation } from "react-i18next";
  14. import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil";
  15. import { submitDialogWithWarning } from "../Swal/CustomAlerts";
  16. import { createStockInLine } from "@/app/api/stockIn/actions";
  17. import { previewPoBatchStockQty } from "./stockQtyRound";
  18. const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]);
  19. function totalPutAwayStockQtyForPol(row: PurchaseOrderLine): number {
  20. return row.stockInLine
  21. .filter((sil) => sil.purchaseOrderLineId === row.id)
  22. .reduce((acc, sil) => {
  23. const lineSum =
  24. sil.putAwayLines?.reduce(
  25. (s, p) => s + Number(p.stockQty ?? p.qty ?? 0),
  26. 0,
  27. ) ?? 0;
  28. return acc + lineSum;
  29. }, 0);
  30. }
  31. function polOrderStockQty(row: PurchaseOrderLine): number {
  32. return Number(row.stockUom?.stockQty ?? row.qty ?? 0);
  33. }
  34. function purchaseOrderLineHasIncompleteStockIn(row: PurchaseOrderLine): boolean {
  35. const orderStock = polOrderStockQty(row);
  36. const putAway = totalPutAwayStockQtyForPol(row);
  37. if (orderStock > 0 && putAway >= orderStock) {
  38. return false;
  39. }
  40. return row.stockInLine
  41. .filter((sil) => sil.purchaseOrderLineId === row.id)
  42. .some((sil) =>
  43. PURCHASE_STOCK_IN_ALERT_STATUSES.has((sil.status ?? "").toLowerCase().trim()),
  44. );
  45. }
  46. export type PoDetailRowDnValues = {
  47. dnNo?: string;
  48. receiptDate?: string;
  49. };
  50. type Props = {
  51. row: PurchaseOrderLine;
  52. selected: boolean;
  53. canSeeStockInReminders: boolean;
  54. showDnQty: boolean;
  55. savedLotNo: string;
  56. savedDnQty: string;
  57. onSelect: (row: PurchaseOrderLine) => void;
  58. onInputBlur: (rowId: number, lotNo: string, dnQty: string) => void;
  59. getDnValues: () => PoDetailRowDnValues;
  60. formatReceiptDate: (receiptDate?: string) => string | undefined;
  61. onSubmitted: (row: PurchaseOrderLine) => void;
  62. };
  63. /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */
  64. export const PoDetailRow = memo(function PoDetailRow({
  65. row,
  66. selected,
  67. canSeeStockInReminders,
  68. showDnQty,
  69. savedLotNo,
  70. savedDnQty,
  71. onSelect,
  72. onInputBlur,
  73. getDnValues,
  74. formatReceiptDate,
  75. onSubmitted,
  76. }: Props) {
  77. const { t } = useTranslation("purchaseOrder");
  78. const [lotNoInput, setLotNoInput] = useState(savedLotNo);
  79. const [dnQtyInput, setDnQtyInput] = useState(savedDnQty);
  80. const submitInFlightRef = useRef(false);
  81. const [isStarting, setIsStarting] = useState(false);
  82. useEffect(() => {
  83. setLotNoInput(savedLotNo);
  84. setDnQtyInput(savedDnQty.replace(/[^\d]/g, ""));
  85. }, [savedLotNo, savedDnQty]);
  86. const handleStart = useCallback(
  87. () => {
  88. if (submitInFlightRef.current || isStarting) return;
  89. const orderQty = Number(row?.qty) ?? 0;
  90. const acceptedQty = Number(dnQtyInput.trim());
  91. if (!Number.isInteger(acceptedQty) || acceptedQty <= 0) {
  92. alert("來貨數量必須為大於0的整數!");
  93. return;
  94. }
  95. const previewStockQty = previewPoBatchStockQty(
  96. orderQty,
  97. Number(row.stockUom?.stockQty ?? 0),
  98. acceptedQty,
  99. );
  100. const doSubmit = () => {
  101. if (submitInFlightRef.current) return;
  102. submitInFlightRef.current = true;
  103. setIsStarting(true);
  104. void (async () => {
  105. try {
  106. const dn = getDnValues();
  107. const postData = {
  108. dnNo: dn.dnNo,
  109. receiptDate: formatReceiptDate(dn.receiptDate),
  110. itemId: row.itemId,
  111. itemNo: row.itemNo,
  112. itemName: row.itemName,
  113. purchaseOrderLineId: row.id,
  114. acceptedQty: acceptedQty,
  115. productLotNo: lotNoInput || "",
  116. };
  117. const res = await createStockInLine(postData);
  118. if (res) {
  119. setLotNoInput("");
  120. setDnQtyInput("");
  121. onSubmitted(row);
  122. }
  123. console.log(res);
  124. } finally {
  125. setIsStarting(false);
  126. submitInFlightRef.current = false;
  127. }
  128. })();
  129. };
  130. const sils = row.stockInLine ?? [];
  131. const alreadyM18 = sils.reduce(
  132. (acc, sil) => acc + Number(sil.purchaseAcceptedQty ?? 0),
  133. 0,
  134. );
  135. const alreadyStock = sils.reduce(
  136. (acc, sil) => acc + Number(sil.acceptedQty ?? 0),
  137. 0,
  138. );
  139. const stockDemand = Number(row.stockUom?.stockQty ?? 0);
  140. const thisBatchStock = previewStockQty;
  141. const exceedByOrderUnit =
  142. orderQty > 0 && alreadyM18 + acceptedQty > orderQty * 1.1;
  143. const exceedByStockUnit =
  144. stockDemand > 0 && alreadyStock + thisBatchStock > stockDemand * 1.1;
  145. if (exceedByOrderUnit || exceedByStockUnit) {
  146. submitDialogWithWarning(doSubmit, t, {
  147. title: t("Confirm submit"),
  148. html: t("qtyExceedsOrderConfirm"),
  149. confirmButtonText: t("Submit"),
  150. });
  151. } else {
  152. doSubmit();
  153. }
  154. },
  155. [
  156. dnQtyInput,
  157. formatReceiptDate,
  158. getDnValues,
  159. isStarting,
  160. lotNoInput,
  161. onSubmitted,
  162. row,
  163. t,
  164. ],
  165. );
  166. const totalStockReceived = row.stockInLine
  167. .filter((sil) => sil.purchaseOrderLineId === row.id)
  168. .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0);
  169. const receivedTotalText = decimalFormatter.format(totalStockReceived);
  170. const highlightColor =
  171. Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit";
  172. const needsStockInAttention =
  173. canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row);
  174. return (
  175. <TableRow
  176. hover
  177. title={
  178. needsStockInAttention
  179. ? "採購入庫未完成:此採購明細尚有入庫單為「待處理」或「收貨中」,請於下方完成入庫。"
  180. : undefined
  181. }
  182. sx={{
  183. "& > *": { borderBottom: "unset" },
  184. color: "black",
  185. ...(needsStockInAttention
  186. ? (theme) => ({
  187. boxShadow: `inset 4px 0 0 ${theme.palette.error.main}`,
  188. backgroundColor: alpha(theme.palette.error.main, 0.07),
  189. })
  190. : {}),
  191. }}
  192. onClick={() => onSelect(row)}
  193. >
  194. <TableCell align="center" sx={{ width: "60px", position: "relative" }}>
  195. {needsStockInAttention && (
  196. <Box
  197. component="span"
  198. aria-hidden
  199. sx={{
  200. position: "absolute",
  201. top: 6,
  202. left: 8,
  203. width: 10,
  204. height: 10,
  205. borderRadius: "50%",
  206. bgcolor: "error.main",
  207. border: "2px solid",
  208. borderColor: "background.paper",
  209. boxShadow: (theme) => `0 0 0 1px ${alpha(theme.palette.error.main, 0.45)}`,
  210. zIndex: 1,
  211. }}
  212. />
  213. )}
  214. <Radio checked={selected} />
  215. </TableCell>
  216. <TableCell
  217. align="left"
  218. sx={{ width: 88, maxWidth: 88, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
  219. title={row.itemNo}
  220. >
  221. {row.itemNo}
  222. </TableCell>
  223. <TableCell
  224. align="left"
  225. sx={{ width: 100, maxWidth: 100, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
  226. title={row.itemName}
  227. >
  228. {row.itemName}
  229. </TableCell>
  230. <TableCell align="right">{integerFormatter.format(row.qty)}</TableCell>
  231. <TableCell align="right">{integerFormatter.format(row.processed)}</TableCell>
  232. <TableCell align="left">{row.uom?.udfudesc}</TableCell>
  233. <TableCell sx={{ color: highlightColor }} align="right">
  234. {decimalFormatter.format(totalStockReceived)}
  235. </TableCell>
  236. <TableCell sx={{ color: highlightColor }} align="left">
  237. {row.stockUom.stockUomDesc}
  238. </TableCell>
  239. <TableCell sx={{ color: highlightColor }} align="left">
  240. {t(`${row.status.toLowerCase()}`)}
  241. </TableCell>
  242. <TableCell align="center">
  243. <TextField
  244. id={`lotNo-${row.id}`}
  245. label="輸入貨品批號"
  246. type="text"
  247. variant="outlined"
  248. value={lotNoInput}
  249. onChange={(e) => setLotNoInput(e.target.value)}
  250. onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)}
  251. onClick={(e) => e.stopPropagation()}
  252. />
  253. </TableCell>
  254. {showDnQty ? (
  255. <TableCell align="center">
  256. <TextField
  257. id={`dnQty-${row.id}`}
  258. label="此批來貨數量"
  259. type="text"
  260. variant="outlined"
  261. value={dnQtyInput}
  262. onChange={(e) => setDnQtyInput(e.target.value.replace(/[^\d]/g, ""))}
  263. onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)}
  264. onClick={(e) => e.stopPropagation()}
  265. InputProps={{
  266. inputProps: {
  267. min: 1,
  268. step: 1,
  269. inputMode: "numeric",
  270. pattern: "[0-9]*",
  271. },
  272. }}
  273. />
  274. </TableCell>
  275. ) : null}
  276. <TableCell align="center">
  277. <Button
  278. variant="contained"
  279. disabled={isStarting}
  280. onMouseDown={(e) => {
  281. e.preventDefault();
  282. e.stopPropagation();
  283. }}
  284. onClick={(e) => {
  285. e.stopPropagation();
  286. handleStart();
  287. }}
  288. >
  289. {t("submit")}
  290. </Button>
  291. </TableCell>
  292. </TableRow>
  293. );
  294. });