| @@ -38,6 +38,10 @@ export interface StockInLineEntry { | |||||
| receiptDate?: string; | receiptDate?: string; | ||||
| dnDate?: string; | dnDate?: string; | ||||
| dnNo?: string; | dnNo?: string; | ||||
| stockQtyRoundMode?: "CEILING" | "FLOOR" | "HALF_UP" | "CUSTOM"; | |||||
| stockQtyCustomQty?: number; | |||||
| stockQtyRoundSource?: "CREATE" | "QC"; | |||||
| stockQtyCustomReason?: string; | |||||
| } | } | ||||
| export interface QcResult{ | export interface QcResult{ | ||||
| @@ -66,6 +70,7 @@ export interface StockInInput { | |||||
| productionDate?: string; | productionDate?: string; | ||||
| expiryDate: string; | expiryDate: string; | ||||
| uom: Uom; | uom: Uom; | ||||
| stockQtyRoundMode?: "CEILING" | "FLOOR"; | |||||
| } | } | ||||
| export interface QCInput { | export interface QCInput { | ||||
| status: string; | status: string; | ||||
| @@ -50,6 +50,7 @@ export interface StockInInput { | |||||
| productionDate?: string; | productionDate?: string; | ||||
| expiryDate: string; | expiryDate: string; | ||||
| uom?: Uom; | uom?: Uom; | ||||
| stockQtyRoundMode?: "CEILING" | "FLOOR"; | |||||
| } | } | ||||
| export interface PoResult { | export interface PoResult { | ||||
| @@ -9,9 +9,7 @@ import { | |||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| ButtonProps, | ButtonProps, | ||||
| Collapse, | |||||
| Grid, | Grid, | ||||
| IconButton, | |||||
| Paper, | Paper, | ||||
| Stack, | Stack, | ||||
| Tab, | Tab, | ||||
| @@ -29,16 +27,14 @@ import { | |||||
| FormControlLabel, | FormControlLabel, | ||||
| Card, | Card, | ||||
| CardContent, | CardContent, | ||||
| Radio, | |||||
| alpha, | |||||
| Dialog, | Dialog, | ||||
| DialogActions, | DialogActions, | ||||
| DialogContent, | DialogContent, | ||||
| DialogTitle, | DialogTitle, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | |||||
| import PrinterSelect from "@/components/common/PrinterSelect"; | import PrinterSelect from "@/components/common/PrinterSelect"; | ||||
| import { PoDetailRow } from "./PoDetailRow"; | |||||
| // import InputDataGrid, { TableRow } from "../InputDataGrid/InputDataGrid"; | // import InputDataGrid, { TableRow } from "../InputDataGrid/InputDataGrid"; | ||||
| import { | import { | ||||
| GridColDef, | GridColDef, | ||||
| @@ -52,9 +48,6 @@ import { | |||||
| fetchPoSummariesClient, | fetchPoSummariesClient, | ||||
| startPo, | startPo, | ||||
| } from "@/app/api/po/actions"; | } from "@/app/api/po/actions"; | ||||
| import { | |||||
| createStockInLine | |||||
| } from "@/app/api/stockIn/actions"; | |||||
| import { | import { | ||||
| useCallback, | useCallback, | ||||
| useContext, | useContext, | ||||
| @@ -63,20 +56,16 @@ import { | |||||
| useRef, | useRef, | ||||
| useState, | useState, | ||||
| } from "react"; | } from "react"; | ||||
| import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; | |||||
| import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; | |||||
| import PoInputGrid from "./PoInputGrid"; | import PoInputGrid from "./PoInputGrid"; | ||||
| // import { QcItemWithChecks } from "@/app/api/qc"; | // import { QcItemWithChecks } from "@/app/api/qc"; | ||||
| import { useRouter, useSearchParams, usePathname } from "next/navigation"; | import { useRouter, useSearchParams, usePathname } from "next/navigation"; | ||||
| import { WarehouseResult } from "@/app/api/warehouse"; | 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 { CameraContext } from "../Cameras/CameraProvider"; | ||||
| import QrModal from "./QrModal"; | import QrModal from "./QrModal"; | ||||
| import { PlayArrow } from "@mui/icons-material"; | import { PlayArrow } from "@mui/icons-material"; | ||||
| import DoneIcon from "@mui/icons-material/Done"; | import DoneIcon from "@mui/icons-material/Done"; | ||||
| import { downloadFile, getCustomWidth } from "@/app/utils/commonUtil"; | 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 { List, ListItem, ListItemButton, ListItemText, Divider } from "@mui/material"; | ||||
| import { Controller, FormProvider, useForm } from "react-hook-form"; | import { Controller, FormProvider, useForm } from "react-hook-form"; | ||||
| import dayjs, { Dayjs } from "dayjs"; | import dayjs, { Dayjs } from "dayjs"; | ||||
| @@ -100,41 +89,6 @@ type Props = { | |||||
| printerCombo: PrinterCombo[]; | 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 = | type EntryError = | ||||
| | { | | { | ||||
| [field in keyof StockInLine]?: string; | [field in keyof StockInLine]?: string; | ||||
| @@ -567,6 +521,48 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| setPurchaseOrder(newPo); | setPurchaseOrder(newPo); | ||||
| }, [purchaseOrder.id]); | }, [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 handleMailTemplateForStockInLine = useCallback(async (stockInLineId: number) => { | ||||
| const response = await getMailTemplatePdfForStockInLine(stockInLineId) | const response = await getMailTemplatePdfForStockInLine(stockInLineId) | ||||
| if (response) { | if (response) { | ||||
| @@ -582,298 +578,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| // setStockInLine([]) | // 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 [tabIndex, setTabIndex] = useState(0); | ||||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | ||||
| @@ -1178,7 +883,20 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| {rows.map((row) => ( | {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> | </TableBody> | ||||
| </Table> | </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(() => { | 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 [autoSyncStatus, setAutoSyncStatus] = useState<string | null>(null); | ||||
| const [isM18LookupLoading, setIsM18LookupLoading] = useState(false); | const [isM18LookupLoading, setIsM18LookupLoading] = useState(false); | ||||
| @@ -286,93 +293,97 @@ const PoSearch: React.FC<Props> = ({ | |||||
| if (typeof v === "string" && (v as string).trim() === "") return; | if (typeof v === "string" && (v as string).trim() === "") return; | ||||
| cleanedQuery[k] = String(v); | 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 { | 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" }, | { 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" }, | { 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"); | 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"); | 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) { | } 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 PoSearchLoading from "./PoSearchLoading"; | ||||
| import PoSearch from "./PoSearch"; | 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 { | interface SubComponents { | ||||
| Loading: typeof PoSearchLoading; | 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; | 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") { | if (isNaN(accQty) || accQty === undefined || accQty === null || typeof(accQty) != "number") { | ||||
| setError("acceptQty", { message: t("value must be a number") }); | setError("acceptQty", { message: t("value must be a number") }); | ||||
| } else | } else | ||||
| if (!isJobOrder && accQty > itemDetail.acceptedQty) { | |||||
| if (!isJobOrder && accQty > Math.ceil(itemDetail.acceptedQty)) { | |||||
| setError("acceptQty", { message: `${t("acceptQty must not greater than")} ${ | setError("acceptQty", { message: `${t("acceptQty must not greater than")} ${ | ||||
| itemDetail.acceptedQty}` }); | |||||
| Math.ceil(itemDetail.acceptedQty)}` }); | |||||
| } else | } else | ||||
| if (accQty <= 0) { | if (accQty <= 0) { | ||||
| setError("acceptQty", { message: t("minimal value is 1") }); | 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]) | },[setError, qcDecision, accQty, itemDetail, isJobOrder]) | ||||
| useEffect(() => { // W I P // ----- | useEffect(() => { // W I P // ----- | ||||
| if (qcDecision == 1) { | 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", accQty <= 0, t("minimal value is 1"))) return; | ||||
| if (validateFieldFail("acceptQty", isNaN(accQty), t("value must be a number"))) return; | if (validateFieldFail("acceptQty", isNaN(accQty), t("value must be a number"))) return; | ||||
| @@ -616,7 +616,7 @@ useEffect(() => { | |||||
| } | } | ||||
| e.target.value = r; | 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) => { | // onChange={(e) => { | ||||
| // const inputValue = e.target.value; | // const inputValue = e.target.value; | ||||
| // if (inputValue === '' || /^[0-9]*$/.test(inputValue)) { | // if (inputValue === '' || /^[0-9]*$/.test(inputValue)) { | ||||
| @@ -14,7 +14,7 @@ import { | |||||
| TextField, | TextField, | ||||
| Typography, | Typography, | ||||
| } from "@mui/material"; | } 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 { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; | ||||
| import { StockInLineRow } from "../PoDetail/PoInputGrid"; | import { StockInLineRow } from "../PoDetail/PoInputGrid"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -23,6 +23,7 @@ import QcComponent from "./QcComponent"; | |||||
| import PutAwayForm from "../PoDetail/PutAwayForm"; | import PutAwayForm from "../PoDetail/PutAwayForm"; | ||||
| import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid"; | import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid"; | ||||
| import {msg, submitDialogWithWarning} from "../Swal/CustomAlerts"; | import {msg, submitDialogWithWarning} from "../Swal/CustomAlerts"; | ||||
| import { needsPoQcStockQtyRound, roundStockQty } from "../PoDetail/stockQtyRound"; | |||||
| import { INPUT_DATE_FORMAT, arrayToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil"; | import { INPUT_DATE_FORMAT, arrayToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { fetchPoQrcode } from "@/app/api/pdf/actions"; | import { fetchPoQrcode } from "@/app/api/pdf/actions"; | ||||
| @@ -99,6 +100,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| const [stockInLineInfo, setStockInLineInfo] = useState<StockInLine>(); | const [stockInLineInfo, setStockInLineInfo] = useState<StockInLine>(); | ||||
| const [isLoading, setIsLoading] = useState<boolean>(false); | const [isLoading, setIsLoading] = useState<boolean>(false); | ||||
| const [isSubmitting, setIsSubmitting] = useState<boolean>(false); | const [isSubmitting, setIsSubmitting] = useState<boolean>(false); | ||||
| const qcSubmitInFlightRef = useRef(false); | |||||
| // const [skipQc, setSkipQc] = useState<Boolean>(false); | // const [skipQc, setSkipQc] = useState<Boolean>(false); | ||||
| // const [viewOnly, setViewOnly] = useState(false); | // const [viewOnly, setViewOnly] = useState(false); | ||||
| @@ -241,6 +243,29 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| ...defaultNewValue, | ...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( | const closeWithResult = useCallback( | ||||
| (updatedStockInLine?: StockInLine) => { | (updatedStockInLine?: StockInLine) => { | ||||
| @@ -410,6 +435,34 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| return; | 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 isJobOrderSource = Boolean(stockInLineInfo?.jobOrderId) || printSource === "productionProcess"; | ||||
| const qcData = { | const qcData = { | ||||
| dnNo : data.dnNo? data.dnNo : "DN00000", | 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. | // For Job Order QC, allow updating received qty beyond demand/accepted. | ||||
| // Backend uses request.acceptedQty in QC flow, so we must send it explicitly. | // Backend uses request.acceptedQty in QC flow, so we must send it explicitly. | ||||
| acceptedQty: (qcAcceptLocal && isJobOrderSource) ? (acceptQtyLocal ? acceptQtyLocal : 0) : stockInLineInfo?.acceptedQty, | acceptedQty: (qcAcceptLocal && isJobOrderSource) ? (acceptQtyLocal ? acceptQtyLocal : 0) : stockInLineInfo?.acceptedQty, | ||||
| ...roundPayload, | |||||
| // qcResult: itemDetail.status != "escalated" ? qcResults.map(item => ({ | // qcResult: itemDetail.status != "escalated" ? qcResults.map(item => ({ | ||||
| qcResult: qcResultsLocal.map(item => ({ | qcResult: qcResultsLocal.map(item => ({ | ||||
| // id: item.id, | // id: item.id, | ||||
| @@ -431,7 +485,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| // qcDescription: item.qcDescription, | // qcDescription: item.qcDescription, | ||||
| qcPassed: item.qcPassed? item.qcPassed : false, | qcPassed: item.qcPassed? item.qcPassed : false, | ||||
| failQty: (item.failQty && !item.qcPassed) ? item.failQty : 0, | 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 || '', | remarks: item.remarks || '', | ||||
| ...(QC_MEASUREMENT_ENABLED && isMeasurableQcItem(item) | ...(QC_MEASUREMENT_ENABLED && isMeasurableQcItem(item) | ||||
| ? { measurement: buildQcMeasurementPayload(item) } | ? { measurement: buildQcMeasurementPayload(item) } | ||||
| @@ -457,11 +511,13 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| } | } | ||||
| console.log("Escalation Data for submission", escalationLog); | console.log("Escalation Data for submission", escalationLog); | ||||
| qcSubmitInFlightRef.current = true; | |||||
| setIsSubmitting(true); | setIsSubmitting(true); | ||||
| const resEscalate = await postStockInLine({...qcData, escalationLog}); | const resEscalate = await postStockInLine({...qcData, escalationLog}); | ||||
| qcRes = Array.isArray(resEscalate.entity) ? resEscalate.entity[0] : (resEscalate.entity as StockInLine); | qcRes = Array.isArray(resEscalate.entity) ? resEscalate.entity[0] : (resEscalate.entity as StockInLine); | ||||
| } else { | } else { | ||||
| qcSubmitInFlightRef.current = true; | |||||
| setIsSubmitting(true); | setIsSubmitting(true); | ||||
| const resNormal = await postStockInLine(qcData); | const resNormal = await postStockInLine(qcData); | ||||
| qcRes = Array.isArray(resNormal.entity) ? resNormal.entity[0] : (resNormal.entity as StockInLine); | qcRes = Array.isArray(resNormal.entity) ? resNormal.entity[0] : (resNormal.entity as StockInLine); | ||||
| @@ -553,7 +609,8 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| } else { | } else { | ||||
| closeWithResult(qcRes); | closeWithResult(qcRes); | ||||
| } | } | ||||
| setIsSubmitting(false); | |||||
| setIsSubmitting(false); | |||||
| qcSubmitInFlightRef.current = false; | |||||
| msg("已更新來貨狀態", { | msg("已更新來貨狀態", { | ||||
| position: | position: | ||||
| typeof window !== "undefined" && | typeof window !== "undefined" && | ||||
| @@ -815,7 +872,7 @@ const printQrcode = useCallback( | |||||
| color="primary" | color="primary" | ||||
| sx={{ mt: 1 }} | sx={{ mt: 1 }} | ||||
| onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)} | onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)} | ||||
| disabled={isSubmitting || isLoading} | |||||
| disabled={isSubmitting || isLoading || roundChoiceRequired} | |||||
| > | > | ||||
| {isSubmitting ? (t("submitting")) : (skipQc ? t("confirm") : t("confirm qc result"))} | {isSubmitting ? (t("submitting")) : (skipQc ? t("confirm") : t("confirm qc result"))} | ||||
| </Button>)} | </Button>)} | ||||
| @@ -23,6 +23,11 @@ import { | |||||
| useGridApiRef, | useGridApiRef, | ||||
| } from "@mui/x-data-grid"; | } from "@mui/x-data-grid"; | ||||
| import { StockInLine } from "@/app/api/stockIn"; | import { StockInLine } from "@/app/api/stockIn"; | ||||
| import { | |||||
| needsPoQcStockQtyRound, | |||||
| roundStockQty, | |||||
| StockQtyRoundMode, | |||||
| } from "@/components/PoDetail/stockQtyRound"; | |||||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | ||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import { dayjsToDateString, INPUT_DATE_FORMAT, OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | 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 productionDate = watch("productionDate"); | ||||
| const expiryDate = watch("expiryDate"); | const expiryDate = watch("expiryDate"); | ||||
| const uom = watch("uom"); | 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 [openModal, setOpenModal] = useState<boolean>(false); | ||||
| const [openExpDatePicker, setOpenExpDatePicker] = useState<boolean>(false); | const [openExpDatePicker, setOpenExpDatePicker] = useState<boolean>(false); | ||||
| @@ -401,17 +436,47 @@ const StockInForm: React.FC<Props> = ({ | |||||
| </Grid> | </Grid> | ||||
| </> | </> | ||||
| ) : ( | ) : ( | ||||
| <> | |||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={t("acceptedQty")} | label={t("acceptedQty")} | ||||
| fullWidth | fullWidth | ||||
| sx={compactFields ? undefined : textfieldSx} | sx={compactFields ? undefined : textfieldSx} | ||||
| disabled={true} | disabled={true} | ||||
| value={displayedAcceptedQty ?? originalAcceptedQty} | |||||
| {...register("acceptedQty", { | {...register("acceptedQty", { | ||||
| required: "acceptedQty required!", | required: "acceptedQty required!", | ||||
| })} | })} | ||||
| /> | /> | ||||
| </Grid> | </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}> | {/* <Grid item xs={4}> | ||||
| <TextField | <TextField | ||||
| @@ -22,6 +22,7 @@ | |||||
| "Start PO": "Start PO", | "Start PO": "Start PO", | ||||
| "Do you want to complete?": "Do you want to complete?", | "Do you want to complete?": "Do you want to complete?", | ||||
| "Cancel": "Cancel", | "Cancel": "Cancel", | ||||
| "Confirm": "Confirm", | |||||
| "Complete": "Complete", | "Complete": "Complete", | ||||
| "Complete Success": "Complete Success", | "Complete Success": "Complete Success", | ||||
| "Complete Fail": "Complete Fail", | "Complete Fail": "Complete Fail", | ||||
| @@ -52,6 +53,12 @@ | |||||
| "putawayQty": "Put Away Qty", | "putawayQty": "Put Away Qty", | ||||
| "Confirm submit": "Confirm Submit", | "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?", | "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", | "acceptQty": "Accept Qty", | ||||
| "printQty": "Print Qty", | "printQty": "Print Qty", | ||||
| "qcResult": "QC Result", | "qcResult": "QC Result", | ||||
| @@ -22,6 +22,7 @@ | |||||
| "Start PO": "開始採購訂單", | "Start PO": "開始採購訂單", | ||||
| "Do you want to complete?": "確定完成嗎?", | "Do you want to complete?": "確定完成嗎?", | ||||
| "Cancel": "取消", | "Cancel": "取消", | ||||
| "Confirm": "確認", | |||||
| "Complete": "完成", | "Complete": "完成", | ||||
| "Complete Success": "完成成功", | "Complete Success": "完成成功", | ||||
| "Complete Fail": "完成失敗", | "Complete Fail": "完成失敗", | ||||
| @@ -52,6 +53,12 @@ | |||||
| "putawayQty": "上架數量", | "putawayQty": "上架數量", | ||||
| "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": "換算庫存數量不是整數", | |||||
| "Converted stock qty is": "換算庫存數量為", | |||||
| "Choose rounding method": "。請選擇進位方式:", | |||||
| "Round ceiling": "向上取整", | |||||
| "Round floor": "向下取整", | |||||
| "Please choose a rounding method": "請選擇進位方式", | |||||
| "acceptQty": "揀收數量", | "acceptQty": "揀收數量", | ||||
| "printQty": "列印數量", | "printQty": "列印數量", | ||||
| "qcResult": "品檢結果", | "qcResult": "品檢結果", | ||||