| @@ -38,6 +38,10 @@ export interface StockInLineEntry { | |||
| receiptDate?: string; | |||
| dnDate?: string; | |||
| dnNo?: string; | |||
| stockQtyRoundMode?: "CEILING" | "FLOOR" | "HALF_UP" | "CUSTOM"; | |||
| stockQtyCustomQty?: number; | |||
| stockQtyRoundSource?: "CREATE" | "QC"; | |||
| stockQtyCustomReason?: string; | |||
| } | |||
| export interface QcResult{ | |||
| @@ -66,6 +70,7 @@ export interface StockInInput { | |||
| productionDate?: string; | |||
| expiryDate: string; | |||
| uom: Uom; | |||
| stockQtyRoundMode?: "CEILING" | "FLOOR"; | |||
| } | |||
| export interface QCInput { | |||
| status: string; | |||
| @@ -50,6 +50,7 @@ export interface StockInInput { | |||
| productionDate?: string; | |||
| expiryDate: string; | |||
| uom?: Uom; | |||
| stockQtyRoundMode?: "CEILING" | "FLOOR"; | |||
| } | |||
| export interface PoResult { | |||
| @@ -9,9 +9,7 @@ import { | |||
| Box, | |||
| Button, | |||
| ButtonProps, | |||
| Collapse, | |||
| Grid, | |||
| IconButton, | |||
| Paper, | |||
| Stack, | |||
| Tab, | |||
| @@ -29,16 +27,14 @@ import { | |||
| FormControlLabel, | |||
| Card, | |||
| CardContent, | |||
| Radio, | |||
| alpha, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogTitle, | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | |||
| import PrinterSelect from "@/components/common/PrinterSelect"; | |||
| import { PoDetailRow } from "./PoDetailRow"; | |||
| // import InputDataGrid, { TableRow } from "../InputDataGrid/InputDataGrid"; | |||
| import { | |||
| GridColDef, | |||
| @@ -52,9 +48,6 @@ import { | |||
| fetchPoSummariesClient, | |||
| startPo, | |||
| } from "@/app/api/po/actions"; | |||
| import { | |||
| createStockInLine | |||
| } from "@/app/api/stockIn/actions"; | |||
| import { | |||
| useCallback, | |||
| useContext, | |||
| @@ -63,20 +56,16 @@ import { | |||
| useRef, | |||
| useState, | |||
| } from "react"; | |||
| import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; | |||
| import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; | |||
| import PoInputGrid from "./PoInputGrid"; | |||
| // import { QcItemWithChecks } from "@/app/api/qc"; | |||
| import { useRouter, useSearchParams, usePathname } from "next/navigation"; | |||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import { calculateWeight, dateStringToDayjs, dayjsToDateString, OUTPUT_DATE_FORMAT, outputDateStringToInputDateString, returnWeightUnit } from "@/app/utils/formatUtil"; | |||
| import { dateStringToDayjs, dayjsToDateString, OUTPUT_DATE_FORMAT, outputDateStringToInputDateString, decimalFormatter, arrayToDateString } from "@/app/utils/formatUtil"; | |||
| import { CameraContext } from "../Cameras/CameraProvider"; | |||
| import QrModal from "./QrModal"; | |||
| import { PlayArrow } from "@mui/icons-material"; | |||
| import DoneIcon from "@mui/icons-material/Done"; | |||
| import { downloadFile, getCustomWidth } from "@/app/utils/commonUtil"; | |||
| import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; | |||
| import { arrayToDateString } from "@/app/utils/formatUtil"; | |||
| import { List, ListItem, ListItemButton, ListItemText, Divider } from "@mui/material"; | |||
| import { Controller, FormProvider, useForm } from "react-hook-form"; | |||
| import dayjs, { Dayjs } from "dayjs"; | |||
| @@ -100,41 +89,6 @@ type Props = { | |||
| printerCombo: PrinterCombo[]; | |||
| }; | |||
| /** PO stock-in lines still in pre-complete workflow (align with nav alert: pending / receiving). */ | |||
| const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]); | |||
| /** Sum of put-away in stock units (matches StockInForm「已上架數量」stockQty). */ | |||
| function totalPutAwayStockQtyForPol(row: PurchaseOrderLine): number { | |||
| return row.stockInLine | |||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | |||
| .reduce((acc, sil) => { | |||
| const lineSum = | |||
| sil.putAwayLines?.reduce( | |||
| (s, p) => s + Number(p.stockQty ?? p.qty ?? 0), | |||
| 0, | |||
| ) ?? 0; | |||
| return acc + lineSum; | |||
| }, 0); | |||
| } | |||
| /** POL order demand in stock units (same basis as PoDetail processed / backend PO detail). */ | |||
| function polOrderStockQty(row: PurchaseOrderLine): number { | |||
| return Number(row.stockUom?.stockQty ?? row.qty ?? 0); | |||
| } | |||
| function purchaseOrderLineHasIncompleteStockIn(row: PurchaseOrderLine): boolean { | |||
| const orderStock = polOrderStockQty(row); | |||
| const putAway = totalPutAwayStockQtyForPol(row); | |||
| if (orderStock > 0 && putAway >= orderStock) { | |||
| return false; | |||
| } | |||
| return row.stockInLine | |||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | |||
| .some((sil) => | |||
| PURCHASE_STOCK_IN_ALERT_STATUSES.has((sil.status ?? "").toLowerCase().trim()), | |||
| ); | |||
| } | |||
| type EntryError = | |||
| | { | |||
| [field in keyof StockInLine]?: string; | |||
| @@ -567,6 +521,48 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| setPurchaseOrder(newPo); | |||
| }, [purchaseOrder.id]); | |||
| const getDnValues = useCallback(() => dnFormProps.getValues(), [dnFormProps]); | |||
| const formatReceiptDate = useCallback((receiptDate?: string) => { | |||
| return outputDateStringToInputDateString(receiptDate ?? ""); | |||
| }, []); | |||
| const handleSelectPol = useCallback((row: PurchaseOrderLine) => { | |||
| selectedPolIdRef.current = row.id; | |||
| setSelectedRow(row); | |||
| setStockInLine(row.stockInLine ?? []); | |||
| setProcessedQty(row.processed); | |||
| patchPoEditQuery((params) => { | |||
| params.set("polId", String(row.id)); | |||
| params.delete("stockInLineId"); | |||
| }); | |||
| }, [patchPoEditQuery]); | |||
| const handleRowInputBlur = useCallback((rowId: number, lotNo: string, dnQty: string) => { | |||
| setPolInputList((prev) => { | |||
| const current = prev[rowId] ?? { lotNo: "", dnQty: "" }; | |||
| if (current.lotNo === lotNo && current.dnQty === dnQty) return prev; | |||
| return { | |||
| ...prev, | |||
| [rowId]: { lotNo, dnQty }, | |||
| }; | |||
| }); | |||
| }, []); | |||
| const handleRowSubmitted = useCallback((row: PurchaseOrderLine) => { | |||
| setPolInputList((prev) => ({ | |||
| ...prev, | |||
| [row.id]: { lotNo: "", dnQty: "" }, | |||
| })); | |||
| selectedPolIdRef.current = row.id; | |||
| setSelectedRow(row); | |||
| patchPoEditQuery((params) => { | |||
| params.set("polId", String(row.id)); | |||
| params.delete("stockInLineId"); | |||
| }); | |||
| fetchPoDetail(selectedPoId.toString(), true, row.id); | |||
| }, [fetchPoDetail, patchPoEditQuery, selectedPoId]); | |||
| const handleMailTemplateForStockInLine = useCallback(async (stockInLineId: number) => { | |||
| const response = await getMailTemplatePdfForStockInLine(stockInLineId) | |||
| if (response) { | |||
| @@ -582,298 +578,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| // setStockInLine([]) | |||
| // }, []); | |||
| function Row(props: { row: PurchaseOrderLine }) { | |||
| const { row } = props; | |||
| // const [firstReceiveQty, setFirstReceiveQty] = useState<number>() | |||
| // const [secondReceiveQty, setSecondReceiveQty] = useState<number>() | |||
| // const [open, setOpen] = useState(false); | |||
| const [processedQty, setProcessedQty] = useState(row.processed); | |||
| const [currStatus, setCurrStatus] = useState(row.status); | |||
| const [lotNoInput, setLotNoInput] = useState(polInputList[row.id]?.lotNo ?? ""); | |||
| const [dnQtyInput, setDnQtyInput] = useState(polInputList[row.id]?.dnQty ?? ""); | |||
| // const [stockInLine, setStockInLine] = useState(row.stockInLine); | |||
| const totalWeight = useMemo( | |||
| () => calculateWeight(row.qty, row.uom), | |||
| [row.qty, row.uom], | |||
| ); | |||
| const weightUnit = useMemo( | |||
| () => returnWeightUnit(row.uom), | |||
| [row.uom], | |||
| ); | |||
| useEffect(() => { | |||
| // `processedQty` comes from putAwayLines (stock unit). | |||
| // After the fix, `row.qty` is qtyM18 (M18 unit), so compare using stockUom demand. | |||
| const targetStockQty = Number(row.stockUom?.stockQty ?? row.qty ?? 0); | |||
| if (targetStockQty > 0 && processedQty >= targetStockQty) { | |||
| setCurrStatus("completed".toUpperCase()); | |||
| } else if (processedQty > 0) { | |||
| setCurrStatus("receiving".toUpperCase()); | |||
| } else { | |||
| setCurrStatus("pending".toUpperCase()); | |||
| } | |||
| }, [processedQty, row.qty, row.stockUom?.stockQty]); | |||
| useEffect(() => { | |||
| setLotNoInput(polInputList[row.id]?.lotNo ?? ""); | |||
| setDnQtyInput(polInputList[row.id]?.dnQty ?? ""); | |||
| }, [polInputList, row.id]); | |||
| const changeStockInLines = useCallback( | |||
| (id: number) => { | |||
| const target = rows.find((r) => r.id === id); | |||
| if (!target) return; | |||
| selectedPolIdRef.current = id; | |||
| setSelectedRow(target); | |||
| setStockInLine(target.stockInLine ?? []); | |||
| setProcessedQty(target.processed); | |||
| // history.replaceState: keep URL in sync without scrolling to top | |||
| patchPoEditQuery((params) => { | |||
| params.set("polId", String(id)); | |||
| params.delete("stockInLineId"); | |||
| }); | |||
| }, | |||
| [rows, patchPoEditQuery], | |||
| ); | |||
| const handleStart = useCallback( | |||
| () => { | |||
| const orderQty = Number(row?.qty) ?? 0; | |||
| const acceptedQty = Number(dnQtyInput.trim()); | |||
| if (isNaN(acceptedQty) || acceptedQty <= 0) { | |||
| alert("來貨數量必須大於0!"); | |||
| return; | |||
| } | |||
| const doSubmit = () => { | |||
| setTimeout(async () => { | |||
| const currentDnNo = dnFormProps.watch("dnNo"); | |||
| const postData = { | |||
| dnNo: dnFormProps.watch("dnNo"), | |||
| receiptDate: outputDateStringToInputDateString(dnFormProps.watch("receiptDate")), | |||
| itemId: row.itemId, | |||
| itemNo: row.itemNo, | |||
| itemName: row.itemName, | |||
| purchaseOrderLineId: row.id, | |||
| acceptedQty: acceptedQty, | |||
| productLotNo: lotNoInput || "", | |||
| }; | |||
| const res = await createStockInLine(postData); | |||
| if (res) { | |||
| setLotNoInput(""); | |||
| setDnQtyInput(""); | |||
| setPolInputList((prev) => ({ | |||
| ...prev, | |||
| [row.id]: { lotNo: "", dnQty: "" }, | |||
| })); | |||
| selectedPolIdRef.current = row.id; | |||
| setSelectedRow(row); | |||
| patchPoEditQuery((params) => { | |||
| params.set("polId", String(row.id)); | |||
| params.delete("stockInLineId"); | |||
| }); | |||
| fetchPoDetail(selectedPoId.toString(), true, row.id); | |||
| } | |||
| console.log(res); | |||
| }, 200); | |||
| }; | |||
| const exceedOrderBy10Percent = orderQty > 0 && acceptedQty > orderQty * 1.1; | |||
| if (exceedOrderBy10Percent) { | |||
| submitDialogWithWarning(doSubmit, t, { | |||
| title: t("Confirm submit"), | |||
| html: t("This batch quantity exceeds order quantity. Do you still want to submit?"), | |||
| confirmButtonText: t("Submit"), | |||
| }); | |||
| } else { | |||
| doSubmit(); | |||
| } | |||
| }, | |||
| [dnQtyInput, row, dnFormProps, selectedPoId, fetchPoDetail, t, lotNoInput, patchPoEditQuery], | |||
| ); | |||
| const syncRowInputToParent = useCallback((lotNo: string, dnQty: string) => { | |||
| setPolInputList((prev) => { | |||
| const current = prev[row.id] ?? { lotNo: "", dnQty: "" }; | |||
| if (current.lotNo === lotNo && current.dnQty === dnQty) return prev; | |||
| return { | |||
| ...prev, | |||
| [row.id]: { lotNo, dnQty }, | |||
| }; | |||
| }); | |||
| }, [row.id]); | |||
| // const [focusField, setFocusField] = useState<HTMLInputElement>(); | |||
| // 本批收貨數量(訂單單位): 使用者在該行輸入的 dnQty | |||
| const batchPurchaseQty = Number(dnQtyInput.trim()) || 0; | |||
| // 已來貨總數(庫存單位): 同一 POL 底下所有 stock_in_line.acceptedQty 的合計 | |||
| const totalStockReceived = row.stockInLine | |||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | |||
| .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0); | |||
| const receivedTotalText = decimalFormatter.format(totalStockReceived); | |||
| const highlightColor = | |||
| Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; | |||
| const needsStockInAttention = | |||
| canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row); | |||
| return ( | |||
| <> | |||
| <TableRow | |||
| hover | |||
| title={ | |||
| needsStockInAttention | |||
| ? "採購入庫未完成:此採購明細尚有入庫單為「待處理」或「收貨中」,請於下方完成入庫。" | |||
| : undefined | |||
| } | |||
| sx={{ | |||
| "& > *": { borderBottom: "unset" }, | |||
| color: "black", | |||
| ...(needsStockInAttention | |||
| ? (theme) => ({ | |||
| boxShadow: `inset 4px 0 0 ${theme.palette.error.main}`, | |||
| backgroundColor: alpha(theme.palette.error.main, 0.07), | |||
| }) | |||
| : {}), | |||
| }} | |||
| onClick={() => changeStockInLines(row.id)} | |||
| > | |||
| {/* <TableCell> | |||
| <IconButton | |||
| disabled={purchaseOrder.status.toLowerCase() === "pending"} | |||
| aria-label="expand row" | |||
| size="small" | |||
| onClick={() => setOpen(!open)} | |||
| > | |||
| {open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />} | |||
| </IconButton> | |||
| </TableCell> */} | |||
| <TableCell align="center" sx={{ width: "60px", position: "relative" }}> | |||
| {needsStockInAttention && ( | |||
| <Box | |||
| component="span" | |||
| aria-hidden | |||
| sx={{ | |||
| position: "absolute", | |||
| top: 6, | |||
| left: 8, | |||
| width: 10, | |||
| height: 10, | |||
| borderRadius: "50%", | |||
| bgcolor: "error.main", | |||
| border: "2px solid", | |||
| borderColor: "background.paper", | |||
| boxShadow: (theme) => `0 0 0 1px ${alpha(theme.palette.error.main, 0.45)}`, | |||
| zIndex: 1, | |||
| }} | |||
| /> | |||
| )} | |||
| <Radio | |||
| checked={selectedRow?.id === row.id} | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="left" sx={{ width: 88, maxWidth: 88, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={row.itemNo}> | |||
| {row.itemNo} | |||
| </TableCell> | |||
| <TableCell align="left" sx={{ width: 100, maxWidth: 100, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={row.itemName}> | |||
| {row.itemName} | |||
| </TableCell> | |||
| <TableCell align="right">{integerFormatter.format(row.qty)}</TableCell> | |||
| <TableCell align="right">{integerFormatter.format(row.processed)}</TableCell> | |||
| <TableCell align="left">{row.uom?.udfudesc}</TableCell> | |||
| {/* <TableCell align="right">{decimalFormatter.format(row.stockUom.stockQty)}</TableCell> */} | |||
| {/* <TableCell sx={{ color: highlightColor}} align="right">{receivedTotal}</TableCell> */} | |||
| <TableCell sx={{ color: highlightColor }} align="right"> | |||
| {decimalFormatter.format(totalStockReceived)} | |||
| </TableCell> | |||
| <TableCell sx={{ color: highlightColor}} align="left">{row.stockUom.stockUomDesc}</TableCell> | |||
| {/* <TableCell align="right"> | |||
| {decimalFormatter.format(totalWeight)} {weightUnit} | |||
| </TableCell> */} | |||
| {/* <TableCell align="left">{weightUnit}</TableCell> */} | |||
| {/* <TableCell align="right">{decimalFormatter.format(row.price)}</TableCell> */} | |||
| {/* <TableCell align="left">{row.expiryDate}</TableCell> */} | |||
| <TableCell sx={{ color: highlightColor}} align="left">{t(`${row.status.toLowerCase()}`)}</TableCell> | |||
| {/* <TableCell sx={{ color: highlightColor}} align="left">{t(`${currStatus.toLowerCase()}`)}</TableCell> */} | |||
| {/* <TableCell align="right">{integerFormatter.format(row.receivedQty)}</TableCell> */} | |||
| <TableCell align="center"> | |||
| <TextField | |||
| id="lotNo" | |||
| label="輸入貨品批號" | |||
| type="text" // Use type="text" to allow validation in the change handler | |||
| variant="outlined" | |||
| value={lotNoInput} | |||
| onChange={(e) => setLotNoInput(e.target.value)} | |||
| onBlur={() => syncRowInputToParent(lotNoInput, dnQtyInput)} | |||
| onClick={(e) => e.stopPropagation()} | |||
| // onFocus={(e) => {setFocusField(e.target as HTMLInputElement);}} | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <TextField | |||
| id="dnQty" | |||
| label="此批送貨數量" | |||
| type="text" // Use type="text" to allow validation in the change handler | |||
| variant="outlined" | |||
| value={dnQtyInput} | |||
| onChange={(e) => setDnQtyInput(e.target.value)} | |||
| onBlur={() => syncRowInputToParent(lotNoInput, dnQtyInput)} | |||
| onClick={(e) => e.stopPropagation()} | |||
| InputProps={{ | |||
| inputProps: { | |||
| min: 0, // Optional: set a minimum value | |||
| step: "any", | |||
| inputMode: "decimal", | |||
| } | |||
| }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Button | |||
| variant="contained" | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| handleStart(); | |||
| }} | |||
| > | |||
| {t("submit")} | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| {/* <TableRow> */} | |||
| {/* <TableCell /> */} | |||
| {/* <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={12}> */} | |||
| {/* <Collapse in={true} timeout="auto" unmountOnExit> */} | |||
| {/* <Collapse in={open} timeout="auto" unmountOnExit> */} | |||
| {/* <Table> | |||
| <TableBody> | |||
| <TableRow> | |||
| <TableCell align="right"> | |||
| <Box> | |||
| <PoInputGrid | |||
| qc={qc} | |||
| setRows={setRows} | |||
| stockInLine={stockInLine} | |||
| setStockInLine={setStockInLine} | |||
| setProcessedQty={setProcessedQty} | |||
| itemDetail={row} | |||
| warehouse={warehouse} | |||
| /> | |||
| </Box> | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableBody> | |||
| </Table> */} | |||
| {/* </Collapse> */} | |||
| {/* </TableCell> */} | |||
| {/* </TableRow> */} | |||
| </> | |||
| ); | |||
| } | |||
| // ROW END | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||
| @@ -1178,7 +883,20 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||
| </TableHead> | |||
| <TableBody> | |||
| {rows.map((row) => ( | |||
| <Row key={row.id} row={row} /> | |||
| <PoDetailRow | |||
| key={row.id} | |||
| row={row} | |||
| selected={selectedRow?.id === row.id} | |||
| canSeeStockInReminders={canSeeStockInReminders} | |||
| showDnQty={renderFieldCondition(SECOND_IN_FIELD)} | |||
| savedLotNo={polInputList[row.id]?.lotNo ?? ""} | |||
| savedDnQty={polInputList[row.id]?.dnQty ?? ""} | |||
| onSelect={handleSelectPol} | |||
| onInputBlur={handleRowInputBlur} | |||
| getDnValues={getDnValues} | |||
| formatReceiptDate={formatReceiptDate} | |||
| onSubmitted={handleRowSubmitted} | |||
| /> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| @@ -0,0 +1,360 @@ | |||
| "use client"; | |||
| import { PurchaseOrderLine } from "@/app/api/po"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Radio, | |||
| Stack, | |||
| TableCell, | |||
| TableRow, | |||
| TextField, | |||
| Typography, | |||
| alpha, | |||
| } from "@mui/material"; | |||
| import { memo, useCallback, useEffect, useRef, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; | |||
| import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | |||
| import { createStockInLine } from "@/app/api/stockIn/actions"; | |||
| import { | |||
| isNotIntegerQty, | |||
| previewPoBatchStockQty, | |||
| roundStockQty, | |||
| StockQtyRoundChoice, | |||
| StockQtyRoundMode, | |||
| } from "./stockQtyRound"; | |||
| const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]); | |||
| function totalPutAwayStockQtyForPol(row: PurchaseOrderLine): number { | |||
| return row.stockInLine | |||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | |||
| .reduce((acc, sil) => { | |||
| const lineSum = | |||
| sil.putAwayLines?.reduce( | |||
| (s, p) => s + Number(p.stockQty ?? p.qty ?? 0), | |||
| 0, | |||
| ) ?? 0; | |||
| return acc + lineSum; | |||
| }, 0); | |||
| } | |||
| function polOrderStockQty(row: PurchaseOrderLine): number { | |||
| return Number(row.stockUom?.stockQty ?? row.qty ?? 0); | |||
| } | |||
| function purchaseOrderLineHasIncompleteStockIn(row: PurchaseOrderLine): boolean { | |||
| const orderStock = polOrderStockQty(row); | |||
| const putAway = totalPutAwayStockQtyForPol(row); | |||
| if (orderStock > 0 && putAway >= orderStock) { | |||
| return false; | |||
| } | |||
| return row.stockInLine | |||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | |||
| .some((sil) => | |||
| PURCHASE_STOCK_IN_ALERT_STATUSES.has((sil.status ?? "").toLowerCase().trim()), | |||
| ); | |||
| } | |||
| export type PoDetailRowDnValues = { | |||
| dnNo?: string; | |||
| receiptDate?: string; | |||
| }; | |||
| type Props = { | |||
| row: PurchaseOrderLine; | |||
| selected: boolean; | |||
| canSeeStockInReminders: boolean; | |||
| showDnQty: boolean; | |||
| savedLotNo: string; | |||
| savedDnQty: string; | |||
| onSelect: (row: PurchaseOrderLine) => void; | |||
| onInputBlur: (rowId: number, lotNo: string, dnQty: string) => void; | |||
| getDnValues: () => PoDetailRowDnValues; | |||
| formatReceiptDate: (receiptDate?: string) => string | undefined; | |||
| onSubmitted: (row: PurchaseOrderLine) => void; | |||
| }; | |||
| export const PoDetailRow = memo(function PoDetailRow({ | |||
| row, | |||
| selected, | |||
| canSeeStockInReminders, | |||
| showDnQty, | |||
| savedLotNo, | |||
| savedDnQty, | |||
| onSelect, | |||
| onInputBlur, | |||
| getDnValues, | |||
| formatReceiptDate, | |||
| onSubmitted, | |||
| }: Props) { | |||
| const { t } = useTranslation("purchaseOrder"); | |||
| const [lotNoInput, setLotNoInput] = useState(savedLotNo); | |||
| const [dnQtyInput, setDnQtyInput] = useState(savedDnQty); | |||
| const submitInFlightRef = useRef(false); | |||
| const [isStarting, setIsStarting] = useState(false); | |||
| useEffect(() => { | |||
| setLotNoInput(savedLotNo); | |||
| setDnQtyInput(savedDnQty); | |||
| }, [savedLotNo, savedDnQty]); | |||
| const handleStart = useCallback( | |||
| (roundMode?: StockQtyRoundMode) => { | |||
| if (submitInFlightRef.current || isStarting) return; | |||
| const orderQty = Number(row?.qty) ?? 0; | |||
| const acceptedQty = Number(dnQtyInput.trim()); | |||
| if (isNaN(acceptedQty) || acceptedQty <= 0) { | |||
| alert("來貨數量必須大於0!"); | |||
| return; | |||
| } | |||
| const previewStockQty = previewPoBatchStockQty( | |||
| orderQty, | |||
| Number(row.stockUom?.stockQty ?? 0), | |||
| acceptedQty, | |||
| ); | |||
| const needsRound = isNotIntegerQty(previewStockQty); | |||
| if (needsRound && roundMode !== "CEILING" && roundMode !== "FLOOR") { | |||
| return; | |||
| } | |||
| const round: StockQtyRoundChoice | undefined = | |||
| needsRound && roundMode | |||
| ? { | |||
| mode: roundMode, | |||
| before: previewStockQty, | |||
| after: roundStockQty(previewStockQty, roundMode), | |||
| } | |||
| : undefined; | |||
| const doSubmit = () => { | |||
| if (submitInFlightRef.current) return; | |||
| submitInFlightRef.current = true; | |||
| setIsStarting(true); | |||
| void (async () => { | |||
| try { | |||
| const dn = getDnValues(); | |||
| const postData = { | |||
| dnNo: dn.dnNo, | |||
| receiptDate: formatReceiptDate(dn.receiptDate), | |||
| itemId: row.itemId, | |||
| itemNo: row.itemNo, | |||
| itemName: row.itemName, | |||
| purchaseOrderLineId: row.id, | |||
| acceptedQty: acceptedQty, | |||
| productLotNo: lotNoInput || "", | |||
| ...(round | |||
| ? { | |||
| stockQtyRoundMode: round.mode, | |||
| stockQtyRoundSource: "CREATE" as const, | |||
| } | |||
| : {}), | |||
| }; | |||
| const res = await createStockInLine(postData); | |||
| if (res) { | |||
| setLotNoInput(""); | |||
| setDnQtyInput(""); | |||
| onSubmitted(row); | |||
| } | |||
| console.log(res); | |||
| } finally { | |||
| setIsStarting(false); | |||
| submitInFlightRef.current = false; | |||
| } | |||
| })(); | |||
| }; | |||
| const exceedOrderBy10Percent = orderQty > 0 && acceptedQty > orderQty * 1.1; | |||
| if (exceedOrderBy10Percent) { | |||
| submitDialogWithWarning(doSubmit, t, { | |||
| title: t("Confirm submit"), | |||
| html: t("This batch quantity exceeds order quantity. Do you still want to submit?"), | |||
| confirmButtonText: t("Submit"), | |||
| }); | |||
| } else { | |||
| doSubmit(); | |||
| } | |||
| }, | |||
| [ | |||
| dnQtyInput, | |||
| formatReceiptDate, | |||
| getDnValues, | |||
| isStarting, | |||
| lotNoInput, | |||
| onSubmitted, | |||
| row, | |||
| t, | |||
| ], | |||
| ); | |||
| const batchPurchaseQty = Number(dnQtyInput.trim()) || 0; | |||
| const previewStockQty = previewPoBatchStockQty( | |||
| Number(row?.qty) ?? 0, | |||
| Number(row.stockUom?.stockQty ?? 0), | |||
| batchPurchaseQty, | |||
| ); | |||
| const needsStockQtyRound = batchPurchaseQty > 0 && isNotIntegerQty(previewStockQty); | |||
| const roundUpQty = roundStockQty(previewStockQty, "CEILING"); | |||
| const roundDownQty = roundStockQty(previewStockQty, "FLOOR"); | |||
| const stockUomLabel = row.stockUom?.stockUomDesc?.trim() | |||
| ? ` ${row.stockUom.stockUomDesc.trim()}` | |||
| : ""; | |||
| const totalStockReceived = row.stockInLine | |||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | |||
| .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0); | |||
| const receivedTotalText = decimalFormatter.format(totalStockReceived); | |||
| const highlightColor = | |||
| Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; | |||
| const needsStockInAttention = | |||
| canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row); | |||
| return ( | |||
| <TableRow | |||
| hover | |||
| title={ | |||
| needsStockInAttention | |||
| ? "採購入庫未完成:此採購明細尚有入庫單為「待處理」或「收貨中」,請於下方完成入庫。" | |||
| : undefined | |||
| } | |||
| sx={{ | |||
| "& > *": { borderBottom: "unset" }, | |||
| color: "black", | |||
| ...(needsStockInAttention | |||
| ? (theme) => ({ | |||
| boxShadow: `inset 4px 0 0 ${theme.palette.error.main}`, | |||
| backgroundColor: alpha(theme.palette.error.main, 0.07), | |||
| }) | |||
| : {}), | |||
| }} | |||
| onClick={() => onSelect(row)} | |||
| > | |||
| <TableCell align="center" sx={{ width: "60px", position: "relative" }}> | |||
| {needsStockInAttention && ( | |||
| <Box | |||
| component="span" | |||
| aria-hidden | |||
| sx={{ | |||
| position: "absolute", | |||
| top: 6, | |||
| left: 8, | |||
| width: 10, | |||
| height: 10, | |||
| borderRadius: "50%", | |||
| bgcolor: "error.main", | |||
| border: "2px solid", | |||
| borderColor: "background.paper", | |||
| boxShadow: (theme) => `0 0 0 1px ${alpha(theme.palette.error.main, 0.45)}`, | |||
| zIndex: 1, | |||
| }} | |||
| /> | |||
| )} | |||
| <Radio checked={selected} /> | |||
| </TableCell> | |||
| <TableCell | |||
| align="left" | |||
| sx={{ width: 88, maxWidth: 88, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} | |||
| title={row.itemNo} | |||
| > | |||
| {row.itemNo} | |||
| </TableCell> | |||
| <TableCell | |||
| align="left" | |||
| sx={{ width: 100, maxWidth: 100, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} | |||
| title={row.itemName} | |||
| > | |||
| {row.itemName} | |||
| </TableCell> | |||
| <TableCell align="right">{integerFormatter.format(row.qty)}</TableCell> | |||
| <TableCell align="right">{integerFormatter.format(row.processed)}</TableCell> | |||
| <TableCell align="left">{row.uom?.udfudesc}</TableCell> | |||
| <TableCell sx={{ color: highlightColor }} align="right"> | |||
| {decimalFormatter.format(totalStockReceived)} | |||
| </TableCell> | |||
| <TableCell sx={{ color: highlightColor }} align="left"> | |||
| {row.stockUom.stockUomDesc} | |||
| </TableCell> | |||
| <TableCell sx={{ color: highlightColor }} align="left"> | |||
| {t(`${row.status.toLowerCase()}`)} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <TextField | |||
| id={`lotNo-${row.id}`} | |||
| label="輸入貨品批號" | |||
| type="text" | |||
| variant="outlined" | |||
| value={lotNoInput} | |||
| onChange={(e) => setLotNoInput(e.target.value)} | |||
| onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} | |||
| onClick={(e) => e.stopPropagation()} | |||
| /> | |||
| </TableCell> | |||
| {showDnQty ? ( | |||
| <TableCell align="center"> | |||
| <TextField | |||
| id={`dnQty-${row.id}`} | |||
| label="此批來貨數量" | |||
| type="text" | |||
| variant="outlined" | |||
| value={dnQtyInput} | |||
| onChange={(e) => setDnQtyInput(e.target.value)} | |||
| onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} | |||
| onClick={(e) => e.stopPropagation()} | |||
| InputProps={{ | |||
| inputProps: { | |||
| min: 0, | |||
| step: "any", | |||
| inputMode: "decimal", | |||
| }, | |||
| }} | |||
| /> | |||
| </TableCell> | |||
| ) : null} | |||
| <TableCell align="center"> | |||
| {needsStockQtyRound ? ( | |||
| <Stack spacing={0.5} alignItems="stretch" onClick={(e) => e.stopPropagation()}> | |||
| <Typography variant="caption" color="text.secondary" sx={{ whiteSpace: "nowrap" }}> | |||
| {t("Converted stock qty is")} {decimalFormatter.format(previewStockQty)} | |||
| {row.stockUom?.stockUomDesc ? ` (${row.stockUom.stockUomDesc})` : ""} | |||
| </Typography> | |||
| <Button | |||
| variant="contained" | |||
| size="medium" | |||
| disabled={isStarting} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| handleStart("CEILING"); | |||
| }} | |||
| > | |||
| {t("Round ceiling")} {roundUpQty}{stockUomLabel} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| size="medium" | |||
| disabled={isStarting} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| handleStart("FLOOR"); | |||
| }} | |||
| > | |||
| {t("Round floor")} {roundDownQty}{stockUomLabel} | |||
| </Button> | |||
| </Stack> | |||
| ) : ( | |||
| <Button | |||
| variant="contained" | |||
| disabled={isStarting} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| handleStart(); | |||
| }} | |||
| > | |||
| {t("submit")} | |||
| </Button> | |||
| )} | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| }); | |||
| @@ -0,0 +1,64 @@ | |||
| import Swal from "sweetalert2"; | |||
| import { TFunction } from "i18next"; | |||
| import { | |||
| isNotIntegerQty, | |||
| roundStockQty, | |||
| StockQtyRoundChoice, | |||
| StockQtyRoundMode, | |||
| } from "./stockQtyRound"; | |||
| type Translate = TFunction<["translation", ...string[]], undefined>; | |||
| export async function askStockQtyRoundDialog( | |||
| before: number, | |||
| t: Translate, | |||
| stockUomDesc?: string, | |||
| ): Promise<StockQtyRoundChoice | null> { | |||
| if (!isNotIntegerQty(before)) return null; | |||
| const ceiling = roundStockQty(before, "CEILING"); | |||
| const floor = roundStockQty(before, "FLOOR"); | |||
| const uom = stockUomDesc?.trim() ? ` (${stockUomDesc.trim()})` : ""; | |||
| const beforeText = before.toFixed(2); | |||
| const result = await Swal.fire({ | |||
| icon: "warning", | |||
| title: t("Stock qty is not an integer"), | |||
| html: ` | |||
| <div style="text-align:left"> | |||
| <p>${t("Converted stock qty is")} <b>${beforeText}</b>${uom}${t("Choose rounding method")}</p> | |||
| <label style="display:flex;align-items:center;gap:12px;margin:14px 0;font-size:18px;cursor:pointer"> | |||
| <input type="radio" name="stockQtyRoundMode" value="CEILING" checked style="width:22px;height:22px;flex-shrink:0" /> | |||
| ${t("Round ceiling")} → <b>${ceiling}</b>${uom} | |||
| </label> | |||
| <label style="display:flex;align-items:center;gap:12px;margin:14px 0;font-size:18px;cursor:pointer"> | |||
| <input type="radio" name="stockQtyRoundMode" value="FLOOR" style="width:22px;height:22px;flex-shrink:0" /> | |||
| ${t("Round floor")} → <b>${floor}</b>${uom} | |||
| </label> | |||
| </div> | |||
| `, | |||
| showCancelButton: true, | |||
| confirmButtonText: t("Confirm"), | |||
| cancelButtonText: t("Cancel"), | |||
| customClass: { | |||
| container: "swal-container-class", | |||
| popup: "swal-popup-class", | |||
| }, | |||
| preConfirm: () => { | |||
| const popup = Swal.getPopup(); | |||
| const selected = popup?.querySelector<HTMLInputElement>('input[name="stockQtyRoundMode"]:checked')?.value as StockQtyRoundMode | undefined; | |||
| if (selected !== "CEILING" && selected !== "FLOOR") { | |||
| Swal.showValidationMessage(t("Please choose a rounding method")); | |||
| return false; | |||
| } | |||
| return { | |||
| mode: selected, | |||
| before, | |||
| after: roundStockQty(before, selected), | |||
| } satisfies StockQtyRoundChoice; | |||
| }, | |||
| }); | |||
| if (!result.isConfirmed) return null; | |||
| return (result.value as StockQtyRoundChoice) ?? null; | |||
| } | |||
| @@ -0,0 +1,41 @@ | |||
| export type StockQtyRoundMode = "CEILING" | "FLOOR"; | |||
| export type StockQtyRoundSource = "CREATE" | "QC"; | |||
| export type StockQtyRoundChoice = { | |||
| mode: StockQtyRoundMode; | |||
| before: number; | |||
| after: number; | |||
| }; | |||
| export function previewPoBatchStockQty( | |||
| orderM18Qty: number, | |||
| orderStockQty: number, | |||
| batchM18Qty: number, | |||
| ): number { | |||
| if (!Number.isFinite(orderM18Qty) || orderM18Qty === 0) { | |||
| return Number(batchM18Qty.toFixed(2)); | |||
| } | |||
| return Number(((batchM18Qty * orderStockQty) / orderM18Qty).toFixed(2)); | |||
| } | |||
| export function isNotIntegerQty(qty: number): boolean { | |||
| if (!Number.isFinite(qty)) return false; | |||
| return Math.abs(qty - Math.round(qty)) > 1e-9; | |||
| } | |||
| /** PO 收貨/品檢:pending 或 escalated 且庫存數量非整數時需選擇 CEILING / FLOOR */ | |||
| export function needsPoQcStockQtyRound( | |||
| purchaseOrderLineId: number | null | undefined, | |||
| status: string | null | undefined, | |||
| acceptedQty: number | null | undefined, | |||
| ): boolean { | |||
| if (!purchaseOrderLineId) return false; | |||
| const silStatus = (status ?? "").toLowerCase().trim(); | |||
| if (silStatus !== "pending" && silStatus !== "escalated") return false; | |||
| return isNotIntegerQty(Number(acceptedQty ?? 0)); | |||
| } | |||
| export function roundStockQty(before: number, mode: StockQtyRoundMode): number { | |||
| if (mode === "CEILING") return Math.ceil(before); | |||
| return Math.floor(before); | |||
| } | |||
| @@ -260,8 +260,15 @@ const PoSearch: React.FC<Props> = ({ | |||
| ); | |||
| const onReset = useCallback(() => { | |||
| setFilteredPo(po); | |||
| }, [po]); | |||
| const today = dayjsToDateString(dayjs(), "input"); | |||
| setSelectedPoIds([]); | |||
| setSelectAll(false); | |||
| setPagingController(defaultPagingController); | |||
| setFilterArgs({ | |||
| estimatedArrivalDate: today, | |||
| estimatedArrivalDateTo: today, | |||
| }); | |||
| }, []); | |||
| const [autoSyncStatus, setAutoSyncStatus] = useState<string | null>(null); | |||
| const [isM18LookupLoading, setIsM18LookupLoading] = useState(false); | |||
| @@ -286,93 +293,97 @@ const PoSearch: React.FC<Props> = ({ | |||
| if (typeof v === "string" && (v as string).trim() === "") return; | |||
| cleanedQuery[k] = String(v); | |||
| }); | |||
| const baseListResp = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`, | |||
| { method: "GET" }, | |||
| ); | |||
| if (!baseListResp.ok) { | |||
| throw new Error(`PO list fetch failed: ${baseListResp.status}`); | |||
| } | |||
| const res = await baseListResp.json(); | |||
| if (!res) return; | |||
| if (res.records && res.records.length > 0) { | |||
| setFilteredPo(res.records); | |||
| setTotalCount(res.total); | |||
| return; | |||
| } | |||
| const searchedCodeRaw = (filterArgs as any)?.code; | |||
| const searchedCode = | |||
| typeof searchedCodeRaw === "string" ? searchedCodeRaw.trim() : ""; | |||
| const shouldAutoSyncFromM18 = | |||
| searchedCode.length > 14 && | |||
| (searchedCode.startsWith("PP") || searchedCode.startsWith("PF")); | |||
| if (!shouldAutoSyncFromM18 || autoSyncInProgressRef.current) { | |||
| setFilteredPo(res.records); | |||
| setTotalCount(res.total); | |||
| return; | |||
| } | |||
| try { | |||
| autoSyncInProgressRef.current = true; | |||
| setIsM18LookupLoading(true); | |||
| setAutoSyncStatus("正在從M18找尋PO..."); | |||
| const syncResp = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent( | |||
| searchedCode, | |||
| )}`, | |||
| const baseListResp = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`, | |||
| { method: "GET" }, | |||
| ); | |||
| if (!syncResp.ok) { | |||
| throw new Error(`M18 sync failed: ${syncResp.status}`); | |||
| if (!baseListResp.ok) { | |||
| throw new Error(`PO list fetch failed: ${baseListResp.status}`); | |||
| } | |||
| const res = await baseListResp.json(); | |||
| if (!res) return; | |||
| let syncJson: any = null; | |||
| try { | |||
| syncJson = await syncResp.json(); | |||
| } catch { | |||
| // Some endpoints may respond with plain text | |||
| const txt = await syncResp.text(); | |||
| syncJson = { raw: txt }; | |||
| if (res.records && res.records.length > 0) { | |||
| setFilteredPo(res.records); | |||
| setTotalCount(res.total); | |||
| return; | |||
| } | |||
| const syncOk = Boolean(syncJson?.totalSuccess && syncJson.totalSuccess > 0); | |||
| if (syncOk) { | |||
| setAutoSyncStatus("成功找到PO"); | |||
| const searchedCodeRaw = (filterArgs as any)?.code; | |||
| const searchedCode = | |||
| typeof searchedCodeRaw === "string" ? searchedCodeRaw.trim() : ""; | |||
| const shouldAutoSyncFromM18 = | |||
| searchedCode.length > 14 && | |||
| (searchedCode.startsWith("PP") || searchedCode.startsWith("PF")); | |||
| const listResp = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams( | |||
| cleanedQuery, | |||
| ).toString()}`, | |||
| if (!shouldAutoSyncFromM18 || autoSyncInProgressRef.current) { | |||
| setFilteredPo(res.records); | |||
| setTotalCount(res.total); | |||
| return; | |||
| } | |||
| try { | |||
| autoSyncInProgressRef.current = true; | |||
| setIsM18LookupLoading(true); | |||
| setAutoSyncStatus("正在從M18找尋PO..."); | |||
| const syncResp = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent( | |||
| searchedCode, | |||
| )}`, | |||
| { method: "GET" }, | |||
| ); | |||
| if (listResp.ok) { | |||
| const listJson = await listResp.json(); | |||
| setFilteredPo(listJson.records ?? []); | |||
| setTotalCount(listJson.total ?? 0); | |||
| if (!syncResp.ok) { | |||
| throw new Error(`M18 sync failed: ${syncResp.status}`); | |||
| } | |||
| let syncJson: any = null; | |||
| try { | |||
| syncJson = await syncResp.json(); | |||
| } catch { | |||
| // Some endpoints may respond with plain text | |||
| const txt = await syncResp.text(); | |||
| syncJson = { raw: txt }; | |||
| } | |||
| const syncOk = Boolean(syncJson?.totalSuccess && syncJson.totalSuccess > 0); | |||
| if (syncOk) { | |||
| setAutoSyncStatus("成功找到PO"); | |||
| return; | |||
| const listResp = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams( | |||
| cleanedQuery, | |||
| ).toString()}`, | |||
| { method: "GET" }, | |||
| ); | |||
| if (listResp.ok) { | |||
| const listJson = await listResp.json(); | |||
| setFilteredPo(listJson.records ?? []); | |||
| setTotalCount(listJson.total ?? 0); | |||
| setAutoSyncStatus("成功找到PO"); | |||
| return; | |||
| } | |||
| setAutoSyncStatus("找不到PO"); | |||
| } else { | |||
| setAutoSyncStatus("找不到PO"); | |||
| } | |||
| // Ensure UI updates even if sync didn't change results | |||
| setFilteredPo(res.records); | |||
| setTotalCount(res.total ?? 0); | |||
| } catch (e) { | |||
| console.error("Auto sync error:", e); | |||
| setAutoSyncStatus("找不到PO"); | |||
| } else { | |||
| setAutoSyncStatus("找不到PO"); | |||
| setFilteredPo(res.records); | |||
| setTotalCount(res.total ?? 0); | |||
| } finally { | |||
| setIsM18LookupLoading(false); | |||
| autoSyncInProgressRef.current = false; | |||
| } | |||
| // Ensure UI updates even if sync didn't change results | |||
| setFilteredPo(res.records); | |||
| setTotalCount(res.total ?? 0); | |||
| } catch (e) { | |||
| console.error("Auto sync error:", e); | |||
| setAutoSyncStatus("找不到PO"); | |||
| setFilteredPo(res.records); | |||
| setTotalCount(res.total ?? 0); | |||
| } finally { | |||
| setIsM18LookupLoading(false); | |||
| autoSyncInProgressRef.current = false; | |||
| console.error("PO list fetch error:", e); | |||
| } | |||
| }, | |||
| [], | |||
| @@ -1,45 +1,12 @@ | |||
| import { fetchAllItems } from "@/app/api/settings/item"; | |||
| // import ItemsSearch from "./ItemsSearch"; | |||
| // import ItemsSearchLoading from "./ItemsSearchLoading"; | |||
| import { SearchParams } from "@/app/utils/fetchUtil"; | |||
| import { TypeEnum } from "@/app/utils/typeEnum"; | |||
| import { notFound } from "next/navigation"; | |||
| import PoSearchLoading from "./PoSearchLoading"; | |||
| import PoSearch from "./PoSearch"; | |||
| import { fetchPoList, PoResult } from "@/app/api/po"; | |||
| import dayjs from "dayjs"; | |||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { defaultPagingController } from "../SearchResults/SearchResults"; | |||
| dayjs.extend(arraySupport); | |||
| interface SubComponents { | |||
| Loading: typeof PoSearchLoading; | |||
| } | |||
| type Props = { | |||
| // type: TypeEnum; | |||
| }; | |||
| const PoSearchWrapper: React.FC<Props> & SubComponents = async ( | |||
| { | |||
| // type, | |||
| }, | |||
| ) => { | |||
| // console.log(defaultPagingController) | |||
| const po = await fetchPoList({ | |||
| pageNum: 1, | |||
| pageSize: 10, | |||
| }); | |||
| const fixPoDate = po.records.map((p) => { | |||
| return { | |||
| ...p, | |||
| orderDate: dayjs(p.orderDate).add(-1, "month").format(OUTPUT_DATE_FORMAT), | |||
| }; | |||
| }); | |||
| return ( | |||
| <PoSearch po={fixPoDate} totalCount={po.total} /> | |||
| ); | |||
| const PoSearchWrapper: React.FC & SubComponents = () => { | |||
| return <PoSearch po={[]} totalCount={0} />; | |||
| }; | |||
| PoSearchWrapper.Loading = PoSearchLoading; | |||
| @@ -150,9 +150,9 @@ const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false, compactLay | |||
| if (isNaN(accQty) || accQty === undefined || accQty === null || typeof(accQty) != "number") { | |||
| setError("acceptQty", { message: t("value must be a number") }); | |||
| } else | |||
| if (!isJobOrder && accQty > itemDetail.acceptedQty) { | |||
| if (!isJobOrder && accQty > Math.ceil(itemDetail.acceptedQty)) { | |||
| setError("acceptQty", { message: `${t("acceptQty must not greater than")} ${ | |||
| itemDetail.acceptedQty}` }); | |||
| Math.ceil(itemDetail.acceptedQty)}` }); | |||
| } else | |||
| if (accQty <= 0) { | |||
| setError("acceptQty", { message: t("minimal value is 1") }); | |||
| @@ -163,8 +163,8 @@ const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false, compactLay | |||
| },[setError, qcDecision, accQty, itemDetail, isJobOrder]) | |||
| useEffect(() => { // W I P // ----- | |||
| if (qcDecision == 1) { | |||
| if (!isJobOrder && validateFieldFail("acceptQty", accQty > itemDetail.acceptedQty, `${t("acceptQty must not greater than")} ${ | |||
| itemDetail.acceptedQty}`)) return; | |||
| if (!isJobOrder && validateFieldFail("acceptQty", accQty > Math.ceil(itemDetail.acceptedQty), `${t("acceptQty must not greater than")} ${ | |||
| Math.ceil(itemDetail.acceptedQty)}`)) return; | |||
| if (validateFieldFail("acceptQty", accQty <= 0, t("minimal value is 1"))) return; | |||
| if (validateFieldFail("acceptQty", isNaN(accQty), t("value must be a number"))) return; | |||
| @@ -616,7 +616,7 @@ useEffect(() => { | |||
| } | |||
| e.target.value = r; | |||
| }} | |||
| inputProps={isJobOrder ? { min: 0.01, step: 0.01 } : { min: 0.01, max: itemDetail.acceptedQty, step: 0.01 }} | |||
| inputProps={isJobOrder ? { min: 0.01, step: 0.01 } : { min: 0.01, max: Math.ceil(itemDetail.acceptedQty), step: 0.01 }} | |||
| // onChange={(e) => { | |||
| // const inputValue = e.target.value; | |||
| // if (inputValue === '' || /^[0-9]*$/.test(inputValue)) { | |||
| @@ -14,7 +14,7 @@ import { | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||
| import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; | |||
| import { StockInLineRow } from "../PoDetail/PoInputGrid"; | |||
| import { useTranslation } from "react-i18next"; | |||
| @@ -23,6 +23,7 @@ import QcComponent from "./QcComponent"; | |||
| import PutAwayForm from "../PoDetail/PutAwayForm"; | |||
| import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid"; | |||
| import {msg, submitDialogWithWarning} from "../Swal/CustomAlerts"; | |||
| import { needsPoQcStockQtyRound, roundStockQty } from "../PoDetail/stockQtyRound"; | |||
| import { INPUT_DATE_FORMAT, arrayToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil"; | |||
| import dayjs from "dayjs"; | |||
| import { fetchPoQrcode } from "@/app/api/pdf/actions"; | |||
| @@ -99,6 +100,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| const [stockInLineInfo, setStockInLineInfo] = useState<StockInLine>(); | |||
| const [isLoading, setIsLoading] = useState<boolean>(false); | |||
| const [isSubmitting, setIsSubmitting] = useState<boolean>(false); | |||
| const qcSubmitInFlightRef = useRef(false); | |||
| // const [skipQc, setSkipQc] = useState<Boolean>(false); | |||
| // const [viewOnly, setViewOnly] = useState(false); | |||
| @@ -241,6 +243,29 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| ...defaultNewValue, | |||
| }, | |||
| }); | |||
| const stockQtyRoundMode = formProps.watch("stockQtyRoundMode"); | |||
| const qcDecision = formProps.watch("qcDecision"); | |||
| const roundChoiceRequired = useMemo(() => { | |||
| const needsRound = needsPoQcStockQtyRound( | |||
| stockInLineInfo?.purchaseOrderLineId, | |||
| stockInLineInfo?.status, | |||
| stockInLineInfo?.acceptedQty, | |||
| ); | |||
| const willAcceptStock = Boolean(skipQc) || qcDecision == 1; | |||
| return ( | |||
| needsRound && | |||
| willAcceptStock && | |||
| stockQtyRoundMode !== "CEILING" && | |||
| stockQtyRoundMode !== "FLOOR" | |||
| ); | |||
| }, [ | |||
| stockInLineInfo?.purchaseOrderLineId, | |||
| stockInLineInfo?.status, | |||
| stockInLineInfo?.acceptedQty, | |||
| skipQc, | |||
| qcDecision, | |||
| stockQtyRoundMode, | |||
| ]); | |||
| const closeWithResult = useCallback( | |||
| (updatedStockInLine?: StockInLine) => { | |||
| @@ -410,6 +435,34 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| return; | |||
| } | |||
| if (qcSubmitInFlightRef.current || isSubmitting) return; | |||
| const storedStockQty = Number(stockInLineInfo?.acceptedQty ?? 0); | |||
| const needsStockQtyRound = needsPoQcStockQtyRound( | |||
| stockInLineInfo?.purchaseOrderLineId, | |||
| stockInLineInfo?.status, | |||
| storedStockQty, | |||
| ); | |||
| const willAcceptStock = Boolean(skipQc) || qcAcceptLocal; | |||
| let roundPayload: | |||
| | { | |||
| stockQtyRoundMode: "CEILING" | "FLOOR"; | |||
| stockQtyRoundSource: "QC"; | |||
| } | |||
| | undefined; | |||
| if (willAcceptStock && needsStockQtyRound) { | |||
| const selectedMode = data.stockQtyRoundMode; | |||
| if (selectedMode !== "CEILING" && selectedMode !== "FLOOR") { | |||
| alert(t("Please choose a rounding method")); | |||
| return; | |||
| } | |||
| acceptQtyLocal = roundStockQty(storedStockQty, selectedMode); | |||
| roundPayload = { | |||
| stockQtyRoundMode: selectedMode, | |||
| stockQtyRoundSource: "QC", | |||
| }; | |||
| } | |||
| const isJobOrderSource = Boolean(stockInLineInfo?.jobOrderId) || printSource === "productionProcess"; | |||
| const qcData = { | |||
| dnNo : data.dnNo? data.dnNo : "DN00000", | |||
| @@ -423,6 +476,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| // For Job Order QC, allow updating received qty beyond demand/accepted. | |||
| // Backend uses request.acceptedQty in QC flow, so we must send it explicitly. | |||
| acceptedQty: (qcAcceptLocal && isJobOrderSource) ? (acceptQtyLocal ? acceptQtyLocal : 0) : stockInLineInfo?.acceptedQty, | |||
| ...roundPayload, | |||
| // qcResult: itemDetail.status != "escalated" ? qcResults.map(item => ({ | |||
| qcResult: qcResultsLocal.map(item => ({ | |||
| // id: item.id, | |||
| @@ -431,7 +485,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| // qcDescription: item.qcDescription, | |||
| qcPassed: item.qcPassed? item.qcPassed : false, | |||
| failQty: (item.failQty && !item.qcPassed) ? item.failQty : 0, | |||
| // failedQty: (typeof item.failedQty === "number" && !item.isPassed) ? item.failedQty : 0, | |||
| // failedQty: (typeof item.failedQty === "number" && !item.isPassed) ? item.failQty : 0, | |||
| remarks: item.remarks || '', | |||
| ...(QC_MEASUREMENT_ENABLED && isMeasurableQcItem(item) | |||
| ? { measurement: buildQcMeasurementPayload(item) } | |||
| @@ -457,11 +511,13 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| } | |||
| console.log("Escalation Data for submission", escalationLog); | |||
| qcSubmitInFlightRef.current = true; | |||
| setIsSubmitting(true); | |||
| const resEscalate = await postStockInLine({...qcData, escalationLog}); | |||
| qcRes = Array.isArray(resEscalate.entity) ? resEscalate.entity[0] : (resEscalate.entity as StockInLine); | |||
| } else { | |||
| qcSubmitInFlightRef.current = true; | |||
| setIsSubmitting(true); | |||
| const resNormal = await postStockInLine(qcData); | |||
| qcRes = Array.isArray(resNormal.entity) ? resNormal.entity[0] : (resNormal.entity as StockInLine); | |||
| @@ -553,7 +609,8 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| } else { | |||
| closeWithResult(qcRes); | |||
| } | |||
| setIsSubmitting(false); | |||
| setIsSubmitting(false); | |||
| qcSubmitInFlightRef.current = false; | |||
| msg("已更新來貨狀態", { | |||
| position: | |||
| typeof window !== "undefined" && | |||
| @@ -815,7 +872,7 @@ const printQrcode = useCallback( | |||
| color="primary" | |||
| sx={{ mt: 1 }} | |||
| onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)} | |||
| disabled={isSubmitting || isLoading} | |||
| disabled={isSubmitting || isLoading || roundChoiceRequired} | |||
| > | |||
| {isSubmitting ? (t("submitting")) : (skipQc ? t("confirm") : t("confirm qc result"))} | |||
| </Button>)} | |||
| @@ -23,6 +23,11 @@ import { | |||
| useGridApiRef, | |||
| } from "@mui/x-data-grid"; | |||
| import { StockInLine } from "@/app/api/stockIn"; | |||
| import { | |||
| needsPoQcStockQtyRound, | |||
| roundStockQty, | |||
| StockQtyRoundMode, | |||
| } from "@/components/PoDetail/stockQtyRound"; | |||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import { dayjsToDateString, INPUT_DATE_FORMAT, OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| @@ -114,6 +119,36 @@ const StockInForm: React.FC<Props> = ({ | |||
| const productionDate = watch("productionDate"); | |||
| const expiryDate = watch("expiryDate"); | |||
| const uom = watch("uom"); | |||
| const stockQtyRoundMode = watch("stockQtyRoundMode"); | |||
| const displayedAcceptedQty = watch("acceptedQty"); | |||
| const originalAcceptedQty = Number(itemDetail.acceptedQty ?? 0); | |||
| const showStockQtyRoundButtons = | |||
| !putawayMode && | |||
| !disabled && | |||
| needsPoQcStockQtyRound( | |||
| itemDetail.purchaseOrderLineId, | |||
| itemDetail.status, | |||
| originalAcceptedQty, | |||
| ); | |||
| const stockUomLabel = itemDetail.stockUomDesc ? ` ${itemDetail.stockUomDesc}` : ""; | |||
| const roundUpQty = roundStockQty(originalAcceptedQty, "CEILING"); | |||
| const roundDownQty = roundStockQty(originalAcceptedQty, "FLOOR"); | |||
| const selectStockQtyRound = useCallback( | |||
| (mode: StockQtyRoundMode) => { | |||
| const after = roundStockQty(Number(itemDetail.acceptedQty ?? 0), mode); | |||
| setValue("stockQtyRoundMode", mode, { shouldDirty: true, shouldTouch: true }); | |||
| setValue("acceptedQty", after, { shouldDirty: true, shouldTouch: true }); | |||
| (setValue as (name: string, value: unknown, options?: object) => void)( | |||
| "acceptQty", | |||
| after, | |||
| { shouldDirty: true, shouldTouch: true }, | |||
| ); | |||
| const accInput = document.getElementById("accQty") as HTMLInputElement | null; | |||
| if (accInput) accInput.value = String(after); | |||
| }, | |||
| [itemDetail.acceptedQty, setValue], | |||
| ); | |||
| const [openModal, setOpenModal] = useState<boolean>(false); | |||
| const [openExpDatePicker, setOpenExpDatePicker] = useState<boolean>(false); | |||
| @@ -401,17 +436,47 @@ const StockInForm: React.FC<Props> = ({ | |||
| </Grid> | |||
| </> | |||
| ) : ( | |||
| <> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("acceptedQty")} | |||
| fullWidth | |||
| sx={compactFields ? undefined : textfieldSx} | |||
| disabled={true} | |||
| value={displayedAcceptedQty ?? originalAcceptedQty} | |||
| {...register("acceptedQty", { | |||
| required: "acceptedQty required!", | |||
| })} | |||
| /> | |||
| </Grid> | |||
| {showStockQtyRoundButtons && ( | |||
| <Grid item xs={6}> | |||
| <Stack spacing={0.75} justifyContent="flex-end" sx={{ height: "100%" }}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("Please choose a rounding method")} | |||
| </Typography> | |||
| <Stack direction="row" spacing={1} alignItems="stretch"> | |||
| <Button | |||
| variant={stockQtyRoundMode === "CEILING" ? "contained" : "outlined"} | |||
| size={compactFields ? "medium" : "large"} | |||
| onClick={() => selectStockQtyRound("CEILING")} | |||
| sx={{ flex: 1, fontWeight: 700 }} | |||
| > | |||
| {t("Round ceiling")} {roundUpQty}{stockUomLabel} | |||
| </Button> | |||
| <Button | |||
| variant={stockQtyRoundMode === "FLOOR" ? "contained" : "outlined"} | |||
| size={compactFields ? "medium" : "large"} | |||
| onClick={() => selectStockQtyRound("FLOOR")} | |||
| sx={{ flex: 1, fontWeight: 700 }} | |||
| > | |||
| {t("Round floor")} {roundDownQty}{stockUomLabel} | |||
| </Button> | |||
| </Stack> | |||
| </Stack> | |||
| </Grid> | |||
| )} | |||
| </> | |||
| )} | |||
| {/* <Grid item xs={4}> | |||
| <TextField | |||
| @@ -22,6 +22,7 @@ | |||
| "Start PO": "Start PO", | |||
| "Do you want to complete?": "Do you want to complete?", | |||
| "Cancel": "Cancel", | |||
| "Confirm": "Confirm", | |||
| "Complete": "Complete", | |||
| "Complete Success": "Complete Success", | |||
| "Complete Fail": "Complete Fail", | |||
| @@ -52,6 +53,12 @@ | |||
| "putawayQty": "Put Away Qty", | |||
| "Confirm submit": "Confirm Submit", | |||
| "This batch quantity exceeds order quantity. Do you still want to submit?": "This batch quantity exceeds order quantity. Do you still want to submit?", | |||
| "Stock qty is not an integer": "Stock quantity is not an integer", | |||
| "Converted stock qty is": "Converted stock qty is", | |||
| "Choose rounding method": ". Please choose a rounding method:", | |||
| "Round ceiling": "Round up", | |||
| "Round floor": "Round down", | |||
| "Please choose a rounding method": "Please choose a rounding method", | |||
| "acceptQty": "Accept Qty", | |||
| "printQty": "Print Qty", | |||
| "qcResult": "QC Result", | |||
| @@ -22,6 +22,7 @@ | |||
| "Start PO": "開始採購訂單", | |||
| "Do you want to complete?": "確定完成嗎?", | |||
| "Cancel": "取消", | |||
| "Confirm": "確認", | |||
| "Complete": "完成", | |||
| "Complete Success": "完成成功", | |||
| "Complete Fail": "完成失敗", | |||
| @@ -52,6 +53,12 @@ | |||
| "putawayQty": "上架數量", | |||
| "Confirm submit": "確定提交", | |||
| "This batch quantity exceeds order quantity. Do you still want to submit?": "本批收貨數量超出訂單數量。仍要提交嗎?", | |||
| "Stock qty is not an integer": "換算庫存數量不是整數", | |||
| "Converted stock qty is": "換算庫存數量為", | |||
| "Choose rounding method": "。請選擇進位方式:", | |||
| "Round ceiling": "向上取整", | |||
| "Round floor": "向下取整", | |||
| "Please choose a rounding method": "請選擇進位方式", | |||
| "acceptQty": "揀收數量", | |||
| "printQty": "列印數量", | |||
| "qcResult": "品檢結果", | |||