| @@ -4,6 +4,7 @@ | |||||
| "private": true, | "private": true, | ||||
| "scripts": { | "scripts": { | ||||
| "dev": "next dev", | "dev": "next dev", | ||||
| "warmup": "node scripts/warmup-menu.mjs", | |||||
| "build": "next build", | "build": "next build", | ||||
| "start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start", | "start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start", | ||||
| "lint": "next lint", | "lint": "next lint", | ||||
| @@ -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)" : ""}`, | |||||
| ); | |||||
| } | |||||
| @@ -24,6 +24,8 @@ export interface InventoryLotDetailResponse { | |||||
| holdQty: number; | holdQty: number; | ||||
| availableQty: number; | availableQty: number; | ||||
| uom: string; | uom: string; | ||||
| /** Stock UoM short label (e.g. 包) for the count input. */ | |||||
| uomShortDesc?: string | null; | |||||
| warehouseCode: string; | warehouseCode: string; | ||||
| warehouseName: string; | warehouseName: string; | ||||
| warehouseSlot: string; | warehouseSlot: string; | ||||
| @@ -33,6 +33,10 @@ import { | |||||
| batchSaveApproverStockTakeRecords, | batchSaveApproverStockTakeRecords, | ||||
| updateStockTakeRecordStatusToNotMatch, | updateStockTakeRecordStatusToNotMatch, | ||||
| } from "@/app/api/stockTake/actions"; | } 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 { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| @@ -52,6 +56,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| onSnackbar, | onSnackbar, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(["stockTake", "common"]); | const { t } = useTranslation(["stockTake", "common"]); | ||||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| @@ -61,6 +66,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| // 每个记录的选择状态,key 为 detail.id | // 每个记录的选择状态,key 为 detail.id | ||||
| const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | ||||
| const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | ||||
| const [gapCheckOpen, setGapCheckOpen] = useState<Record<string, boolean>>({}); | |||||
| const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({}); | const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({}); | ||||
| const [saving, setSaving] = useState(false); | const [saving, setSaving] = useState(false); | ||||
| const [batchSaving, setBatchSaving] = useState(false); | const [batchSaving, setBatchSaving] = useState(false); | ||||
| @@ -255,7 +261,19 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| selectedSession.stockTakeId | 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; | const goodQty = finalQty - finalBadQty; | ||||
| @@ -292,7 +310,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| } finally { | } finally { | ||||
| setSaving(false); | 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) => { | const handleUpdateStatusToNotMatch = useCallback(async (detail: InventoryLotDetailResponse) => { | ||||
| if (!detail.stockTakeRecordId) { | if (!detail.stockTakeRecordId) { | ||||
| @@ -598,17 +616,28 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| value={approverQty[detail.id] || ""} | 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 })} | onChange={(e) => setApproverQty({ ...approverQty, [detail.id]: e.target.value })} | ||||
| InputProps={{ | |||||
| endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), | |||||
| }} | |||||
| sx={{ | sx={{ | ||||
| width: 130, | |||||
| minWidth: 130, | |||||
| width: 168, | |||||
| minWidth: 168, | |||||
| '& .MuiInputBase-input': { | '& .MuiInputBase-input': { | ||||
| height: '1.4375em', | height: '1.4375em', | ||||
| padding: '4px 8px' | padding: '4px 8px' | ||||
| } | } | ||||
| }} | }} | ||||
| placeholder={t("Stock Take Qty") } | placeholder={t("Stock Take Qty") } | ||||
| disabled={selection !== "approver"} | |||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| @@ -631,6 +660,12 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| = {formatNumber(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} | = {formatNumber(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} | ||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| <StockTakeQtyGapHint | |||||
| open={!!gapCheckOpen[`${detail.id}:approver`]} | |||||
| entered={approverQty[detail.id] || ""} | |||||
| currentQty={stockTakeHiddenOnHand(detail)} | |||||
| threshold={qtyGapWarnPercent} | |||||
| /> | |||||
| )} | )} | ||||
| {(() => { | {(() => { | ||||
| @@ -53,6 +53,10 @@ import { | |||||
| type ApproverInventoryLotDetailsQuery, | type ApproverInventoryLotDetailsQuery, | ||||
| } from "@/app/api/stockTake/actions"; | } from "@/app/api/stockTake/actions"; | ||||
| import { fetchStockTakeSections } from "@/app/api/warehouse/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 { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| @@ -226,6 +230,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| onSnackbar, | onSnackbar, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(["stockTake", "common"]); | const { t } = useTranslation(["stockTake", "common"]); | ||||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| @@ -235,6 +240,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| const [searchVarianceFilterStrict, setSearchVarianceFilterStrict] = useState(false); | const [searchVarianceFilterStrict, setSearchVarianceFilterStrict] = useState(false); | ||||
| const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | ||||
| const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | ||||
| const [gapCheckOpen, setGapCheckOpen] = useState<Record<string, boolean>>({}); | |||||
| const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({}); | const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({}); | ||||
| const [saving, setSaving] = useState(false); | const [saving, setSaving] = useState(false); | ||||
| const [batchSaving, setBatchSaving] = useState(false); | const [batchSaving, setBatchSaving] = useState(false); | ||||
| @@ -654,7 +660,19 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| try { | try { | ||||
| await saveApproverStockTakeRecord(request, selectedSession.stockTakeId); | 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) => | setInventoryLotDetails((prev) => | ||||
| prev.map((d) => | prev.map((d) => | ||||
| @@ -688,7 +706,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| setSaving(false); | setSaving(false); | ||||
| } | } | ||||
| }, | }, | ||||
| [selectedSession, currentUserId, qtySelection, approverQty, approverBadQty, t, onSnackbar, mode] | |||||
| [selectedSession, currentUserId, qtySelection, approverQty, approverBadQty, t, onSnackbar, mode, qtyGapWarnPercent] | |||||
| ); | ); | ||||
| const handleUpdateStatusToNotMatch = useCallback( | const handleUpdateStatusToNotMatch = useCallback( | ||||
| @@ -934,10 +952,13 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| flex: 1, | flex: 1, | ||||
| sortable: false, | sortable: false, | ||||
| renderCell: (params) => ( | renderCell: (params) => ( | ||||
| <Stack spacing={0.5} sx={{ lineHeight: 1.5 }}> | |||||
| <Box> | |||||
| <Stack spacing={0.5} sx={{ lineHeight: 1.5, py: 0.5 }}> | |||||
| <Typography | |||||
| component="div" | |||||
| sx={{ fontWeight: 800, fontSize: "1.15rem", lineHeight: 1.3, color: "text.primary" }} | |||||
| > | |||||
| {params.row.itemCode || "-"} {params.row.itemName || "-"} | {params.row.itemCode || "-"} {params.row.itemName || "-"} | ||||
| </Box> | |||||
| </Typography> | |||||
| <Box>{params.row.lotNo || "-"}</Box> | <Box>{params.row.lotNo || "-"}</Box> | ||||
| <Box> | <Box> | ||||
| {params.row.expiryDate | {params.row.expiryDate | ||||
| @@ -1052,12 +1073,12 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| /> | /> | ||||
| <Typography variant="body2" component="span"> | <Typography variant="body2" component="span"> | ||||
| {t("First")}:{" "} | {t("First")}:{" "} | ||||
| {formatNumber( | |||||
| (detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0) | |||||
| )}{" "} | |||||
| {/* | |||||
| = {formatNumber(detail.firstStockTakeQty ?? 0)} | |||||
| */} | |||||
| <StockTakeQtyWithUnit | |||||
| qty={formatNumber( | |||||
| (detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0) | |||||
| )} | |||||
| uomShortDesc={detail.uomShortDesc} | |||||
| /> | |||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| )} | )} | ||||
| @@ -1077,12 +1098,12 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| /> | /> | ||||
| <Typography variant="body2" component="span"> | <Typography variant="body2" component="span"> | ||||
| {t("Second")}:{" "} | {t("Second")}:{" "} | ||||
| {formatNumber( | |||||
| (detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0) | |||||
| )}{" "} | |||||
| {/* | |||||
| = {formatNumber(detail.secondStockTakeQty ?? 0)} | |||||
| */} | |||||
| <StockTakeQtyWithUnit | |||||
| qty={formatNumber( | |||||
| (detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0) | |||||
| )} | |||||
| uomShortDesc={detail.uomShortDesc} | |||||
| /> | |||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| @@ -1108,6 +1129,18 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| value={approverQty[detail.id] || ""} | 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} | onKeyDown={blockNonIntegerKeys} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| const clean = sanitizeIntegerInput(e.target.value); | const clean = sanitizeIntegerInput(e.target.value); | ||||
| @@ -1116,18 +1149,29 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| [detail.id]: clean, | [detail.id]: clean, | ||||
| }); | }); | ||||
| }} | }} | ||||
| InputProps={{ | |||||
| endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), | |||||
| }} | |||||
| sx={{ | sx={{ | ||||
| width: 72, | |||||
| minWidth: 72, | |||||
| width: 120, | |||||
| minWidth: 120, | |||||
| "& .MuiInputBase-input": { | "& .MuiInputBase-input": { | ||||
| py: 0.5, | py: 0.5, | ||||
| px: 1, | px: 1, | ||||
| }, | }, | ||||
| }} | }} | ||||
| // placeholder={t("Stock Take Qty")} | // placeholder={t("Stock Take Qty")} | ||||
| disabled={mode === "approved" || selection !== "approver"} | |||||
| disabled={mode === "approved"} | |||||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| /> | /> | ||||
| <Box sx={{ flexBasis: "100%" }}> | |||||
| <StockTakeQtyGapHint | |||||
| open={selection === "approver" && !!gapCheckOpen[`${detail.id}:approver`]} | |||||
| entered={approverQty[detail.id] || ""} | |||||
| currentQty={stockTakeHiddenOnHand(detail)} | |||||
| threshold={qtyGapWarnPercent} | |||||
| /> | |||||
| </Box> | |||||
| {/* | {/* | ||||
| <Typography variant="body2" component="span" sx={{ ml: 0.5 }}> | <Typography variant="body2" component="span" sx={{ ml: 0.5 }}> | ||||
| = {formatNumber(approverGoodQty)} | = {formatNumber(approverGoodQty)} | ||||
| @@ -1152,7 +1196,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| <Stack spacing={0.75}> | <Stack spacing={0.75}> | ||||
| {summaryLine( | {summaryLine( | ||||
| `${t("Selected Qty")}:`, | `${t("Selected Qty")}:`, | ||||
| formatNumber(selectedQty) | |||||
| [formatNumber(selectedQty), detail.uomShortDesc?.trim()].filter(Boolean).join(" ") | |||||
| )} | )} | ||||
| {summaryLine(`${t("Book Qty")}:`, formatNumber(bookQty))} | {summaryLine(`${t("Book Qty")}:`, formatNumber(bookQty))} | ||||
| {summaryLine( | {summaryLine( | ||||
| @@ -30,6 +30,10 @@ import { | |||||
| getInventoryLotDetailsBySectionNotMatch | getInventoryLotDetailsBySectionNotMatch | ||||
| } from "@/app/api/stockTake/actions"; | } from "@/app/api/stockTake/actions"; | ||||
| import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; | 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 PickerBatchSaveFab from "./PickerBatchSaveFab"; | ||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| @@ -52,6 +56,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| onSnackbar, | onSnackbar, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(["stockTake", "common"]); | const { t } = useTranslation(["stockTake", "common"]); | ||||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| @@ -65,6 +70,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| remark: string; | remark: string; | ||||
| }>>({}); | }>>({}); | ||||
| const [saving, setSaving] = useState(false); | const [saving, setSaving] = useState(false); | ||||
| const [gapCheckOpen, setGapCheckOpen] = useState<Record<string, boolean>>({}); | |||||
| const [batchSaving, setBatchSaving] = useState(false); | const [batchSaving, setBatchSaving] = useState(false); | ||||
| const [shortcutInput, setShortcutInput] = useState<string>(""); | const [shortcutInput, setShortcutInput] = useState<string>(""); | ||||
| const [page, setPage] = useState(0); | const [page, setPage] = useState(0); | ||||
| @@ -246,7 +252,19 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| currentUserId | 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; | const savedId = result?.id ?? detail.stockTakeRecordId; | ||||
| setInventoryLotDetails((prev) => | setInventoryLotDetails((prev) => | ||||
| @@ -286,7 +304,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| } finally { | } finally { | ||||
| setSaving(false); | 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 => { | const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | ||||
| if (selectedSession?.status?.toLowerCase() === "completed") { | if (selectedSession?.status?.toLowerCase() === "completed") { | ||||
| @@ -492,7 +510,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | <TableCell>{t("Warehouse Location")}</TableCell> | ||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | ||||
| <TableCell>{t("UOM")}</TableCell> | <TableCell>{t("UOM")}</TableCell> | ||||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||||
| <TableCell sx={{ width: 250, minWidth: 250 }}>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||||
| <TableCell>{t("Action")}</TableCell> | <TableCell>{t("Action")}</TableCell> | ||||
| {/*<TableCell>{t("Remark")}</TableCell>*/} | {/*<TableCell>{t("Remark")}</TableCell>*/} | ||||
| <TableCell>{t("Record Status")}</TableCell> | <TableCell>{t("Record Status")}</TableCell> | ||||
| @@ -520,28 +538,40 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| <TableRow key={detail.id}> | <TableRow key={detail.id}> | ||||
| <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell> | <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell> | ||||
| <TableCell sx={{ | <TableCell sx={{ | ||||
| maxWidth: 150, | |||||
| maxWidth: 280, | |||||
| wordBreak: 'break-word', | wordBreak: 'break-word', | ||||
| whiteSpace: 'normal', | whiteSpace: 'normal', | ||||
| lineHeight: 1.5 | lineHeight: 1.5 | ||||
| }}> | }}> | ||||
| <Stack spacing={0.5}> | <Stack spacing={0.5}> | ||||
| <Box>{detail.itemCode || "-"} {detail.itemName || "-"}</Box> | |||||
| <Typography | |||||
| component="div" | |||||
| sx={{ fontWeight: 800, fontSize: "1.15rem", lineHeight: 1.3, color: "text.primary" }} | |||||
| > | |||||
| {detail.itemCode || "-"} {detail.itemName || "-"} | |||||
| </Typography> | |||||
| <Box>{detail.lotNo || "-"}</Box> | <Box>{detail.lotNo || "-"}</Box> | ||||
| <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | ||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | <TableCell>{detail.uom || "-"}</TableCell> | ||||
| <TableCell sx={{ minWidth: 300 }}> | |||||
| <TableCell sx={{ width: 250, minWidth: 250 }}> | |||||
| <Stack spacing={1}> | <Stack spacing={1}> | ||||
| {/* First */} | {/* First */} | ||||
| {!submitDisabled && isFirstSubmit ? ( | {!submitDisabled && isFirstSubmit ? ( | ||||
| <Stack spacing={0.5} alignItems="flex-start"> | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| <Typography variant="body2">{t("First")}:</Typography> | <Typography variant="body2">{t("First")}:</Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| value={inputs.firstQty} | value={inputs.firstQty} | ||||
| onFocus={() => | |||||
| setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:first`]: false })) | |||||
| } | |||||
| onBlur={() => | |||||
| setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:first`]: true })) | |||||
| } | |||||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| onKeyDown={blockNonIntegerKeys} | onKeyDown={blockNonIntegerKeys} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| @@ -553,9 +583,12 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), firstQty: val } | [detail.id]: { ...(prev[detail.id] ?? defaultInputs), firstQty: val } | ||||
| })); | })); | ||||
| }} | }} | ||||
| InputProps={{ | |||||
| endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), | |||||
| }} | |||||
| sx={{ | sx={{ | ||||
| width: 130, | |||||
| minWidth: 130, | |||||
| width: 148, | |||||
| minWidth: 148, | |||||
| "& .MuiInputBase-input": { | "& .MuiInputBase-input": { | ||||
| height: "1.4375em", | height: "1.4375em", | ||||
| padding: "4px 8px", | padding: "4px 8px", | ||||
| @@ -590,28 +623,39 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| placeholder={t("Bad Qty")} | placeholder={t("Bad Qty")} | ||||
| /> | /> | ||||
| */} | */} | ||||
| <Typography variant="body2"> | |||||
| = {formatNumber(parseFloat(inputs.firstQty || "0") - parseFloat(inputs.firstBadQty || "0"))} | |||||
| </Typography> | |||||
| </Stack> | |||||
| <StockTakeQtyGapHint | |||||
| open={!!gapCheckOpen[`${detail.id}:first`]} | |||||
| entered={inputs.firstQty} | |||||
| currentQty={stockTakeHiddenOnHand(detail)} | |||||
| threshold={qtyGapWarnPercent} | |||||
| /> | |||||
| </Stack> | </Stack> | ||||
| ) : detail.firstStockTakeQty != null ? ( | ) : detail.firstStockTakeQty != null ? ( | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {t("First")}:{" "} | {t("First")}:{" "} | ||||
| {formatNumber((detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0))}{" "} | |||||
| {/* ({formatNumber(detail.firstBadQty ?? 0)}) */} | |||||
| ={" "} | |||||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | |||||
| <StockTakeQtyWithUnit | |||||
| qty={formatNumber(detail.firstStockTakeQty ?? 0)} | |||||
| uomShortDesc={detail.uomShortDesc} | |||||
| /> | |||||
| </Typography> | </Typography> | ||||
| ) : null} | ) : null} | ||||
| {/* Second */} | {/* Second */} | ||||
| {!submitDisabled && isSecondSubmit ? ( | {!submitDisabled && isSecondSubmit ? ( | ||||
| <Stack spacing={0.5} alignItems="flex-start"> | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| <Typography variant="body2">{t("Second")}:</Typography> | <Typography variant="body2">{t("Second")}:</Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| value={inputs.secondQty} | value={inputs.secondQty} | ||||
| onFocus={() => | |||||
| setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:second`]: false })) | |||||
| } | |||||
| onBlur={() => | |||||
| setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:second`]: true })) | |||||
| } | |||||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| onKeyDown={blockNonIntegerKeys} | onKeyDown={blockNonIntegerKeys} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| @@ -623,9 +667,12 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), secondQty: clean } | [detail.id]: { ...(prev[detail.id] ?? defaultInputs), secondQty: clean } | ||||
| })); | })); | ||||
| }} | }} | ||||
| InputProps={{ | |||||
| endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), | |||||
| }} | |||||
| sx={{ | sx={{ | ||||
| width: 130, | |||||
| minWidth: 130, | |||||
| width: 148, | |||||
| minWidth: 148, | |||||
| "& .MuiInputBase-input": { | "& .MuiInputBase-input": { | ||||
| height: "1.4375em", | height: "1.4375em", | ||||
| padding: "4px 8px", | padding: "4px 8px", | ||||
| @@ -660,17 +707,21 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| placeholder={t("Bad Qty")} | placeholder={t("Bad Qty")} | ||||
| /> | /> | ||||
| */} | */} | ||||
| <Typography variant="body2"> | |||||
| = {formatNumber(parseFloat(inputs.secondQty || "0") - parseFloat(inputs.secondBadQty || "0"))} | |||||
| </Typography> | |||||
| </Stack> | |||||
| <StockTakeQtyGapHint | |||||
| open={!!gapCheckOpen[`${detail.id}:second`]} | |||||
| entered={inputs.secondQty} | |||||
| currentQty={stockTakeHiddenOnHand(detail)} | |||||
| threshold={qtyGapWarnPercent} | |||||
| /> | |||||
| </Stack> | </Stack> | ||||
| ) : detail.secondStockTakeQty != null ? ( | ) : detail.secondStockTakeQty != null ? ( | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {t("Second")}:{" "} | {t("Second")}:{" "} | ||||
| {formatNumber((detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0))}{" "} | |||||
| {/* ({formatNumber(detail.secondBadQty ?? 0)}) */} | |||||
| ={" "} | |||||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | |||||
| <StockTakeQtyWithUnit | |||||
| qty={formatNumber(detail.secondStockTakeQty ?? 0)} | |||||
| uomShortDesc={detail.uomShortDesc} | |||||
| /> | |||||
| </Typography> | </Typography> | ||||
| ) : null} | ) : null} | ||||
| @@ -35,6 +35,10 @@ import { | |||||
| batchSavePickerStockTakeInputs, | batchSavePickerStockTakeInputs, | ||||
| } from "@/app/api/stockTake/actions"; | } from "@/app/api/stockTake/actions"; | ||||
| import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; | 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 PickerBatchSaveFab from "./PickerBatchSaveFab"; | ||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| @@ -57,6 +61,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| onSnackbar, | onSnackbar, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(["stockTake", "common"]); | const { t } = useTranslation(["stockTake", "common"]); | ||||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| @@ -72,6 +77,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| const [savingRecordId, setSavingRecordId] = useState<number | null>(null); | const [savingRecordId, setSavingRecordId] = useState<number | null>(null); | ||||
| const [remark, setRemark] = useState<string>(""); | const [remark, setRemark] = useState<string>(""); | ||||
| const [saving, setSaving] = useState(false); | const [saving, setSaving] = useState(false); | ||||
| /** Qty fields the user has left, or just saved. Warning stays hidden while typing. */ | |||||
| const [gapCheckOpen, setGapCheckOpen] = useState<Record<string, boolean>>({}); | |||||
| const [batchSaving, setBatchSaving] = useState(false); | const [batchSaving, setBatchSaving] = useState(false); | ||||
| const [shortcutInput, setShortcutInput] = useState<string>(""); | const [shortcutInput, setShortcutInput] = useState<string>(""); | ||||
| const [page, setPage] = useState(0); | const [page, setPage] = useState(0); | ||||
| @@ -240,7 +247,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| await saveStockTakeRecord(request, selectedSession.stockTakeId, currentUserId); | 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 }); | //await loadDetails(page, pageSize, { silent: true }); | ||||
| setInventoryLotDetails((prev) => | setInventoryLotDetails((prev) => | ||||
| @@ -286,6 +305,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| t, | t, | ||||
| currentUserId, | currentUserId, | ||||
| onSnackbar, | onSnackbar, | ||||
| qtyGapWarnPercent, | |||||
| ] | ] | ||||
| ); | ); | ||||
| @@ -540,7 +560,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | <TableCell>{t("Warehouse Location")}</TableCell> | ||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | ||||
| <TableCell>{t("UOM")}</TableCell> | <TableCell>{t("UOM")}</TableCell> | ||||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||||
| <TableCell sx={{ width: 250, minWidth: 250 }}>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||||
| <TableCell>{t("Action")}</TableCell> | <TableCell>{t("Action")}</TableCell> | ||||
| {/*<TableCell>{t("Remark")}</TableCell>*/} | {/*<TableCell>{t("Remark")}</TableCell>*/} | ||||
| <TableCell>{t("Record Status")}</TableCell> | <TableCell>{t("Record Status")}</TableCell> | ||||
| @@ -572,16 +592,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell | <TableCell | ||||
| sx={{ | sx={{ | ||||
| maxWidth: 150, | |||||
| maxWidth: 280, | |||||
| wordBreak: "break-word", | wordBreak: "break-word", | ||||
| whiteSpace: "normal", | whiteSpace: "normal", | ||||
| lineHeight: 1.5, | lineHeight: 1.5, | ||||
| }} | }} | ||||
| > | > | ||||
| <Stack spacing={0.5}> | <Stack spacing={0.5}> | ||||
| <Box> | |||||
| <Typography | |||||
| component="div" | |||||
| sx={{ fontWeight: 800, fontSize: "1.15rem", lineHeight: 1.3, color: "text.primary" }} | |||||
| > | |||||
| {detail.itemCode || "-"} {detail.itemName || "-"} | {detail.itemCode || "-"} {detail.itemName || "-"} | ||||
| </Box> | |||||
| </Typography> | |||||
| <Box>{detail.lotNo || "-"}</Box> | <Box>{detail.lotNo || "-"}</Box> | ||||
| <Box> | <Box> | ||||
| {detail.expiryDate | {detail.expiryDate | ||||
| @@ -592,16 +615,23 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | <TableCell>{detail.uom || "-"}</TableCell> | ||||
| {/* Qty + Bad Qty 合并显示/输入 */} | {/* Qty + Bad Qty 合并显示/输入 */} | ||||
| <TableCell sx={{ minWidth: 300 }}> | |||||
| <TableCell sx={{ width: 250, minWidth: 250 }}> | |||||
| <Stack spacing={1}> | <Stack spacing={1}> | ||||
| {/* First */} | {/* First */} | ||||
| {!submitDisabled && isFirstSubmit ? ( | {!submitDisabled && isFirstSubmit ? ( | ||||
| <Stack spacing={0.5} alignItems="flex-start"> | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| <Typography variant="body2">{t("First")}:</Typography> | <Typography variant="body2">{t("First")}:</Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| value={recordInputs[detail.id]?.firstQty || ""} | value={recordInputs[detail.id]?.firstQty || ""} | ||||
| onFocus={() => | |||||
| setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:first`]: false })) | |||||
| } | |||||
| onBlur={() => | |||||
| setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:first`]: true })) | |||||
| } | |||||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| onKeyDown={blockNonIntegerKeys} | onKeyDown={blockNonIntegerKeys} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| @@ -610,9 +640,12 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| if (val.includes("-")) return; | if (val.includes("-")) return; | ||||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstQty: val } })); | setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstQty: val } })); | ||||
| }} | }} | ||||
| InputProps={{ | |||||
| endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), | |||||
| }} | |||||
| sx={{ | sx={{ | ||||
| width: 130, | |||||
| minWidth: 130, | |||||
| width: 148, | |||||
| minWidth: 148, | |||||
| "& .MuiInputBase-input": { | "& .MuiInputBase-input": { | ||||
| height: "1.4375em", | height: "1.4375em", | ||||
| padding: "4px 8px", | padding: "4px 8px", | ||||
| @@ -652,40 +685,39 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| placeholder={t("Bad Qty")} | placeholder={t("Bad Qty")} | ||||
| /> | /> | ||||
| */} | */} | ||||
| <Typography variant="body2"> | |||||
| = | |||||
| {formatNumber( | |||||
| parseFloat(recordInputs[detail.id]?.firstQty || "0") - | |||||
| parseFloat(recordInputs[detail.id]?.firstBadQty || "0") | |||||
| )} | |||||
| </Typography> | |||||
| </Stack> | |||||
| <StockTakeQtyGapHint | |||||
| open={!!gapCheckOpen[`${detail.id}:first`]} | |||||
| entered={recordInputs[detail.id]?.firstQty || ""} | |||||
| currentQty={stockTakeHiddenOnHand(detail)} | |||||
| threshold={qtyGapWarnPercent} | |||||
| /> | |||||
| </Stack> | </Stack> | ||||
| ) : detail.firstStockTakeQty != null ? ( | ) : detail.firstStockTakeQty != null ? ( | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {t("First")}:{" "} | {t("First")}:{" "} | ||||
| {formatNumber( | |||||
| (detail.firstStockTakeQty ?? 0) + | |||||
| (detail.firstBadQty ?? 0) | |||||
| )}{" "} | |||||
| {/* | |||||
| ( | |||||
| {formatNumber( | |||||
| detail.firstBadQty ?? 0 | |||||
| )} | |||||
| */} | |||||
| ={" "} | |||||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | |||||
| <StockTakeQtyWithUnit | |||||
| qty={formatNumber(detail.firstStockTakeQty ?? 0)} | |||||
| uomShortDesc={detail.uomShortDesc} | |||||
| /> | |||||
| </Typography> | </Typography> | ||||
| ) : null} | ) : null} | ||||
| {/* Second */} | {/* Second */} | ||||
| {!submitDisabled && isSecondSubmit ? ( | {!submitDisabled && isSecondSubmit ? ( | ||||
| <Stack spacing={0.5} alignItems="flex-start"> | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| <Typography variant="body2">{t("Second")}:</Typography> | <Typography variant="body2">{t("Second")}:</Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| value={recordInputs[detail.id]?.secondQty || ""} | value={recordInputs[detail.id]?.secondQty || ""} | ||||
| onFocus={() => | |||||
| setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:second`]: false })) | |||||
| } | |||||
| onBlur={() => | |||||
| setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:second`]: true })) | |||||
| } | |||||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| onKeyDown={blockNonIntegerKeys} | onKeyDown={blockNonIntegerKeys} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| @@ -694,9 +726,12 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| if (val.includes("-")) return; | if (val.includes("-")) return; | ||||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondQty: val } })); | setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondQty: val } })); | ||||
| }} | }} | ||||
| InputProps={{ | |||||
| endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), | |||||
| }} | |||||
| sx={{ | sx={{ | ||||
| width: 130, | |||||
| minWidth: 130, | |||||
| width: 148, | |||||
| minWidth: 148, | |||||
| "& .MuiInputBase-input": { | "& .MuiInputBase-input": { | ||||
| height: "1.4375em", | height: "1.4375em", | ||||
| padding: "4px 8px", | padding: "4px 8px", | ||||
| @@ -728,29 +763,21 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| placeholder={t("Bad Qty")} | placeholder={t("Bad Qty")} | ||||
| /> | /> | ||||
| */} | */} | ||||
| <Typography variant="body2"> | |||||
| = | |||||
| {formatNumber( | |||||
| parseFloat(recordInputs[detail.id]?.secondQty || "0") - | |||||
| parseFloat(recordInputs[detail.id]?.secondBadQty || "0") | |||||
| )} | |||||
| </Typography> | |||||
| </Stack> | |||||
| <StockTakeQtyGapHint | |||||
| open={!!gapCheckOpen[`${detail.id}:second`]} | |||||
| entered={recordInputs[detail.id]?.secondQty || ""} | |||||
| currentQty={stockTakeHiddenOnHand(detail)} | |||||
| threshold={qtyGapWarnPercent} | |||||
| /> | |||||
| </Stack> | </Stack> | ||||
| ) : detail.secondStockTakeQty != null ? ( | ) : detail.secondStockTakeQty != null ? ( | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {t("Second")}:{" "} | {t("Second")}:{" "} | ||||
| {formatNumber( | |||||
| (detail.secondStockTakeQty ?? 0) + | |||||
| (detail.secondBadQty ?? 0) | |||||
| )}{" "} | |||||
| {/* | |||||
| ( | |||||
| {formatNumber( | |||||
| detail.secondBadQty ?? 0 | |||||
| )} | |||||
| */} | |||||
| ={" "} | |||||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | |||||
| <StockTakeQtyWithUnit | |||||
| qty={formatNumber(detail.secondStockTakeQty ?? 0)} | |||||
| uomShortDesc={detail.uomShortDesc} | |||||
| /> | |||||
| </Typography> | </Typography> | ||||
| ) : null} | ) : null} | ||||
| @@ -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 ( | |||||
| <Typography | |||||
| variant="caption" | |||||
| component="div" | |||||
| role="status" | |||||
| sx={{ | |||||
| color: "warning.dark", | |||||
| fontWeight: 700, | |||||
| lineHeight: 1.35, | |||||
| maxWidth: 420, | |||||
| }} | |||||
| > | |||||
| {text} | |||||
| </Typography> | |||||
| ); | |||||
| } | |||||
| @@ -1,14 +1,23 @@ | |||||
| "use client"; | "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 { useTranslation } from "react-i18next"; | ||||
| import { AUTH } from "@/authorities"; | |||||
| import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import { AllPickedStockTakeListReponse, getLatestApproverStockTakeHeader } from "@/app/api/stockTake/actions"; | import { AllPickedStockTakeListReponse, getLatestApproverStockTakeHeader } from "@/app/api/stockTake/actions"; | ||||
| import PickerCardList from "./PickerCardList"; | import PickerCardList from "./PickerCardList"; | ||||
| import type { PickerCardListFilters } from "./PickerCardList"; | import type { PickerCardListFilters } from "./PickerCardList"; | ||||
| import PickerStockTake from "./PickerStockTake"; | import PickerStockTake from "./PickerStockTake"; | ||||
| import PickerReStockTake from "./PickerReStockTake"; | import PickerReStockTake from "./PickerReStockTake"; | ||||
| import ApproverStockTakeAll from "./ApproverStockTakeAll"; | 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"; | type ViewScope = "picker" | "approver-all"; | ||||
| const DEFAULT_PICKER_CARD_LIST_FILTERS: PickerCardListFilters = { | const DEFAULT_PICKER_CARD_LIST_FILTERS: PickerCardListFilters = { | ||||
| @@ -21,6 +30,14 @@ const DEFAULT_PICKER_CARD_LIST_FILTERS: PickerCardListFilters = { | |||||
| const StockTakeTab: React.FC = () => { | const StockTakeTab: React.FC = () => { | ||||
| const { t } = useTranslation(["stockTake", "common"]); | 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 [tabValue, setTabValue] = useState(0); | ||||
| const [selectedSession, setSelectedSession] = useState<AllPickedStockTakeListReponse | null>(null); | const [selectedSession, setSelectedSession] = useState<AllPickedStockTakeListReponse | null>(null); | ||||
| const [viewMode, setViewMode] = useState<"details" | "reStockTake">("details"); | 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(() => { | useEffect(() => { | ||||
| if (tabValue !== 1 && tabValue !== 2) return; | if (tabValue !== 1 && tabValue !== 2) return; | ||||
| setApproverLoading(true); | setApproverLoading(true); | ||||
| @@ -115,6 +157,46 @@ const StockTakeTab: React.FC = () => { | |||||
| return ( | return ( | ||||
| <Box> | <Box> | ||||
| {isAdmin && ( | |||||
| <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}> | |||||
| <Typography | |||||
| component="label" | |||||
| htmlFor="stock-take-qty-gap-warn" | |||||
| sx={{ m: 0, height: 40, fontSize: 18, fontWeight: 500, lineHeight: "40px" }} | |||||
| > | |||||
| {t("qtyGapWarnPercent")} | |||||
| </Typography> | |||||
| <TextField | |||||
| id="stock-take-qty-gap-warn" | |||||
| size="small" | |||||
| type="number" | |||||
| value={qtyGapDraft} | |||||
| onChange={(e) => 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", | |||||
| }, | |||||
| }} | |||||
| /> | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| disabled={qtyGapSaving} | |||||
| onClick={saveQtyGapWarnPercent} | |||||
| sx={{ height: 40 }} | |||||
| > | |||||
| {t("Save")} | |||||
| </Button> | |||||
| </Stack> | |||||
| )} | |||||
| <Tabs | <Tabs | ||||
| value={tabValue} | value={tabValue} | ||||
| onChange={(e, newValue) => { | onChange={(e, newValue) => { | ||||
| @@ -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<number> { | |||||
| 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<void> { | |||||
| 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 })); | |||||
| } | |||||
| @@ -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 ( | |||||
| <InputAdornment position="end" sx={{ ml: 0.25, mr: 0.25, maxHeight: "none" }}> | |||||
| <Typography | |||||
| component="span" | |||||
| sx={{ | |||||
| fontWeight: 800, | |||||
| fontSize: "1.05rem", | |||||
| color: "primary.main", | |||||
| lineHeight: 1, | |||||
| }} | |||||
| > | |||||
| {label} | |||||
| </Typography> | |||||
| </InputAdornment> | |||||
| ); | |||||
| } | |||||
| /** 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 ? ( | |||||
| <Typography | |||||
| component="span" | |||||
| sx={{ | |||||
| fontWeight: 800, | |||||
| fontSize: "1.05rem", | |||||
| color: "primary.main", | |||||
| lineHeight: 1, | |||||
| ml: 0.5, | |||||
| }} | |||||
| > | |||||
| {unit} | |||||
| </Typography> | |||||
| ) : null} | |||||
| </> | |||||
| ); | |||||
| } | |||||
| @@ -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, unknown>) => 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, | |||||
| }); | |||||
| } | |||||
| @@ -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<number>).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; | |||||
| } | |||||
| @@ -116,7 +116,7 @@ | |||||
| "Stock Take Management": "Stock Take Management", | "Stock Take Management": "Stock Take Management", | ||||
| "Stock Take Qty": "Stock Take Qty", | "Stock Take Qty": "Stock Take Qty", | ||||
| "Stock Take Qty Data and Variance Analysis": "Stock Take Qty Data and Variance Analysis", | "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 Round": "Stock Take Round", | ||||
| "Stock Take Section": "Stock Take Section", | "Stock Take Section": "Stock Take Section", | ||||
| "Stock Take Section (can use , to search multiple sections)": "Stock Take Section (can use , to search multiple sections)", | "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)", | "sections unit": "area(s)", | ||||
| "selected stock take qty": "selected stock take qty", | "selected stock take qty": "selected stock take qty", | ||||
| "start time": "start time", | "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", | "stockTaking": "Stock taking", | ||||
| "stock_take": "Stock take", | "stock_take": "Stock take", | ||||
| "variance Percentage": "variance Percentage" | "variance Percentage": "variance Percentage" | ||||
| @@ -53,7 +53,7 @@ | |||||
| "Warehouse Location": "倉庫位置", | "Warehouse Location": "倉庫位置", | ||||
| "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | ||||
| "UOM": "單位", | "UOM": "單位", | ||||
| "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數= 可用數", | |||||
| "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數", | |||||
| "Record Status": "盤點狀態", | "Record Status": "盤點狀態", | ||||
| "No data": "沒有數據", | "No data": "沒有數據", | ||||
| "Difference": "差異", | "Difference": "差異", | ||||
| @@ -61,6 +61,11 @@ | |||||
| "Second": "第二次", | "Second": "第二次", | ||||
| "Approver Input": "審核員輸入", | "Approver Input": "審核員輸入", | ||||
| "Stock Take Qty": "盤點數", | "Stock Take Qty": "盤點數", | ||||
| "qtyGapWarnPercent": "出入差異警告%", | |||||
| "qtyGapWarnPercentSaved": "出入差異警告%已保存", | |||||
| "qtyGapWarnPercentInvalid": "請輸入 0 至 1000 的整數", | |||||
| "stockTakeQtyGapWarn": "請小心輸入查看,盤點數與現時倉存有 {{pct}}% 的出入", | |||||
| "stockTakeQtyGapWarnOver": "請小心輸入查看,盤點數與現時倉存的出入已超過 {{threshold}}%", | |||||
| "Bad Qty": "不良數量", | "Bad Qty": "不良數量", | ||||
| "selected stock take qty": "已選擇盤點數量", | "selected stock take qty": "已選擇盤點數量", | ||||
| "book qty": "帳面庫存", | "book qty": "帳面庫存", | ||||