Browse Source

added unit showing refine in stock take, allow user to set a warning percent to remind stock take difference when inputing the stock qty

production
PC-20260115JRSN\Administrator 7 hours ago
parent
commit
ed5b984ead
15 changed files with 681 additions and 102 deletions
  1. +1
    -0
      package.json
  2. +88
    -0
      scripts/warmup-menu.mjs
  3. +2
    -0
      src/app/api/stockTake/actions.ts
  4. +40
    -5
      src/components/StockTakeManagement/ApproverStockTake.tsx
  5. +65
    -21
      src/components/StockTakeManagement/ApproverStockTakeAll.tsx
  6. +75
    -24
      src/components/StockTakeManagement/PickerReStockTake.tsx
  7. +75
    -48
      src/components/StockTakeManagement/PickerStockTake.tsx
  8. +40
    -0
      src/components/StockTakeManagement/StockTakeQtyGapHint.tsx
  9. +84
    -2
      src/components/StockTakeManagement/StockTakeTab.tsx
  10. +49
    -0
      src/components/StockTakeManagement/qtyGapWarnSettingClient.ts
  11. +55
    -0
      src/components/StockTakeManagement/stockTakeQtyAdornment.tsx
  12. +62
    -0
      src/components/StockTakeManagement/stockTakeQtyGapWarning.ts
  13. +33
    -0
      src/components/StockTakeManagement/useStockTakeQtyGapWarnPercent.ts
  14. +6
    -1
      src/i18n/en/stockTake.json
  15. +6
    -1
      src/i18n/zh/stockTake.json

+ 1
- 0
package.json View File

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


+ 88
- 0
scripts/warmup-menu.mjs View File

@@ -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)" : ""}`,
);
}

+ 2
- 0
src/app/api/stockTake/actions.ts View File

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


+ 40
- 5
src/components/StockTakeManagement/ApproverStockTake.tsx View File

@@ -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}
/>
)}
{(() => {


+ 65
- 21
src/components/StockTakeManagement/ApproverStockTakeAll.tsx View File

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


+ 75
- 24
src/components/StockTakeManagement/PickerReStockTake.tsx View File

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



+ 75
- 48
src/components/StockTakeManagement/PickerStockTake.tsx View File

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



+ 40
- 0
src/components/StockTakeManagement/StockTakeQtyGapHint.tsx View File

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

+ 84
- 2
src/components/StockTakeManagement/StockTakeTab.tsx View File

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


+ 49
- 0
src/components/StockTakeManagement/qtyGapWarnSettingClient.ts View File

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

+ 55
- 0
src/components/StockTakeManagement/stockTakeQtyAdornment.tsx View File

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

+ 62
- 0
src/components/StockTakeManagement/stockTakeQtyGapWarning.ts View File

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

+ 33
- 0
src/components/StockTakeManagement/useStockTakeQtyGapWarnPercent.ts View File

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

+ 6
- 1
src/i18n/en/stockTake.json View File

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


+ 6
- 1
src/i18n/zh/stockTake.json View File

@@ -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": "帳面庫存",


Loading…
Cancel
Save