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

437 строки
14 KiB

  1. "use client";
  2. import { PurchaseOrderLine } from "@/app/api/po";
  3. import {
  4. Box,
  5. Button,
  6. InputAdornment,
  7. Radio,
  8. Stack,
  9. TableCell,
  10. TableRow,
  11. TextField,
  12. Typography,
  13. alpha,
  14. } from "@mui/material";
  15. import { memo, useCallback, useEffect, useRef, useState } from "react";
  16. import { useTranslation } from "react-i18next";
  17. import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil";
  18. import { submitDialogWithWarning } from "../Swal/CustomAlerts";
  19. import { createStockInLine } from "@/app/api/stockIn/actions";
  20. import { previewPoBatchStockQty } from "./stockQtyRound";
  21. import {
  22. formatQtyWithPurchaseUom,
  23. formatQtyWithStockUom,
  24. poLineNeedsStockQtyConversion,
  25. purchaseUomShortDesc,
  26. } from "./poPurchaseUom";
  27. const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]);
  28. function totalPutAwayStockQtyForPol(row: PurchaseOrderLine): number {
  29. return row.stockInLine
  30. .filter((sil) => sil.purchaseOrderLineId === row.id)
  31. .reduce((acc, sil) => {
  32. const lineSum =
  33. sil.putAwayLines?.reduce(
  34. (s, p) => s + Number(p.stockQty ?? p.qty ?? 0),
  35. 0,
  36. ) ?? 0;
  37. return acc + lineSum;
  38. }, 0);
  39. }
  40. function polOrderStockQty(row: PurchaseOrderLine): number {
  41. return Number(row.stockUom?.stockQty ?? row.qty ?? 0);
  42. }
  43. function purchaseOrderLineHasIncompleteStockIn(row: PurchaseOrderLine): boolean {
  44. const orderStock = polOrderStockQty(row);
  45. const putAway = totalPutAwayStockQtyForPol(row);
  46. if (orderStock > 0 && putAway >= orderStock) {
  47. return false;
  48. }
  49. return row.stockInLine
  50. .filter((sil) => sil.purchaseOrderLineId === row.id)
  51. .some((sil) =>
  52. PURCHASE_STOCK_IN_ALERT_STATUSES.has((sil.status ?? "").toLowerCase().trim()),
  53. );
  54. }
  55. export type PoDetailRowDnValues = {
  56. dnNo?: string;
  57. receiptDate?: string;
  58. };
  59. type Props = {
  60. row: PurchaseOrderLine;
  61. selected: boolean;
  62. canSeeStockInReminders: boolean;
  63. showDnQty: boolean;
  64. savedLotNo: string;
  65. savedDnQty: string;
  66. onSelect: (row: PurchaseOrderLine) => void;
  67. onInputBlur: (rowId: number, lotNo: string, dnQty: string) => void;
  68. getDnValues: () => PoDetailRowDnValues;
  69. formatReceiptDate: (receiptDate?: string) => string | undefined;
  70. onSubmitted: (row: PurchaseOrderLine) => void;
  71. };
  72. /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */
  73. export const PoDetailRow = memo(function PoDetailRow({
  74. row,
  75. selected,
  76. canSeeStockInReminders,
  77. showDnQty,
  78. savedLotNo,
  79. savedDnQty,
  80. onSelect,
  81. onInputBlur,
  82. getDnValues,
  83. formatReceiptDate,
  84. onSubmitted,
  85. }: Props) {
  86. const { t } = useTranslation("purchaseOrder");
  87. const [lotNoInput, setLotNoInput] = useState(savedLotNo);
  88. const [dnQtyInput, setDnQtyInput] = useState(savedDnQty);
  89. const submitInFlightRef = useRef(false);
  90. const [isStarting, setIsStarting] = useState(false);
  91. useEffect(() => {
  92. setLotNoInput(savedLotNo);
  93. setDnQtyInput(savedDnQty.replace(/[^\d]/g, ""));
  94. }, [savedLotNo, savedDnQty]);
  95. const handleStart = useCallback(
  96. () => {
  97. if (submitInFlightRef.current || isStarting) return;
  98. const orderQty = Number(row?.qty) ?? 0;
  99. const acceptedQty = Number(dnQtyInput.trim());
  100. if (!Number.isInteger(acceptedQty) || acceptedQty <= 0) {
  101. alert("來貨數量必須為大於0的整數!");
  102. return;
  103. }
  104. const previewStockQty = previewPoBatchStockQty(
  105. orderQty,
  106. Number(row.stockUom?.stockQty ?? 0),
  107. acceptedQty,
  108. row.stockUom,
  109. );
  110. const doSubmit = () => {
  111. if (submitInFlightRef.current) return;
  112. submitInFlightRef.current = true;
  113. setIsStarting(true);
  114. void (async () => {
  115. try {
  116. const dn = getDnValues();
  117. const postData = {
  118. dnNo: dn.dnNo,
  119. receiptDate: formatReceiptDate(dn.receiptDate),
  120. itemId: row.itemId,
  121. itemNo: row.itemNo,
  122. itemName: row.itemName,
  123. purchaseOrderLineId: row.id,
  124. acceptedQty: acceptedQty,
  125. productLotNo: lotNoInput || "",
  126. };
  127. const res = await createStockInLine(postData);
  128. if (res) {
  129. setLotNoInput("");
  130. setDnQtyInput("");
  131. onSubmitted(row);
  132. }
  133. console.log(res);
  134. } finally {
  135. setIsStarting(false);
  136. submitInFlightRef.current = false;
  137. }
  138. })();
  139. };
  140. const sils = row.stockInLine ?? [];
  141. const alreadyM18 = sils.reduce(
  142. (acc, sil) => acc + Number(sil.purchaseAcceptedQty ?? 0),
  143. 0,
  144. );
  145. const alreadyStock = sils.reduce(
  146. (acc, sil) => acc + Number(sil.acceptedQty ?? 0),
  147. 0,
  148. );
  149. const stockDemand = Number(row.stockUom?.stockQty ?? 0);
  150. const thisBatchStock = previewStockQty;
  151. const exceedByOrderUnit =
  152. orderQty > 0 && alreadyM18 + acceptedQty > orderQty * 1.1;
  153. const exceedByStockUnit =
  154. stockDemand > 0 && alreadyStock + thisBatchStock > stockDemand * 1.1;
  155. if (exceedByOrderUnit || exceedByStockUnit) {
  156. submitDialogWithWarning(doSubmit, t, {
  157. title: t("Confirm submit"),
  158. html: t("qtyExceedsOrderConfirm"),
  159. confirmButtonText: t("Submit"),
  160. });
  161. } else {
  162. doSubmit();
  163. }
  164. },
  165. [
  166. dnQtyInput,
  167. formatReceiptDate,
  168. getDnValues,
  169. isStarting,
  170. lotNoInput,
  171. onSubmitted,
  172. row,
  173. t,
  174. ],
  175. );
  176. const totalStockReceived = row.stockInLine
  177. .filter((sil) => sil.purchaseOrderLineId === row.id)
  178. .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0);
  179. const receivedTotalText = decimalFormatter.format(totalStockReceived);
  180. const highlightColor =
  181. Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit";
  182. const needsStockInAttention =
  183. canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row);
  184. const uomShort = purchaseUomShortDesc(row.uom);
  185. const orderQtyWithUnit = formatQtyWithPurchaseUom(row.qty, row.uom);
  186. const enteredBatchQty = Number(dnQtyInput.trim());
  187. const hasEnteredBatchQty = Number.isInteger(enteredBatchQty) && enteredBatchQty > 0;
  188. const showStockConversion =
  189. hasEnteredBatchQty &&
  190. poLineNeedsStockQtyConversion(
  191. Number(row.qty ?? 0),
  192. Number(row.stockUom?.stockQty ?? 0),
  193. row.uom,
  194. row.stockUom,
  195. );
  196. const convertedStockQty = previewPoBatchStockQty(
  197. Number(row.qty ?? 0),
  198. Number(row.stockUom?.stockQty ?? 0),
  199. enteredBatchQty,
  200. row.stockUom,
  201. );
  202. const convertedStockQtyWithUnit = formatQtyWithStockUom(
  203. convertedStockQty,
  204. row.stockUom,
  205. );
  206. return (
  207. <TableRow
  208. hover
  209. title={
  210. needsStockInAttention
  211. ? "採購入庫未完成:此採購明細尚有入庫單為「待處理」或「收貨中」,請於下方完成入庫。"
  212. : undefined
  213. }
  214. sx={{
  215. "& > *": { borderBottom: "unset" },
  216. color: "black",
  217. ...(needsStockInAttention
  218. ? (theme) => ({
  219. boxShadow: `inset 4px 0 0 ${theme.palette.error.main}`,
  220. backgroundColor: alpha(theme.palette.error.main, 0.07),
  221. })
  222. : {}),
  223. }}
  224. onClick={() => onSelect(row)}
  225. >
  226. <TableCell align="center" sx={{ width: "60px", position: "relative" }}>
  227. {needsStockInAttention && (
  228. <Box
  229. component="span"
  230. aria-hidden
  231. sx={{
  232. position: "absolute",
  233. top: 6,
  234. left: 8,
  235. width: 10,
  236. height: 10,
  237. borderRadius: "50%",
  238. bgcolor: "error.main",
  239. border: "2px solid",
  240. borderColor: "background.paper",
  241. boxShadow: (theme) => `0 0 0 1px ${alpha(theme.palette.error.main, 0.45)}`,
  242. zIndex: 1,
  243. }}
  244. />
  245. )}
  246. <Radio checked={selected} />
  247. </TableCell>
  248. <TableCell
  249. align="left"
  250. sx={{ width: 88, maxWidth: 88, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
  251. title={row.itemNo}
  252. >
  253. {row.itemNo}
  254. </TableCell>
  255. <TableCell
  256. align="left"
  257. sx={{ width: 100, maxWidth: 100, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
  258. title={row.itemName}
  259. >
  260. {row.itemName}
  261. </TableCell>
  262. <TableCell
  263. align="right"
  264. sx={{
  265. fontWeight: 800,
  266. fontVariantNumeric: "tabular-nums",
  267. whiteSpace: "nowrap",
  268. fontSize: "1.05rem",
  269. color: "text.primary",
  270. }}
  271. title={row.uom?.udfudesc || uomShort || undefined}
  272. >
  273. {orderQtyWithUnit}
  274. </TableCell>
  275. <TableCell align="right">{integerFormatter.format(row.processed)}</TableCell>
  276. <TableCell align="left">{row.uom?.udfudesc}</TableCell>
  277. <TableCell sx={{ color: highlightColor }} align="right">
  278. {decimalFormatter.format(totalStockReceived)}
  279. </TableCell>
  280. <TableCell sx={{ color: highlightColor }} align="left">
  281. {row.stockUom.stockUomDesc}
  282. </TableCell>
  283. <TableCell sx={{ color: highlightColor }} align="left">
  284. {t(`${row.status.toLowerCase()}`)}
  285. </TableCell>
  286. <TableCell align="center">
  287. <TextField
  288. id={`lotNo-${row.id}`}
  289. label="輸入貨品批號"
  290. type="text"
  291. variant="outlined"
  292. value={lotNoInput}
  293. onChange={(e) => setLotNoInput(e.target.value)}
  294. onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)}
  295. onClick={(e) => e.stopPropagation()}
  296. />
  297. </TableCell>
  298. {showDnQty ? (
  299. <TableCell align="center" sx={{ py: 1, overflow: "visible" }}>
  300. <Stack
  301. direction="row"
  302. spacing={1.25}
  303. alignItems="center"
  304. justifyContent="center"
  305. flexWrap="nowrap"
  306. sx={{ width: "max-content" }}
  307. onClick={(e) => e.stopPropagation()}
  308. >
  309. <TextField
  310. id={`dnQty-${row.id}`}
  311. label={t("dnQty")}
  312. type="text"
  313. variant="outlined"
  314. value={dnQtyInput}
  315. onChange={(e) => setDnQtyInput(e.target.value.replace(/[^\d]/g, ""))}
  316. onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)}
  317. onClick={(e) => e.stopPropagation()}
  318. sx={{
  319. minWidth: 168,
  320. width: 168,
  321. flexShrink: 0,
  322. "& .MuiInputLabel-root": {
  323. maxWidth: "calc(100% - 44px)",
  324. },
  325. "& .MuiInputBase-input": {
  326. pr: 0.5,
  327. },
  328. }}
  329. InputLabelProps={{ shrink: true }}
  330. InputProps={{
  331. notched: true,
  332. endAdornment: uomShort ? (
  333. <InputAdornment position="end" sx={{ ml: 0.5 }}>
  334. <Typography
  335. component="span"
  336. sx={{
  337. fontWeight: 800,
  338. fontSize: "1.05rem",
  339. color: "primary.main",
  340. lineHeight: 1,
  341. }}
  342. >
  343. {uomShort}
  344. </Typography>
  345. </InputAdornment>
  346. ) : undefined,
  347. inputProps: {
  348. min: 1,
  349. step: 1,
  350. inputMode: "numeric",
  351. pattern: "[0-9]*",
  352. "aria-label": `${t("dnQty")} ${uomShort}`.trim(),
  353. },
  354. }}
  355. />
  356. {showStockConversion && Number.isFinite(convertedStockQty) && convertedStockQty > 0 ? (
  357. <Box
  358. sx={{
  359. flexShrink: 0,
  360. width: "max-content",
  361. boxSizing: "border-box",
  362. px: 1.25,
  363. py: 0.75,
  364. borderRadius: 1,
  365. bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1),
  366. border: 1,
  367. borderColor: "primary.main",
  368. textAlign: "center",
  369. }}
  370. title={row.stockUom?.stockUomDesc || convertedStockQtyWithUnit}
  371. >
  372. <Typography
  373. component="div"
  374. sx={{
  375. fontSize: "0.7rem",
  376. lineHeight: 1.2,
  377. color: "text.secondary",
  378. fontWeight: 600,
  379. }}
  380. >
  381. {t("stockQtyRef")}
  382. </Typography>
  383. <Typography
  384. component="div"
  385. sx={{
  386. fontWeight: 800,
  387. fontSize: "1rem",
  388. lineHeight: 1.3,
  389. color: "primary.main",
  390. fontVariantNumeric: "tabular-nums",
  391. whiteSpace: "nowrap",
  392. px: 0.25,
  393. }}
  394. >
  395. {convertedStockQtyWithUnit}
  396. </Typography>
  397. </Box>
  398. ) : null}
  399. </Stack>
  400. </TableCell>
  401. ) : null}
  402. <TableCell align="center">
  403. <Button
  404. variant="contained"
  405. disabled={isStarting}
  406. onMouseDown={(e) => {
  407. e.preventDefault();
  408. e.stopPropagation();
  409. }}
  410. onClick={(e) => {
  411. e.stopPropagation();
  412. handleStart();
  413. }}
  414. >
  415. {t("submit")}
  416. </Button>
  417. </TableCell>
  418. </TableRow>
  419. );
  420. });