| @@ -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", | |||
| @@ -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; | |||
| availableQty: number; | |||
| uom: string; | |||
| /** Stock UoM short label (e.g. 包) for the count input. */ | |||
| uomShortDesc?: string | null; | |||
| warehouseCode: string; | |||
| warehouseName: string; | |||
| warehouseSlot: string; | |||
| @@ -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<ApproverStockTakeProps> = ({ | |||
| onSnackbar, | |||
| }) => { | |||
| const { t } = useTranslation(["stockTake", "common"]); | |||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||
| @@ -61,6 +66,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| // 每个记录的选择状态,key 为 detail.id | |||
| const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | |||
| const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | |||
| const [gapCheckOpen, setGapCheckOpen] = useState<Record<string, boolean>>({}); | |||
| const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({}); | |||
| const [saving, setSaving] = useState(false); | |||
| const [batchSaving, setBatchSaving] = useState(false); | |||
| @@ -255,7 +261,19 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| 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<ApproverStockTakeProps> = ({ | |||
| } 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<ApproverStockTakeProps> = ({ | |||
| 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"} | |||
| /> | |||
| <TextField | |||
| @@ -631,6 +660,12 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| = {formatNumber(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} | |||
| </Typography> | |||
| </Stack> | |||
| <StockTakeQtyGapHint | |||
| open={!!gapCheckOpen[`${detail.id}:approver`]} | |||
| entered={approverQty[detail.id] || ""} | |||
| currentQty={stockTakeHiddenOnHand(detail)} | |||
| threshold={qtyGapWarnPercent} | |||
| /> | |||
| )} | |||
| {(() => { | |||
| @@ -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<ApproverStockTakeAllProps> = ({ | |||
| onSnackbar, | |||
| }) => { | |||
| const { t } = useTranslation(["stockTake", "common"]); | |||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||
| @@ -235,6 +240,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| const [searchVarianceFilterStrict, setSearchVarianceFilterStrict] = useState(false); | |||
| const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | |||
| const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | |||
| const [gapCheckOpen, setGapCheckOpen] = useState<Record<string, boolean>>({}); | |||
| const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({}); | |||
| const [saving, setSaving] = useState(false); | |||
| const [batchSaving, setBatchSaving] = useState(false); | |||
| @@ -654,7 +660,19 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| 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<ApproverStockTakeAllProps> = ({ | |||
| 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<ApproverStockTakeAllProps> = ({ | |||
| flex: 1, | |||
| sortable: false, | |||
| 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 || "-"} | |||
| </Box> | |||
| </Typography> | |||
| <Box>{params.row.lotNo || "-"}</Box> | |||
| <Box> | |||
| {params.row.expiryDate | |||
| @@ -1052,12 +1073,12 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| /> | |||
| <Typography variant="body2" component="span"> | |||
| {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> | |||
| </Stack> | |||
| )} | |||
| @@ -1077,12 +1098,12 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| /> | |||
| <Typography variant="body2" component="span"> | |||
| {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> | |||
| </Stack> | |||
| @@ -1108,6 +1129,18 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| 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<ApproverStockTakeAllProps> = ({ | |||
| [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]*" }} | |||
| /> | |||
| <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 }}> | |||
| = {formatNumber(approverGoodQty)} | |||
| @@ -1152,7 +1196,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| <Stack spacing={0.75}> | |||
| {summaryLine( | |||
| `${t("Selected Qty")}:`, | |||
| formatNumber(selectedQty) | |||
| [formatNumber(selectedQty), detail.uomShortDesc?.trim()].filter(Boolean).join(" ") | |||
| )} | |||
| {summaryLine(`${t("Book Qty")}:`, formatNumber(bookQty))} | |||
| {summaryLine( | |||
| @@ -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<PickerReStockTakeProps> = ({ | |||
| onSnackbar, | |||
| }) => { | |||
| const { t } = useTranslation(["stockTake", "common"]); | |||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||
| @@ -65,6 +70,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| remark: string; | |||
| }>>({}); | |||
| const [saving, setSaving] = useState(false); | |||
| const [gapCheckOpen, setGapCheckOpen] = useState<Record<string, boolean>>({}); | |||
| const [batchSaving, setBatchSaving] = useState(false); | |||
| const [shortcutInput, setShortcutInput] = useState<string>(""); | |||
| const [page, setPage] = useState(0); | |||
| @@ -246,7 +252,19 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| 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<PickerReStockTakeProps> = ({ | |||
| } 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<PickerReStockTakeProps> = ({ | |||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</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("Remark")}</TableCell>*/} | |||
| <TableCell>{t("Record Status")}</TableCell> | |||
| @@ -520,28 +538,40 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| <TableRow key={detail.id}> | |||
| <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell> | |||
| <TableCell sx={{ | |||
| maxWidth: 150, | |||
| maxWidth: 280, | |||
| wordBreak: 'break-word', | |||
| whiteSpace: 'normal', | |||
| lineHeight: 1.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.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell>{detail.uom || "-"}</TableCell> | |||
| <TableCell sx={{ minWidth: 300 }}> | |||
| <TableCell sx={{ width: 250, minWidth: 250 }}> | |||
| <Stack spacing={1}> | |||
| {/* First */} | |||
| {!submitDisabled && isFirstSubmit ? ( | |||
| <Stack spacing={0.5} alignItems="flex-start"> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2">{t("First")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| 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]*" }} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| onChange={(e) => { | |||
| @@ -553,9 +583,12 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| [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<PickerReStockTakeProps> = ({ | |||
| 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> | |||
| ) : detail.firstStockTakeQty != null ? ( | |||
| <Typography variant="body2"> | |||
| {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> | |||
| ) : null} | |||
| {/* Second */} | |||
| {!submitDisabled && isSecondSubmit ? ( | |||
| <Stack spacing={0.5} alignItems="flex-start"> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2">{t("Second")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| 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]*" }} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| onChange={(e) => { | |||
| @@ -623,9 +667,12 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| [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<PickerReStockTakeProps> = ({ | |||
| 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> | |||
| ) : detail.secondStockTakeQty != null ? ( | |||
| <Typography variant="body2"> | |||
| {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> | |||
| ) : null} | |||
| @@ -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<PickerStockTakeProps> = ({ | |||
| onSnackbar, | |||
| }) => { | |||
| const { t } = useTranslation(["stockTake", "common"]); | |||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||
| @@ -72,6 +77,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| const [savingRecordId, setSavingRecordId] = useState<number | null>(null); | |||
| const [remark, setRemark] = useState<string>(""); | |||
| 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 [shortcutInput, setShortcutInput] = useState<string>(""); | |||
| const [page, setPage] = useState(0); | |||
| @@ -240,7 +247,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| 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<PickerStockTakeProps> = ({ | |||
| t, | |||
| currentUserId, | |||
| onSnackbar, | |||
| qtyGapWarnPercent, | |||
| ] | |||
| ); | |||
| @@ -540,7 +560,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</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("Remark")}</TableCell>*/} | |||
| <TableCell>{t("Record Status")}</TableCell> | |||
| @@ -572,16 +592,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| </TableCell> | |||
| <TableCell | |||
| sx={{ | |||
| maxWidth: 150, | |||
| maxWidth: 280, | |||
| wordBreak: "break-word", | |||
| whiteSpace: "normal", | |||
| lineHeight: 1.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 || "-"} | |||
| </Box> | |||
| </Typography> | |||
| <Box>{detail.lotNo || "-"}</Box> | |||
| <Box> | |||
| {detail.expiryDate | |||
| @@ -592,16 +615,23 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| </TableCell> | |||
| <TableCell>{detail.uom || "-"}</TableCell> | |||
| {/* Qty + Bad Qty 合并显示/输入 */} | |||
| <TableCell sx={{ minWidth: 300 }}> | |||
| <TableCell sx={{ width: 250, minWidth: 250 }}> | |||
| <Stack spacing={1}> | |||
| {/* First */} | |||
| {!submitDisabled && isFirstSubmit ? ( | |||
| <Stack spacing={0.5} alignItems="flex-start"> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2">{t("First")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| 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]*" }} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| onChange={(e) => { | |||
| @@ -610,9 +640,12 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| 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<PickerStockTakeProps> = ({ | |||
| 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> | |||
| ) : detail.firstStockTakeQty != null ? ( | |||
| <Typography variant="body2"> | |||
| {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> | |||
| ) : null} | |||
| {/* Second */} | |||
| {!submitDisabled && isSecondSubmit ? ( | |||
| <Stack spacing={0.5} alignItems="flex-start"> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2">{t("Second")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| 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]*" }} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| onChange={(e) => { | |||
| @@ -694,9 +726,12 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| 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<PickerStockTakeProps> = ({ | |||
| 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> | |||
| ) : detail.secondStockTakeQty != null ? ( | |||
| <Typography variant="body2"> | |||
| {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> | |||
| ) : 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"; | |||
| 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<AllPickedStockTakeListReponse | null>(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 ( | |||
| <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 | |||
| value={tabValue} | |||
| 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 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" | |||
| @@ -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": "帳面庫存", | |||