CANCERYS\kw093 před 2 týdny
rodič
revize
e1b655696e
13 změnil soubory, kde provedl 761 přidání a 458 odebrání
  1. +5
    -0
      src/app/api/stockIn/actions.ts
  2. +1
    -0
      src/app/api/stockIn/index.ts
  3. +58
    -340
      src/components/PoDetail/PoDetail.tsx
  4. +360
    -0
      src/components/PoDetail/PoDetailRow.tsx
  5. +64
    -0
      src/components/PoDetail/askStockQtyRoundDialog.ts
  6. +41
    -0
      src/components/PoDetail/stockQtyRound.ts
  7. +85
    -74
      src/components/PoSearch/PoSearch.tsx
  8. +2
    -35
      src/components/PoSearch/PoSearchWrapper.tsx
  9. +5
    -5
      src/components/Qc/QcComponent.tsx
  10. +61
    -4
      src/components/Qc/QcStockInModal.tsx
  11. +65
    -0
      src/components/StockIn/StockInForm.tsx
  12. +7
    -0
      src/i18n/en/purchaseOrder.json
  13. +7
    -0
      src/i18n/zh/purchaseOrder.json

+ 5
- 0
src/app/api/stockIn/actions.ts Zobrazit soubor

@@ -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;


+ 1
- 0
src/app/api/stockIn/index.ts Zobrazit soubor

@@ -50,6 +50,7 @@ export interface StockInInput {
productionDate?: string;
expiryDate: string;
uom?: Uom;
stockQtyRoundMode?: "CEILING" | "FLOOR";
}

export interface PoResult {


+ 58
- 340
src/components/PoDetail/PoDetail.tsx Zobrazit soubor

@@ -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>


+ 360
- 0
src/components/PoDetail/PoDetailRow.tsx Zobrazit soubor

@@ -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>
);
});

+ 64
- 0
src/components/PoDetail/askStockQtyRoundDialog.ts Zobrazit soubor

@@ -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;
}

+ 41
- 0
src/components/PoDetail/stockQtyRound.ts Zobrazit soubor

@@ -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);
}

+ 85
- 74
src/components/PoSearch/PoSearch.tsx Zobrazit soubor

@@ -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);
}
},
[],


+ 2
- 35
src/components/PoSearch/PoSearchWrapper.tsx Zobrazit soubor

@@ -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;


+ 5
- 5
src/components/Qc/QcComponent.tsx Zobrazit soubor

@@ -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)) {


+ 61
- 4
src/components/Qc/QcStockInModal.tsx Zobrazit soubor

@@ -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>)}


+ 65
- 0
src/components/StockIn/StockInForm.tsx Zobrazit soubor

@@ -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


+ 7
- 0
src/i18n/en/purchaseOrder.json Zobrazit soubor

@@ -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",


+ 7
- 0
src/i18n/zh/purchaseOrder.json Zobrazit soubor

@@ -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": "品檢結果",


Načítá se…
Zrušit
Uložit