diff --git a/package.json b/package.json index 3a630eb6..20f4f65d 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "warmup": "node scripts/warmup-menu.mjs", "build": "next build", "start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start", "lint": "next lint", diff --git a/scripts/warmup-menu.mjs b/scripts/warmup-menu.mjs new file mode 100644 index 00000000..03910fa1 --- /dev/null +++ b/scripts/warmup-menu.mjs @@ -0,0 +1,88 @@ +const BASE = process.env.WARMUP_BASE || "http://localhost:3000"; +const COOKIE = process.env.WARMUP_COOKIE || ""; + +const PATHS = [ + "/dashboard", + "/po", + "/pickOrder", + "/inventory", + "/itemTracing", + "/stocktakemanagement", + "/stockIssue", + "/putAway", + "/finishedGood/management", + "/stockRecord", + "/doworkbench", + "/do", + "/ps", + "/jo", + "/jodetail", + "/productionProcess", + "/bag", + "/bagPrint", + "/laserPrint", + "/report", + "/m18Syn", + "/chart/purchase", + "/chart/joborder", + "/chart/joborder/board", + "/chart/delivery", + "/chart/warehouse", + "/chart/forecast", + "/settings/user", + "/settings/clientMonitor", + "/settings/items", + "/settings/itemDefaultShelfLife", + "/settings/equipment", + "/settings/warehouse", + "/settings/printer", + "/settings/itemPrice", + "/settings/qcItem", + "/settings/qcCategory", + "/settings/qcItemAll", + "/settings/shop/board", + "/settings/deliveryOrderFloor", + "/settings/rss", + "/settings/bomWeighting", + "/settings/masterDataIssues", + "/settings/qrCodeHandle", + "/settings/m18ImportTesting", + "/settings/importExcel", + "/settings/importBom", +]; + +async function waitReady() { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + try { + const res = await fetch(`${BASE}/login`); + if (res.status < 500) return; + } catch { + // server still starting + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`dev server not ready at ${BASE}`); +} + +console.log(`Waiting for ${BASE}/login (Next compiles this on first hit; the site will look stuck until it finishes)…`); +await waitReady(); +console.log(`Dev server answered. Warming ${PATHS.length} menu pages at ${BASE}`); +if (!COOKIE) { + console.log( + "No WARMUP_COOKIE — private pages (/dashboard, /ps, /report, /settings, …) will redirect and stay uncompiled.", + ); +} + +for (const path of PATHS) { + process.stdout.write(`compiling ${path} … `); + const t0 = Date.now(); + const res = await fetch(`${BASE}${path}`, { + redirect: "manual", + headers: COOKIE ? { cookie: COOKIE } : {}, + }); + const redirected = res.status >= 300 && res.status < 400; + console.log( + `${res.status} ${Date.now() - t0}ms${redirected ? " (redirect, page not compiled)" : ""}`, + ); +} diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index f674efe0..4118c777 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -24,6 +24,8 @@ export interface InventoryLotDetailResponse { holdQty: number; availableQty: number; uom: string; + /** Stock UoM short label (e.g. 包) for the count input. */ + uomShortDesc?: string | null; warehouseCode: string; warehouseName: string; warehouseSlot: string; diff --git a/src/components/StockTakeManagement/ApproverStockTake.tsx b/src/components/StockTakeManagement/ApproverStockTake.tsx index e2bdbb13..a4c0754a 100644 --- a/src/components/StockTakeManagement/ApproverStockTake.tsx +++ b/src/components/StockTakeManagement/ApproverStockTake.tsx @@ -33,6 +33,10 @@ import { batchSaveApproverStockTakeRecords, updateStockTakeRecordStatusToNotMatch, } from "@/app/api/stockTake/actions"; +import { stockTakeQtyEndAdornment } from "./stockTakeQtyAdornment"; +import StockTakeQtyGapHint from "./StockTakeQtyGapHint"; +import { stockTakeHiddenOnHand, stockTakeQtyGapWarnText } from "./stockTakeQtyGapWarning"; +import { useStockTakeQtyGapWarnPercent } from "./useStockTakeQtyGapWarnPercent"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import dayjs from "dayjs"; @@ -52,6 +56,7 @@ const ApproverStockTake: React.FC = ({ onSnackbar, }) => { const { t } = useTranslation(["stockTake", "common"]); + const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); const { data: session } = useSession() as { data: SessionWithTokens | null }; const [inventoryLotDetails, setInventoryLotDetails] = useState([]); @@ -61,6 +66,7 @@ const ApproverStockTake: React.FC = ({ // 每个记录的选择状态,key 为 detail.id const [qtySelection, setQtySelection] = useState>({}); const [approverQty, setApproverQty] = useState>({}); + const [gapCheckOpen, setGapCheckOpen] = useState>({}); const [approverBadQty, setApproverBadQty] = useState>({}); const [saving, setSaving] = useState(false); const [batchSaving, setBatchSaving] = useState(false); @@ -255,7 +261,19 @@ const ApproverStockTake: React.FC = ({ selectedSession.stockTakeId ); - onSnackbar(t("Approver stock take record saved successfully"), "success"); + const gapText = + selection === "approver" + ? stockTakeQtyGapWarnText(t, approverQty[detail.id] || "", stockTakeHiddenOnHand(detail), qtyGapWarnPercent) + : null; + if (selection === "approver") { + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: true })); + } + onSnackbar( + gapText + ? `${t("Approver stock take record saved successfully")} ${gapText}` + : t("Approver stock take record saved successfully"), + gapText ? "warning" : "success", + ); // 計算最終數量(合格數) const goodQty = finalQty - finalBadQty; @@ -292,7 +310,7 @@ const ApproverStockTake: React.FC = ({ } finally { setSaving(false); } - }, [selectedSession, qtySelection, approverQty, approverBadQty, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); + }, [selectedSession, qtySelection, approverQty, approverBadQty, t, currentUserId, onSnackbar, page, pageSize, loadDetails, qtyGapWarnPercent]); const handleUpdateStatusToNotMatch = useCallback(async (detail: InventoryLotDetailResponse) => { if (!detail.stockTakeRecordId) { @@ -598,17 +616,28 @@ const ApproverStockTake: React.FC = ({ size="small" type="number" value={approverQty[detail.id] || ""} + onFocus={() => { + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: false })); + if (selection !== "approver") { + setQtySelection({ ...qtySelection, [detail.id]: "approver" }); + } + }} + onBlur={() => + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: true })) + } onChange={(e) => setApproverQty({ ...approverQty, [detail.id]: e.target.value })} + InputProps={{ + endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), + }} sx={{ - width: 130, - minWidth: 130, + width: 168, + minWidth: 168, '& .MuiInputBase-input': { height: '1.4375em', padding: '4px 8px' } }} placeholder={t("Stock Take Qty") } - disabled={selection !== "approver"} /> = ({ = {formatNumber(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} + )} {(() => { diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index 91f57485..11f94ec4 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -53,6 +53,10 @@ import { type ApproverInventoryLotDetailsQuery, } from "@/app/api/stockTake/actions"; import { fetchStockTakeSections } from "@/app/api/warehouse/actions"; +import { stockTakeQtyEndAdornment, StockTakeQtyWithUnit } from "./stockTakeQtyAdornment"; +import StockTakeQtyGapHint from "./StockTakeQtyGapHint"; +import { stockTakeHiddenOnHand, stockTakeQtyGapWarnText } from "./stockTakeQtyGapWarning"; +import { useStockTakeQtyGapWarnPercent } from "./useStockTakeQtyGapWarnPercent"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import dayjs from "dayjs"; @@ -226,6 +230,7 @@ const ApproverStockTakeAll: React.FC = ({ onSnackbar, }) => { const { t } = useTranslation(["stockTake", "common"]); + const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); const { data: session } = useSession() as { data: SessionWithTokens | null }; const [inventoryLotDetails, setInventoryLotDetails] = useState([]); @@ -235,6 +240,7 @@ const ApproverStockTakeAll: React.FC = ({ const [searchVarianceFilterStrict, setSearchVarianceFilterStrict] = useState(false); const [qtySelection, setQtySelection] = useState>({}); const [approverQty, setApproverQty] = useState>({}); + const [gapCheckOpen, setGapCheckOpen] = useState>({}); const [approverBadQty, setApproverBadQty] = useState>({}); const [saving, setSaving] = useState(false); const [batchSaving, setBatchSaving] = useState(false); @@ -654,7 +660,19 @@ const ApproverStockTakeAll: React.FC = ({ try { await saveApproverStockTakeRecord(request, selectedSession.stockTakeId); - onSnackbar(t("Approver stock take record saved successfully"), "success"); + const gapText = + selection === "approver" + ? stockTakeQtyGapWarnText(t, approverQty[detail.id] || "", stockTakeHiddenOnHand(detail), qtyGapWarnPercent) + : null; + if (selection === "approver") { + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: true })); + } + onSnackbar( + gapText + ? `${t("Approver stock take record saved successfully")} ${gapText}` + : t("Approver stock take record saved successfully"), + gapText ? "warning" : "success", + ); setInventoryLotDetails((prev) => prev.map((d) => @@ -688,7 +706,7 @@ const ApproverStockTakeAll: React.FC = ({ setSaving(false); } }, - [selectedSession, currentUserId, qtySelection, approverQty, approverBadQty, t, onSnackbar, mode] + [selectedSession, currentUserId, qtySelection, approverQty, approverBadQty, t, onSnackbar, mode, qtyGapWarnPercent] ); const handleUpdateStatusToNotMatch = useCallback( @@ -934,10 +952,13 @@ const ApproverStockTakeAll: React.FC = ({ flex: 1, sortable: false, renderCell: (params) => ( - - + + {params.row.itemCode || "-"} {params.row.itemName || "-"} - + {params.row.lotNo || "-"} {params.row.expiryDate @@ -1052,12 +1073,12 @@ const ApproverStockTakeAll: React.FC = ({ /> {t("First")}:{" "} - {formatNumber( - (detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0) - )}{" "} - {/* - = {formatNumber(detail.firstStockTakeQty ?? 0)} - */} + )} @@ -1077,12 +1098,12 @@ const ApproverStockTakeAll: React.FC = ({ /> {t("Second")}:{" "} - {formatNumber( - (detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0) - )}{" "} - {/* - = {formatNumber(detail.secondStockTakeQty ?? 0)} - */} + @@ -1108,6 +1129,18 @@ const ApproverStockTakeAll: React.FC = ({ size="small" type="number" value={approverQty[detail.id] || ""} + onFocus={() => { + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: false })); + if (mode !== "approved" && selection !== "approver") { + setQtySelection({ + ...qtySelection, + [detail.id]: "approver", + }); + } + }} + onBlur={() => + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: true })) + } onKeyDown={blockNonIntegerKeys} onChange={(e) => { const clean = sanitizeIntegerInput(e.target.value); @@ -1116,18 +1149,29 @@ const ApproverStockTakeAll: React.FC = ({ [detail.id]: clean, }); }} + InputProps={{ + endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), + }} sx={{ - width: 72, - minWidth: 72, + width: 120, + minWidth: 120, "& .MuiInputBase-input": { py: 0.5, px: 1, }, }} // placeholder={t("Stock Take Qty")} - disabled={mode === "approved" || selection !== "approver"} + disabled={mode === "approved"} inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} /> + + + {/* = {formatNumber(approverGoodQty)} @@ -1152,7 +1196,7 @@ const ApproverStockTakeAll: React.FC = ({ {summaryLine( `${t("Selected Qty")}:`, - formatNumber(selectedQty) + [formatNumber(selectedQty), detail.uomShortDesc?.trim()].filter(Boolean).join(" ") )} {summaryLine(`${t("Book Qty")}:`, formatNumber(bookQty))} {summaryLine( diff --git a/src/components/StockTakeManagement/PickerReStockTake.tsx b/src/components/StockTakeManagement/PickerReStockTake.tsx index 1c8ebd3b..925b1bbb 100644 --- a/src/components/StockTakeManagement/PickerReStockTake.tsx +++ b/src/components/StockTakeManagement/PickerReStockTake.tsx @@ -30,6 +30,10 @@ import { getInventoryLotDetailsBySectionNotMatch } from "@/app/api/stockTake/actions"; import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; +import { stockTakeQtyEndAdornment, StockTakeQtyWithUnit } from "./stockTakeQtyAdornment"; +import StockTakeQtyGapHint from "./StockTakeQtyGapHint"; +import { stockTakeHiddenOnHand, stockTakeQtyGapWarnText } from "./stockTakeQtyGapWarning"; +import { useStockTakeQtyGapWarnPercent } from "./useStockTakeQtyGapWarnPercent"; import PickerBatchSaveFab from "./PickerBatchSaveFab"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; @@ -52,6 +56,7 @@ const PickerReStockTake: React.FC = ({ onSnackbar, }) => { const { t } = useTranslation(["stockTake", "common"]); + const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); const { data: session } = useSession() as { data: SessionWithTokens | null }; const [inventoryLotDetails, setInventoryLotDetails] = useState([]); @@ -65,6 +70,7 @@ const PickerReStockTake: React.FC = ({ remark: string; }>>({}); const [saving, setSaving] = useState(false); + const [gapCheckOpen, setGapCheckOpen] = useState>({}); const [batchSaving, setBatchSaving] = useState(false); const [shortcutInput, setShortcutInput] = useState(""); const [page, setPage] = useState(0); @@ -246,7 +252,19 @@ const PickerReStockTake: React.FC = ({ currentUserId ); - onSnackbar(t("Stock take record saved successfully"), "success"); + const gapText = stockTakeQtyGapWarnText( + t, + totalQtyStr ?? "", + stockTakeHiddenOnHand(detail), + qtyGapWarnPercent, + ); + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:${isFirstSubmit ? "first" : "second"}`]: true })); + onSnackbar( + gapText + ? `${t("Stock take record saved successfully")} ${gapText}` + : t("Stock take record saved successfully"), + gapText ? "warning" : "success", + ); const savedId = result?.id ?? detail.stockTakeRecordId; setInventoryLotDetails((prev) => @@ -286,7 +304,7 @@ const PickerReStockTake: React.FC = ({ } finally { setSaving(false); } - }, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); + }, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails, qtyGapWarnPercent]); const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { if (selectedSession?.status?.toLowerCase() === "completed") { @@ -492,7 +510,7 @@ const PickerReStockTake: React.FC = ({ {t("Warehouse Location")} {t("Item-lotNo-ExpiryDate")} {t("UOM")} - {t("Stock Take Qty(include Bad Qty)= Available Qty")} + {t("Stock Take Qty(include Bad Qty)= Available Qty")} {t("Action")} {/*{t("Remark")}*/} {t("Record Status")} @@ -520,28 +538,40 @@ const PickerReStockTake: React.FC = ({ {detail.warehouseArea || "-"}{detail.warehouseSlot || "-"} - {detail.itemCode || "-"} {detail.itemName || "-"} + + {detail.itemCode || "-"} {detail.itemName || "-"} + {detail.lotNo || "-"} {detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"} {detail.uom || "-"} - + {/* First */} {!submitDisabled && isFirstSubmit ? ( + {t("First")}: + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:first`]: false })) + } + onBlur={() => + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:first`]: true })) + } inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} onKeyDown={blockNonIntegerKeys} onChange={(e) => { @@ -553,9 +583,12 @@ const PickerReStockTake: React.FC = ({ [detail.id]: { ...(prev[detail.id] ?? defaultInputs), firstQty: val } })); }} + InputProps={{ + endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), + }} sx={{ - width: 130, - minWidth: 130, + width: 148, + minWidth: 148, "& .MuiInputBase-input": { height: "1.4375em", padding: "4px 8px", @@ -590,28 +623,39 @@ const PickerReStockTake: React.FC = ({ placeholder={t("Bad Qty")} /> */} - - = {formatNumber(parseFloat(inputs.firstQty || "0") - parseFloat(inputs.firstBadQty || "0"))} - + + ) : detail.firstStockTakeQty != null ? ( {t("First")}:{" "} - {formatNumber((detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0))}{" "} - {/* ({formatNumber(detail.firstBadQty ?? 0)}) */} - ={" "} - {formatNumber(detail.firstStockTakeQty ?? 0)} + ) : null} {/* Second */} {!submitDisabled && isSecondSubmit ? ( + {t("Second")}: + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:second`]: false })) + } + onBlur={() => + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:second`]: true })) + } inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} onKeyDown={blockNonIntegerKeys} onChange={(e) => { @@ -623,9 +667,12 @@ const PickerReStockTake: React.FC = ({ [detail.id]: { ...(prev[detail.id] ?? defaultInputs), secondQty: clean } })); }} + InputProps={{ + endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), + }} sx={{ - width: 130, - minWidth: 130, + width: 148, + minWidth: 148, "& .MuiInputBase-input": { height: "1.4375em", padding: "4px 8px", @@ -660,17 +707,21 @@ const PickerReStockTake: React.FC = ({ placeholder={t("Bad Qty")} /> */} - - = {formatNumber(parseFloat(inputs.secondQty || "0") - parseFloat(inputs.secondBadQty || "0"))} - + + ) : detail.secondStockTakeQty != null ? ( {t("Second")}:{" "} - {formatNumber((detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0))}{" "} - {/* ({formatNumber(detail.secondBadQty ?? 0)}) */} - ={" "} - {formatNumber(detail.secondStockTakeQty ?? 0)} + ) : null} diff --git a/src/components/StockTakeManagement/PickerStockTake.tsx b/src/components/StockTakeManagement/PickerStockTake.tsx index 220c7b3a..6d63b6b0 100644 --- a/src/components/StockTakeManagement/PickerStockTake.tsx +++ b/src/components/StockTakeManagement/PickerStockTake.tsx @@ -35,6 +35,10 @@ import { batchSavePickerStockTakeInputs, } from "@/app/api/stockTake/actions"; import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; +import { stockTakeQtyEndAdornment, StockTakeQtyWithUnit } from "./stockTakeQtyAdornment"; +import StockTakeQtyGapHint from "./StockTakeQtyGapHint"; +import { stockTakeHiddenOnHand, stockTakeQtyGapWarnText } from "./stockTakeQtyGapWarning"; +import { useStockTakeQtyGapWarnPercent } from "./useStockTakeQtyGapWarnPercent"; import PickerBatchSaveFab from "./PickerBatchSaveFab"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; @@ -57,6 +61,7 @@ const PickerStockTake: React.FC = ({ onSnackbar, }) => { const { t } = useTranslation(["stockTake", "common"]); + const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); const { data: session } = useSession() as { data: SessionWithTokens | null }; const [inventoryLotDetails, setInventoryLotDetails] = useState([]); @@ -72,6 +77,8 @@ const PickerStockTake: React.FC = ({ const [savingRecordId, setSavingRecordId] = useState(null); const [remark, setRemark] = useState(""); const [saving, setSaving] = useState(false); + /** Qty fields the user has left, or just saved. Warning stays hidden while typing. */ + const [gapCheckOpen, setGapCheckOpen] = useState>({}); const [batchSaving, setBatchSaving] = useState(false); const [shortcutInput, setShortcutInput] = useState(""); const [page, setPage] = useState(0); @@ -240,7 +247,19 @@ const PickerStockTake: React.FC = ({ await saveStockTakeRecord(request, selectedSession.stockTakeId, currentUserId); - onSnackbar(t("Stock take record saved successfully"), "success"); + const gapText = stockTakeQtyGapWarnText( + t, + totalQtyStr ?? "", + stockTakeHiddenOnHand(detail), + qtyGapWarnPercent, + ); + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:${isFirstSubmit ? "first" : "second"}`]: true })); + onSnackbar( + gapText + ? `${t("Stock take record saved successfully")} ${gapText}` + : t("Stock take record saved successfully"), + gapText ? "warning" : "success", + ); //await loadDetails(page, pageSize, { silent: true }); setInventoryLotDetails((prev) => @@ -286,6 +305,7 @@ const PickerStockTake: React.FC = ({ t, currentUserId, onSnackbar, + qtyGapWarnPercent, ] ); @@ -540,7 +560,7 @@ const PickerStockTake: React.FC = ({ {t("Warehouse Location")} {t("Item-lotNo-ExpiryDate")} {t("UOM")} - {t("Stock Take Qty(include Bad Qty)= Available Qty")} + {t("Stock Take Qty(include Bad Qty)= Available Qty")} {t("Action")} {/*{t("Remark")}*/} {t("Record Status")} @@ -572,16 +592,19 @@ const PickerStockTake: React.FC = ({ - + {detail.itemCode || "-"} {detail.itemName || "-"} - + {detail.lotNo || "-"} {detail.expiryDate @@ -592,16 +615,23 @@ const PickerStockTake: React.FC = ({ {detail.uom || "-"} {/* Qty + Bad Qty 合并显示/输入 */} - + {/* First */} {!submitDisabled && isFirstSubmit ? ( + {t("First")}: + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:first`]: false })) + } + onBlur={() => + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:first`]: true })) + } inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} onKeyDown={blockNonIntegerKeys} onChange={(e) => { @@ -610,9 +640,12 @@ const PickerStockTake: React.FC = ({ if (val.includes("-")) return; setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstQty: val } })); }} + InputProps={{ + endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), + }} sx={{ - width: 130, - minWidth: 130, + width: 148, + minWidth: 148, "& .MuiInputBase-input": { height: "1.4375em", padding: "4px 8px", @@ -652,40 +685,39 @@ const PickerStockTake: React.FC = ({ placeholder={t("Bad Qty")} /> */} - - = - {formatNumber( - parseFloat(recordInputs[detail.id]?.firstQty || "0") - - parseFloat(recordInputs[detail.id]?.firstBadQty || "0") - )} - + + ) : detail.firstStockTakeQty != null ? ( {t("First")}:{" "} - {formatNumber( - (detail.firstStockTakeQty ?? 0) + - (detail.firstBadQty ?? 0) - )}{" "} - {/* - ( - {formatNumber( - detail.firstBadQty ?? 0 - )} - */} - ={" "} - {formatNumber(detail.firstStockTakeQty ?? 0)} + ) : null} {/* Second */} {!submitDisabled && isSecondSubmit ? ( + {t("Second")}: + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:second`]: false })) + } + onBlur={() => + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:second`]: true })) + } inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} onKeyDown={blockNonIntegerKeys} onChange={(e) => { @@ -694,9 +726,12 @@ const PickerStockTake: React.FC = ({ if (val.includes("-")) return; setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondQty: val } })); }} + InputProps={{ + endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), + }} sx={{ - width: 130, - minWidth: 130, + width: 148, + minWidth: 148, "& .MuiInputBase-input": { height: "1.4375em", padding: "4px 8px", @@ -728,29 +763,21 @@ const PickerStockTake: React.FC = ({ placeholder={t("Bad Qty")} /> */} - - = - {formatNumber( - parseFloat(recordInputs[detail.id]?.secondQty || "0") - - parseFloat(recordInputs[detail.id]?.secondBadQty || "0") - )} - + + ) : detail.secondStockTakeQty != null ? ( {t("Second")}:{" "} - {formatNumber( - (detail.secondStockTakeQty ?? 0) + - (detail.secondBadQty ?? 0) - )}{" "} - {/* - ( - {formatNumber( - detail.secondBadQty ?? 0 - )} - */} - ={" "} - {formatNumber(detail.secondStockTakeQty ?? 0)} + ) : null} diff --git a/src/components/StockTakeManagement/StockTakeQtyGapHint.tsx b/src/components/StockTakeManagement/StockTakeQtyGapHint.tsx new file mode 100644 index 00000000..3323d5e1 --- /dev/null +++ b/src/components/StockTakeManagement/StockTakeQtyGapHint.tsx @@ -0,0 +1,40 @@ +"use client"; + +import Typography from "@mui/material/Typography"; +import { useTranslation } from "react-i18next"; +import { STOCK_TAKE_QTY_GAP_WARN_PERCENT, stockTakeQtyGapWarnText } from "./stockTakeQtyGapWarning"; + +/** Shown after the field is left, or flagged on save. Does not show the hidden on-hand quantity. */ +export default function StockTakeQtyGapHint({ + entered, + currentQty, + open, + threshold = STOCK_TAKE_QTY_GAP_WARN_PERCENT, +}: { + entered: string; + currentQty: number | null | undefined; + /** False while the user is still typing. */ + open: boolean; + /** Warn when the count differs from on-hand by at least this percent. */ + threshold?: number; +}) { + const { t } = useTranslation("stockTake"); + if (!open) return null; + const text = stockTakeQtyGapWarnText(t, entered, currentQty, threshold); + if (!text) return null; + return ( + + {text} + + ); +} diff --git a/src/components/StockTakeManagement/StockTakeTab.tsx b/src/components/StockTakeManagement/StockTakeTab.tsx index ad4af356..957e1901 100644 --- a/src/components/StockTakeManagement/StockTakeTab.tsx +++ b/src/components/StockTakeManagement/StockTakeTab.tsx @@ -1,14 +1,23 @@ "use client"; -import { Box, Tab, Tabs, Snackbar, Alert, CircularProgress, Typography } from "@mui/material"; -import { useState, useCallback, useEffect } from "react"; +import { Box, Tab, Tabs, Snackbar, Alert, CircularProgress, Typography, TextField, Button, Stack } from "@mui/material"; +import { useState, useCallback, useEffect, useRef } from "react"; +import { useSession } from "next-auth/react"; import { useTranslation } from "react-i18next"; +import { AUTH } from "@/authorities"; +import { SessionWithTokens } from "@/config/authConfig"; import { AllPickedStockTakeListReponse, getLatestApproverStockTakeHeader } from "@/app/api/stockTake/actions"; import PickerCardList from "./PickerCardList"; import type { PickerCardListFilters } from "./PickerCardList"; import PickerStockTake from "./PickerStockTake"; import PickerReStockTake from "./PickerReStockTake"; import ApproverStockTakeAll from "./ApproverStockTakeAll"; +import { useStockTakeQtyGapWarnPercent } from "./useStockTakeQtyGapWarnPercent"; +import { + parseQtyGapWarnPercent, + saveStockTakeQtyGapWarnPercent, +} from "./qtyGapWarnSettingClient"; +import { STOCK_TAKE_QTY_GAP_WARN_PERCENT } from "./stockTakeQtyGapWarning"; type ViewScope = "picker" | "approver-all"; const DEFAULT_PICKER_CARD_LIST_FILTERS: PickerCardListFilters = { @@ -21,6 +30,14 @@ const DEFAULT_PICKER_CARD_LIST_FILTERS: PickerCardListFilters = { const StockTakeTab: React.FC = () => { const { t } = useTranslation(["stockTake", "common"]); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const isAdmin = (session?.abilities ?? session?.user?.abilities ?? []).some( + (ability) => String(ability).trim() === AUTH.ADMIN, + ); + const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); + const [qtyGapDraft, setQtyGapDraft] = useState(String(STOCK_TAKE_QTY_GAP_WARN_PERCENT)); + const [qtyGapSaving, setQtyGapSaving] = useState(false); + const qtyGapSaveLock = useRef(false); const [tabValue, setTabValue] = useState(0); const [selectedSession, setSelectedSession] = useState(null); const [viewMode, setViewMode] = useState<"details" | "reStockTake">("details"); @@ -66,6 +83,31 @@ const StockTakeTab: React.FC = () => { }); }, []); + useEffect(() => { + setQtyGapDraft(String(qtyGapWarnPercent)); + }, [qtyGapWarnPercent]); + + const saveQtyGapWarnPercent = useCallback(async () => { + if (!isAdmin || qtyGapSaveLock.current) return; + const parsed = Number(qtyGapDraft.trim()); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 1000) { + handleSnackbar(t("qtyGapWarnPercentInvalid"), "warning"); + return; + } + qtyGapSaveLock.current = true; + setQtyGapSaving(true); + try { + await saveStockTakeQtyGapWarnPercent(parsed); + setQtyGapDraft(String(parseQtyGapWarnPercent(String(parsed)))); + handleSnackbar(t("qtyGapWarnPercentSaved"), "success"); + } catch (e) { + handleSnackbar(e instanceof Error ? e.message : t("qtyGapWarnPercentInvalid"), "error"); + } finally { + qtyGapSaveLock.current = false; + setQtyGapSaving(false); + } + }, [handleSnackbar, isAdmin, qtyGapDraft, t]); + useEffect(() => { if (tabValue !== 1 && tabValue !== 2) return; setApproverLoading(true); @@ -115,6 +157,46 @@ const StockTakeTab: React.FC = () => { return ( + {isAdmin && ( + + + {t("qtyGapWarnPercent")} + + setQtyGapDraft(e.target.value.replace(/[^\d]/g, ""))} + inputProps={{ min: 0, max: 1000, inputMode: "numeric" }} + sx={{ + width: 88, + m: 0, + "& .MuiFilledInput-root": { height: 40 }, + "& .MuiFilledInput-input.MuiInputBase-inputSizeSmall": { + height: 40, + boxSizing: "border-box", + paddingTop: 0, + paddingBottom: 0, + lineHeight: "40px", + }, + }} + /> + + + )} { diff --git a/src/components/StockTakeManagement/qtyGapWarnSettingClient.ts b/src/components/StockTakeManagement/qtyGapWarnSettingClient.ts new file mode 100644 index 00000000..ac6e81ee --- /dev/null +++ b/src/components/StockTakeManagement/qtyGapWarnSettingClient.ts @@ -0,0 +1,49 @@ +"use client"; + +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { STOCK_TAKE_QTY_GAP_WARN_PERCENT } from "./stockTakeQtyGapWarning"; + +export const STOCK_TAKE_QTY_GAP_WARN_SETTING = "STOCK_TAKE.qtyGapWarnPercent"; +export const STOCK_TAKE_QTY_GAP_SETTING_EVENT = "stock-take-qty-gap-warn-percent"; + +type SettingsRow = { name: string; value: string }; + +let cached: number | null = null; + +export function parseQtyGapWarnPercent(raw: string | null | undefined): number { + const text = String(raw ?? "").trim(); + if (!text) return STOCK_TAKE_QTY_GAP_WARN_PERCENT; + const n = Number(text); + if (!Number.isFinite(n) || n < 0 || n > 1000) return STOCK_TAKE_QTY_GAP_WARN_PERCENT; + return Math.round(n); +} + +export async function fetchStockTakeQtyGapWarnPercent(): Promise { + if (cached != null) return cached; + const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); + const res = await clientAuthFetch(`${base}/settings`, { method: "GET" }); + if (!res.ok) return STOCK_TAKE_QTY_GAP_WARN_PERCENT; + const rows = (await res.json()) as SettingsRow[]; + const value = rows.find((row) => row.name === STOCK_TAKE_QTY_GAP_WARN_SETTING)?.value; + cached = parseQtyGapWarnPercent(value); + return cached; +} + +export async function saveStockTakeQtyGapWarnPercent(percent: number): Promise { + const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); + const res = await clientAuthFetch( + `${base}/settings/${encodeURIComponent(STOCK_TAKE_QTY_GAP_WARN_SETTING)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: String(percent) }), + }, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `Failed to save setting: ${res.status}`); + } + cached = percent; + window.dispatchEvent(new CustomEvent(STOCK_TAKE_QTY_GAP_SETTING_EVENT, { detail: percent })); +} diff --git a/src/components/StockTakeManagement/stockTakeQtyAdornment.tsx b/src/components/StockTakeManagement/stockTakeQtyAdornment.tsx new file mode 100644 index 00000000..88e7b7c2 --- /dev/null +++ b/src/components/StockTakeManagement/stockTakeQtyAdornment.tsx @@ -0,0 +1,55 @@ +"use client"; + +import InputAdornment from "@mui/material/InputAdornment"; +import Typography from "@mui/material/Typography"; + +/** Large unit reminder inside the count field, same style as PO stock-in qty. */ +export function stockTakeQtyEndAdornment(uomShortDesc?: string | null) { + const label = uomShortDesc?.trim() ?? ""; + if (!label) return undefined; + return ( + + + {label} + + + ); +} + +/** Saved count with the same large unit label, e.g. 140 包. */ +export function StockTakeQtyWithUnit({ + qty, + uomShortDesc, +}: { + qty: string; + uomShortDesc?: string | null; +}) { + const unit = uomShortDesc?.trim() ?? ""; + return ( + <> + {qty} + {unit ? ( + + {unit} + + ) : null} + + ); +} diff --git a/src/components/StockTakeManagement/stockTakeQtyGapWarning.ts b/src/components/StockTakeManagement/stockTakeQtyGapWarning.ts new file mode 100644 index 00000000..5bdb1144 --- /dev/null +++ b/src/components/StockTakeManagement/stockTakeQtyGapWarning.ts @@ -0,0 +1,62 @@ +/** Warn when the typed count is this far from hidden on-hand, either higher or lower. Save stays allowed. */ +export const STOCK_TAKE_QTY_GAP_WARN_PERCENT = 50; + +export type StockTakeQtyGap = + | { kind: "percent"; pct: number } + | { kind: "over" }; + +/** + * On-hand used only for the gap check. Prefer the stock-take book qty (frozen for this count); + * fall back to live available qty. Callers must not render this number. + */ +export function stockTakeHiddenOnHand(detail: { + bookQty?: number | null; + availableQty?: number | null; +}): number | null { + if (detail.bookQty != null && Number.isFinite(Number(detail.bookQty))) { + return Number(detail.bookQty); + } + if (detail.availableQty != null && Number.isFinite(Number(detail.availableQty))) { + return Number(detail.availableQty); + } + return null; +} + +/** Null when the field is empty, or the gap is within the threshold. */ +export function stockTakeQtyGapWarning( + enteredRaw: string, + currentQty: number | null | undefined, + threshold = STOCK_TAKE_QTY_GAP_WARN_PERCENT, +): StockTakeQtyGap | null { + const raw = enteredRaw.trim(); + if (!raw) return null; + const entered = Number(raw); + if (!Number.isFinite(entered)) return null; + if (currentQty == null || !Number.isFinite(Number(currentQty))) return null; + const current = Number(currentQty); + if (current === 0) { + return entered === 0 ? null : { kind: "over" }; + } + const pct = (Math.abs(entered - current) / Math.abs(current)) * 100; + if (pct + 1e-9 < threshold) return null; + return { kind: "percent", pct: Math.round(pct) }; +} + +export function stockTakeQtyGapWarnText( + t: (key: string, options?: Record) => string, + enteredRaw: string, + currentQty: number | null | undefined, + threshold = STOCK_TAKE_QTY_GAP_WARN_PERCENT, +): string | null { + const gap = stockTakeQtyGapWarning(enteredRaw, currentQty, threshold); + if (!gap) return null; + if (gap.kind === "percent") { + return t("stockTakeQtyGapWarn", { + pct: gap.pct, + threshold, + }); + } + return t("stockTakeQtyGapWarnOver", { + threshold, + }); +} diff --git a/src/components/StockTakeManagement/useStockTakeQtyGapWarnPercent.ts b/src/components/StockTakeManagement/useStockTakeQtyGapWarnPercent.ts new file mode 100644 index 00000000..289214b2 --- /dev/null +++ b/src/components/StockTakeManagement/useStockTakeQtyGapWarnPercent.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { STOCK_TAKE_QTY_GAP_WARN_PERCENT } from "./stockTakeQtyGapWarning"; +import { + fetchStockTakeQtyGapWarnPercent, + STOCK_TAKE_QTY_GAP_SETTING_EVENT, +} from "./qtyGapWarnSettingClient"; + +/** Warning threshold from settings (`STOCK_TAKE.qtyGapWarnPercent`), default 50. */ +export function useStockTakeQtyGapWarnPercent(): number { + const [percent, setPercent] = useState(STOCK_TAKE_QTY_GAP_WARN_PERCENT); + + useEffect(() => { + let cancelled = false; + fetchStockTakeQtyGapWarnPercent() + .then((n) => { + if (!cancelled) setPercent(n); + }) + .catch(() => {}); + const onChange = (event: Event) => { + const next = (event as CustomEvent).detail; + if (Number.isFinite(next)) setPercent(next); + }; + window.addEventListener(STOCK_TAKE_QTY_GAP_SETTING_EVENT, onChange); + return () => { + cancelled = true; + window.removeEventListener(STOCK_TAKE_QTY_GAP_SETTING_EVENT, onChange); + }; + }, []); + + return percent; +} diff --git a/src/i18n/en/stockTake.json b/src/i18n/en/stockTake.json index faaef94f..64f04baf 100644 --- a/src/i18n/en/stockTake.json +++ b/src/i18n/en/stockTake.json @@ -116,7 +116,7 @@ "Stock Take Management": "Stock Take Management", "Stock Take Qty": "Stock Take Qty", "Stock Take Qty Data and Variance Analysis": "Stock Take Qty Data and Variance Analysis", - "Stock Take Qty(include Bad Qty)= Available Qty": "Stock Take Qty(include Bad Qty)= Available Qty", + "Stock Take Qty(include Bad Qty)= Available Qty": "Stock Take Qty", "Stock Take Round": "Stock Take Round", "Stock Take Section": "Stock Take Section", "Stock Take Section (can use , to search multiple sections)": "Stock Take Section (can use , to search multiple sections)", @@ -173,6 +173,11 @@ "sections unit": "area(s)", "selected stock take qty": "selected stock take qty", "start time": "start time", + "qtyGapWarnPercent": "Qty variance warning %", + "qtyGapWarnPercentSaved": "Qty gap warning percent saved", + "qtyGapWarnPercentInvalid": "Enter a whole number from 0 to 1000", + "stockTakeQtyGapWarn": "Please check the entry. The count differs from current stock by {{pct}}%.", + "stockTakeQtyGapWarnOver": "Please check the entry. The count differs from current stock by more than {{threshold}}%.", "stockTaking": "Stock taking", "stock_take": "Stock take", "variance Percentage": "variance Percentage" diff --git a/src/i18n/zh/stockTake.json b/src/i18n/zh/stockTake.json index aad4cd7a..53745a40 100644 --- a/src/i18n/zh/stockTake.json +++ b/src/i18n/zh/stockTake.json @@ -53,7 +53,7 @@ "Warehouse Location": "倉庫位置", "Item-lotNo-ExpiryDate": "貨品-批號-到期日", "UOM": "單位", - "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數= 可用數", + "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數", "Record Status": "盤點狀態", "No data": "沒有數據", "Difference": "差異", @@ -61,6 +61,11 @@ "Second": "第二次", "Approver Input": "審核員輸入", "Stock Take Qty": "盤點數", + "qtyGapWarnPercent": "出入差異警告%", + "qtyGapWarnPercentSaved": "出入差異警告%已保存", + "qtyGapWarnPercentInvalid": "請輸入 0 至 1000 的整數", + "stockTakeQtyGapWarn": "請小心輸入查看,盤點數與現時倉存有 {{pct}}% 的出入", + "stockTakeQtyGapWarnOver": "請小心輸入查看,盤點數與現時倉存的出入已超過 {{threshold}}%", "Bad Qty": "不良數量", "selected stock take qty": "已選擇盤點數量", "book qty": "帳面庫存",