|
- "use client";
-
- import {
- Box,
- Button,
- Stack,
- Typography,
- Chip,
- CircularProgress,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- Paper,
- TextField,
- Radio,
- TablePagination,
- TableSortLabel,
- Card,
- CardContent,
- Grid,
- FormControl,
- InputLabel,
- Select,
- MenuItem,
- FormControlLabel,
- Tab,
- Tabs,
- ToggleButton,
- ToggleButtonGroup,
- Checkbox,
- Dialog,
- DialogTitle,
- DialogContent,
- DialogActions,
- } from "@mui/material";
- import { useState, useCallback, useEffect, useMemo, useRef } from "react";
- import { Collapse } from "@mui/material";
- import Accordion from "@mui/material/Accordion";
- import AccordionSummary from "@mui/material/AccordionSummary";
- import AccordionDetails from "@mui/material/AccordionDetails";
- import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
- import { useTranslation } from "react-i18next";
- import {
- AllPickedStockTakeListReponse,
- InventoryLotDetailResponse,
- SaveApproverStockTakeRecordRequest,
- saveApproverStockTakeRecord,
- batchSaveApproverStockTakeRecordsByIds,
- getApproverInventoryLotDetailsAllPending,
- getApproverInventoryLotDetailsAllApproved,
- updateStockTakeRecordStatusToNotMatch,
- } from "@/app/api/stockTake/actions";
- import { fetchWarehouseListClient } from "@/app/api/warehouse/client";
- import type { WarehouseResult } from "@/app/api/warehouse";
- 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";
- import { OUTPUT_DATE_FORMAT, roundDownPercent } from "@/app/utils/formatUtil";
- import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid';
- import StyledDataGrid from "@/components/StyledDataGrid/StyledDataGrid";
- import type {
-
- GridPaginationModel,
- GridSortModel,
- } from "@mui/x-data-grid";
- interface ApproverStockTakeAllProps {
- selectedSession: AllPickedStockTakeListReponse;
- mode: "pending" | "approved";
- onBack?: () => void;
- onSnackbar: (message: string, severity: "success" | "error" | "warning") => void;
- }
-
- type QtySelectionType = "first" | "second" | "approver";
- type ApproverSearchKind = "location" | "section";
-
- type ApprovedSortKey =
- | "stockTakeEndTime"
- | "stockTakeSection"
- | "item"
- | "stockTakerName"
- | "variance";
-
- const APPROVER_SEARCH_FIELD_HEIGHT = 56;
- const APPROVER_MENU_MAX_HEIGHT = 320;
- const APPROVER_LOCATION_ALL = "ALL";
- const APPROVER_LOCATION_BUTTONS_PER_ROW = 15;
- const APPROVER_LOCATION_BUTTON_WIDTH = 80;
-
- const approverSearchFieldSx = {
- "& .MuiOutlinedInput-root": {
- height: APPROVER_SEARCH_FIELD_HEIGHT,
- minHeight: APPROVER_SEARCH_FIELD_HEIGHT,
- boxSizing: "border-box",
- alignItems: "center",
- },
- "& .MuiSelect-select": {
- height: `${APPROVER_SEARCH_FIELD_HEIGHT}px !important`,
- minHeight: "0 !important",
- display: "flex",
- alignItems: "center",
- boxSizing: "border-box",
- py: "0 !important",
- },
- } as const;
-
- const approverSearchMenuProps = {
- PaperProps: { sx: { maxHeight: APPROVER_MENU_MAX_HEIGHT } },
- } as const;
-
- function chunkApproverOptions<T>(items: T[], size: number): T[][] {
- const rows: T[][] = [];
- for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));
- return rows;
- }
-
- function withApproverAllOption(options: string[]): string[] {
- return options.length > 1 ? [APPROVER_LOCATION_ALL, ...options] : options;
- }
-
- function compareApproverLabel(a: string, b: string): number {
- return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
- }
-
- function selectedApproverSectionCodes(value: string): string[] {
- return value.split(",").map((part) => part.trim()).filter(Boolean);
- }
-
- type ApproverLocationPick = { storeId: string; warehouse: string; area: string; slot: string };
-
- function approverWarehouseKeyword(pick: ApproverLocationPick): string {
- const warehouse = pick.warehouse && pick.warehouse !== APPROVER_LOCATION_ALL ? pick.warehouse : "";
- const area = pick.area && pick.area !== APPROVER_LOCATION_ALL ? pick.area : "";
- const slot = pick.slot && pick.slot !== APPROVER_LOCATION_ALL ? pick.slot : "";
- if (warehouse && area && slot) return `${warehouse}-${area}-${slot}`;
- if (warehouse && area) return `${warehouse}-${area}`;
- if (warehouse) return warehouse;
- if (pick.storeId && pick.storeId !== APPROVER_LOCATION_ALL) return pick.storeId;
- return "";
- }
-
- type ApproverSearchFilters = {
- sectionDescription: string;
- stockTakeSession: string;
- itemKeyword: string;
- warehouseKeyword: string;
- storeId: string;
- status: string;
- variancePercentTolerance: string;
- varianceFilterInclusive: boolean;
- varianceFilterStrict: boolean;
- };
-
- function defaultApproverSearchFilters(mode: "pending" | "approved"): ApproverSearchFilters {
- return {
- sectionDescription: "All",
- stockTakeSession: "",
- itemKeyword: "",
- warehouseKeyword: "",
- storeId: "All",
- status: mode === "pending" ? "pass" : "All",
- variancePercentTolerance: "5",
- varianceFilterInclusive: false,
- varianceFilterStrict: false,
- };
- }
-
- function approverVariancePercent(detail: InventoryLotDetailResponse): number {
- const second = detail.secondStockTakeQty;
- const first = detail.firstStockTakeQty;
- const selected = second != null && second >= 0 ? second : first ?? 0;
- const book = detail.bookQty != null ? detail.bookQty : detail.availableQty ?? 0;
- const difference = selected - book;
- const raw =
- book !== 0 ? (difference / book) * 100 : difference === 0 ? 0 : difference > 0 ? 100 : -100;
- return roundDownPercent(raw);
- }
-
- function matchesApproverDetailFilters(
- detail: InventoryLotDetailResponse,
- filters: ApproverSearchFilters,
- mode: "pending" | "approved"
- ): boolean {
- if (filters.sectionDescription && filters.sectionDescription !== "All") {
- const desc = (detail.stockTakeSectionDescription || "").trim().toLowerCase();
- if (desc !== filters.sectionDescription.trim().toLowerCase()) return false;
- }
- const sections = selectedApproverSectionCodes(filters.stockTakeSession);
- if (sections.length > 0) {
- const sec = (detail.stockTakeSection || "").trim().toLowerCase();
- const hit = sections.some((part) => {
- const wanted = part.toLowerCase();
- return sec === wanted || sec.includes(wanted);
- });
- if (!hit) return false;
- }
- const itemParts = filters.itemKeyword.split(",").map((part) => part.trim()).filter(Boolean);
- if (itemParts.length > 0) {
- const code = detail.itemCode || "";
- const name = detail.itemName || "";
- const hit = itemParts.some(
- (part) =>
- code.toLowerCase().includes(part.toLowerCase()) || name.toLowerCase().includes(part.toLowerCase())
- );
- if (!hit) return false;
- }
- const warehouseParts = filters.warehouseKeyword.split(",").map((part) => part.trim()).filter(Boolean);
- if (warehouseParts.length > 0) {
- const code = detail.warehouseCode || "";
- if (!warehouseParts.some((part) => code.toLowerCase().includes(part.toLowerCase()))) return false;
- }
- if (filters.storeId && filters.storeId !== "All") {
- if ((detail.storeId || "").trim().toLowerCase() !== filters.storeId.trim().toLowerCase()) return false;
- }
- const statusFilter = mode === "pending" ? filters.status || "pass" : "All";
- if (statusFilter !== "All") {
- const rowStatus = String(detail.stockTakeRecordStatus ?? "")
- .trim()
- .toLowerCase()
- .replaceAll("_", "");
- const wanted = statusFilter.trim().toLowerCase().replaceAll("_", "");
- if (rowStatus !== wanted) return false;
- }
- const tolerance = Number(filters.variancePercentTolerance);
- if (filters.variancePercentTolerance.trim() !== "" && Number.isFinite(tolerance) && Math.abs(tolerance) > 0) {
- const vp = approverVariancePercent(detail);
- const limit = Math.abs(tolerance);
- const negative = -limit;
- const inclusive = filters.varianceFilterInclusive;
- const strict = filters.varianceFilterStrict;
- const keep = inclusive
- ? strict
- ? vp > negative && vp < limit
- : vp >= negative && vp <= limit
- : strict
- ? vp < negative || vp > limit
- : vp <= negative || vp >= limit;
- if (!keep) return false;
- }
- return true;
- }
-
- function isBlankApproverField(value: string | undefined): boolean {
- return value == null || String(value).trim() === "";
- }
-
- /** 審核員「盤點數」與「不良數」皆未輸入時應跳過儲存,維持 pending */
- function isApproverInputsBothEmpty(
- detailId: number,
- approverQty: Record<number, string>,
- approverBadQty: Record<number, string>
- ): boolean {
- return isBlankApproverField(approverQty[detailId]) && isBlankApproverField(approverBadQty[detailId]);
- }
-
- type ApproverSaveBuildResult =
- | {
- ok: true;
- request: SaveApproverStockTakeRecordRequest;
- finalQty: number;
- finalBadQty: number;
- goodQty: number;
- selection: QtySelectionType;
- }
- | { ok: false; reason: "skip_approver_empty" }
- | { ok: false; reason: "error"; message: string };
-
- function buildApproverSaveRequest(
- detail: InventoryLotDetailResponse,
- qtySelection: Record<number, QtySelectionType>,
- approverQty: Record<number, string>,
- approverBadQty: Record<number, string>,
- currentUserId: number,
- t: (key: string) => string
- ): ApproverSaveBuildResult {
- const selection = qtySelection[detail.id] || "first";
- let finalQty: number;
- let finalBadQty: number;
-
- if (selection === "first") {
- if (detail.firstStockTakeQty == null) {
- return { ok: false, reason: "error", message: t("First QTY is not available") };
- }
- finalQty = detail.firstStockTakeQty;
- finalBadQty = detail.firstBadQty || 0;
- } else if (selection === "second") {
- if (detail.secondStockTakeQty == null) {
- return { ok: false, reason: "error", message: t("Second QTY is not available") };
- }
- finalQty = detail.secondStockTakeQty;
- finalBadQty = detail.secondBadQty || 0;
- } else {
- if (isApproverInputsBothEmpty(detail.id, approverQty, approverBadQty)) {
- return { ok: false, reason: "skip_approver_empty" };
- }
- const approverQtyValue = approverQty[detail.id] || "0";
- const approverBadQtyValue = approverBadQty[detail.id] || "0";
- finalQty = parseFloat(approverQtyValue) || 0;
- finalBadQty = parseFloat(approverBadQtyValue) || 0;
- }
-
- const request: SaveApproverStockTakeRecordRequest = {
- stockTakeRecordId: detail.stockTakeRecordId || null,
- qty: finalQty,
- badQty: finalBadQty,
- approverId: currentUserId,
- approverQty: selection === "approver" ? finalQty : null,
- approverBadQty: selection === "approver" ? finalBadQty : null,
- lastSelect: selection === "first" ? 1 : selection === "second" ? 2 : 3,
- };
-
- const goodQty = finalQty - finalBadQty;
- return { ok: true, request, finalQty, finalBadQty, goodQty, selection };
- }
-
- function buildBatchApproverSaveRequests(
- details: InventoryLotDetailResponse[],
- qtySelection: Record<number, QtySelectionType>,
- approverQty: Record<number, string>,
- approverBadQty: Record<number, string>,
- currentUserId: number,
- t: (key: string) => string
- ): SaveApproverStockTakeRecordRequest[] {
- const records: SaveApproverStockTakeRecordRequest[] = [];
- for (const detail of details) {
- if (!detail.stockTakeRecordId || detail.stockTakeRecordId <= 0) {
- continue;
- }
- const built = buildApproverSaveRequest(
- detail,
- qtySelection,
- approverQty,
- approverBadQty,
- currentUserId,
- t
- );
- if (!built.ok) {
- continue;
- }
- records.push(built.request);
- }
- return records;
- }
-
- function parseDateTimeMs(
- v: string | string[] | null | undefined
- ): number {
- if (v == null) return 0;
- if (Array.isArray(v)) {
- const arr = v as unknown as number[];
- const [y, m, d, h = 0, min = 0, s = 0] = arr;
- return dayjs(`${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")} ${h}:${min}:${s}`).valueOf();
- }
- return dayjs(v as string).valueOf();
- }
-
- const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
- selectedSession,
- mode,
- onBack,
- onSnackbar,
- }) => {
- const { t } = useTranslation(["stockTake", "common"]);
- const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent();
- const { data: session } = useSession() as { data: SessionWithTokens | null };
-
- const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]);
- const [loadingDetails, setLoadingDetails] = useState(false);
- const [searchVariancePercentTolerance, setSearchVariancePercentTolerance] = useState<string>("5");
- const [searchVarianceFilterInclusive, setSearchVarianceFilterInclusive] = useState(false);
- 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);
- const [updatingStatus, setUpdatingStatus] = useState(false);
- const [page, setPage] = useState(0);
- const [pageSize, setPageSize] = useState<number | string>(50);
- const [total, setTotal] = useState(0);
- const [approvedSortKey, setApprovedSortKey] = useState<ApprovedSortKey | null>(null);
- const [approvedSortDir, setApprovedSortDir] = useState<"asc" | "desc">("asc");
- const [locationWarehouses, setLocationWarehouses] = useState<WarehouseResult[]>([]);
- const [searchKind, setSearchKind] = useState<ApproverSearchKind>("location");
- const [locationPick, setLocationPick] = useState<ApproverLocationPick>({ storeId: "", warehouse: "", area: "", slot: "" });
- const [searchSectionDescription, setSearchSectionDescription] = useState<string>("All");
- const [searchStockTakeSession, setSearchStockTakeSession] = useState<string>("");
- const [searchItemKeyword, setSearchItemKeyword] = useState<string>("");
- const [searchWarehouseKeyword, setSearchWarehouseKeyword] = useState<string>("");
- const [searchStoreId, setSearchStoreId] = useState<string>("All");
- const [searchStatus, setSearchStatus] = useState<string>(mode === "pending" ? "pass" : "All");
- const locationOwnedSectionRef = useRef("");
- const [showFilters, setShowFilters] = useState(true)
- const [appliedFilters, setAppliedFilters] = useState<ApproverSearchFilters>(() =>
- defaultApproverSearchFilters(mode)
- );
- const [openBatchSaveConfirmDialog, setOpenBatchSaveConfirmDialog] = useState(false);
- const batchSaveInFlightRef = useRef(false);
-
- const currentUserId = session?.id ? parseInt(session.id) : undefined;
-
- const handleChangePage = useCallback((_: unknown, newPage: number) => {
- setPage(newPage);
- }, []);
-
- const handleChangeRowsPerPage = useCallback(
- (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
- const newSize = parseInt(event.target.value, 10);
- if (newSize === -1) {
- setPageSize("all");
- } else if (!isNaN(newSize)) {
- setPageSize(newSize);
- }
- setPage(0);
- },
- []
- );
-
- const handleSearch = useCallback((override?: Partial<ApproverSearchFilters> & { searchKind?: ApproverSearchKind }) => {
- const kind = override?.searchKind ?? searchKind;
- const sectionDescription = kind === "location"
- ? "All"
- : (override?.sectionDescription ?? (searchSectionDescription || "All"));
- const stockTakeSession = kind === "location"
- ? (override?.stockTakeSession ?? locationOwnedSectionRef.current)
- : (override?.stockTakeSession ?? (searchStockTakeSession || ""));
- const warehouseKeyword = kind === "location"
- ? (override?.warehouseKeyword ?? (searchWarehouseKeyword || ""))
- : "";
- const storeId = kind === "location"
- ? (override?.storeId ?? (searchStoreId || "All"))
- : "All";
- const next: ApproverSearchFilters = {
- sectionDescription,
- stockTakeSession,
- itemKeyword: override?.itemKeyword ?? (searchItemKeyword || ""),
- warehouseKeyword,
- storeId,
- status: mode === "pending" ? (override?.status ?? searchStatus ?? "pass") : "All",
- variancePercentTolerance: override?.variancePercentTolerance ?? searchVariancePercentTolerance,
- varianceFilterInclusive: override?.varianceFilterInclusive ?? searchVarianceFilterInclusive,
- varianceFilterStrict: override?.varianceFilterStrict ?? searchVarianceFilterStrict,
- };
- if (kind === "section" && override?.sectionDescription != null) setSearchSectionDescription(override.sectionDescription);
- if (kind === "section" && override?.stockTakeSession != null) setSearchStockTakeSession(override.stockTakeSession);
- if (kind === "location" && override?.warehouseKeyword != null) setSearchWarehouseKeyword(override.warehouseKeyword);
- if (kind === "location" && override?.storeId != null) setSearchStoreId(override.storeId);
- if (override?.status != null) setSearchStatus(override.status);
- setAppliedFilters(next);
- setPage(0);
- }, [
- searchKind,
- searchSectionDescription,
- searchStockTakeSession,
- searchItemKeyword,
- searchWarehouseKeyword,
- searchStoreId,
- searchStatus,
- searchVariancePercentTolerance,
- searchVarianceFilterInclusive,
- searchVarianceFilterStrict,
- mode,
- ]);
-
- const handleResetSearch = useCallback(() => {
- const defaultStatus = mode === "pending" ? "pass" : "All";
- locationOwnedSectionRef.current = "";
- setLocationPick({ storeId: "", warehouse: "", area: "", slot: "" });
- setSearchSectionDescription("All");
- setSearchStockTakeSession("");
- setSearchItemKeyword("");
- setSearchWarehouseKeyword("");
- setSearchStoreId("All");
- setSearchStatus(defaultStatus);
- setSearchVariancePercentTolerance("5");
- setSearchVarianceFilterInclusive(false);
- setSearchVarianceFilterStrict(false);
- setAppliedFilters(defaultApproverSearchFilters(mode));
- setPage(0);
- }, [mode]);
-
- const switchSearchKind = useCallback((nextKind: ApproverSearchKind) => {
- if (nextKind === searchKind) return;
- setSearchKind(nextKind);
- if (nextKind === "location") {
- const specificWarehouse = Boolean(locationPick.warehouse && locationPick.warehouse !== APPROVER_LOCATION_ALL);
- handleSearch({
- searchKind: nextKind,
- stockTakeSession: locationOwnedSectionRef.current,
- warehouseKeyword: approverWarehouseKeyword(locationPick),
- storeId: !specificWarehouse && locationPick.storeId && locationPick.storeId !== APPROVER_LOCATION_ALL ? locationPick.storeId : "All",
- });
- return;
- }
- handleSearch({ searchKind: nextKind });
- }, [handleSearch, locationPick, searchKind]);
-
- const loadDetails = useCallback(async () => {
- setLoadingDetails(true);
-
- try {
- // Load this round once; search filters run on the loaded rows.
- const response = mode === "approved"
- ? await getApproverInventoryLotDetailsAllApproved(
- selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null,
- 0,
- 5000
- )
- : await getApproverInventoryLotDetailsAllPending(
- selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null,
- 0,
- 5000
- );
-
- setInventoryLotDetails(Array.isArray(response.records) ? response.records : []);
- setTotal(response.total ?? response.records?.length ?? 0);
- } catch (e) {
- console.error(e);
- setInventoryLotDetails([]);
- } finally {
- setLoadingDetails(false);
- }
- }, [selectedSession, mode]);
-
-
- useEffect(() => {
- loadDetails();
- }, [loadDetails]);
-
- useEffect(() => {
- let cancelled = false;
- fetchWarehouseListClient()
- .then((rows) => {
- if (!cancelled) setLocationWarehouses(rows ?? []);
- })
- .catch((e) => console.error("Failed to load warehouses for approver search:", e));
- return () => {
- cancelled = true;
- };
- }, []);
-
- const roundSectionCodeSet = useMemo(() => {
- const set = new Set<string>();
- inventoryLotDetails.forEach((row) => {
- const code = row.stockTakeSection?.trim();
- if (code) set.add(code);
- });
- return set;
- }, [inventoryLotDetails]);
-
- const roundWarehouses = useMemo(
- () =>
- locationWarehouses.filter((row) => {
- const code = row.stockTakeSection?.trim();
- if (!code) return false;
- if (roundSectionCodeSet.size === 0) return true;
- return roundSectionCodeSet.has(code);
- }),
- [locationWarehouses, roundSectionCodeSet]
- );
-
- const sectionDescriptionOptions = useMemo(() => {
- const pickedCodes = new Set(selectedApproverSectionCodes(searchStockTakeSession));
- const set = new Set<string>();
- roundWarehouses.forEach((row) => {
- const code = row.stockTakeSection?.trim();
- if (pickedCodes.size > 0 && (!code || !pickedCodes.has(code))) return;
- const desc = row.stockTakeSectionDescription?.trim();
- if (desc) set.add(desc);
- });
- return Array.from(set).sort(compareApproverLabel);
- }, [roundWarehouses, searchStockTakeSession]);
-
- const stockTakeSectionOptions = useMemo(() => {
- const descFilter = searchSectionDescription !== "All" ? searchSectionDescription : "";
- const set = new Set<string>();
- roundWarehouses.forEach((row) => {
- if (descFilter && (row.stockTakeSectionDescription?.trim() || "") !== descFilter) return;
- const code = row.stockTakeSection?.trim();
- if (code) set.add(code);
- });
- return Array.from(set).sort(compareApproverLabel);
- }, [roundWarehouses, searchSectionDescription]);
-
- const floorOptions = useMemo(() => {
- const set = new Set<string>();
- roundWarehouses.forEach((row) => {
- if (row.store_id) set.add(row.store_id);
- });
- return withApproverAllOption(Array.from(set).sort(compareApproverLabel));
- }, [roundWarehouses]);
-
- const warehouseOptions = useMemo(() => {
- if (!locationPick.storeId) return [];
- const set = new Set<string>();
- roundWarehouses.forEach((row) => {
- if (locationPick.storeId !== APPROVER_LOCATION_ALL && (row.store_id ?? "").trim() !== locationPick.storeId) return;
- const zone = row.warehouse?.trim();
- if (zone) set.add(zone);
- });
- return withApproverAllOption(Array.from(set).sort(compareApproverLabel));
- }, [roundWarehouses, locationPick.storeId]);
-
- const areaOptions = useMemo(() => {
- if (!locationPick.storeId || !locationPick.warehouse) return [];
- const set = new Set<string>();
- roundWarehouses.forEach((row) => {
- if (locationPick.storeId !== APPROVER_LOCATION_ALL && (row.store_id ?? "").trim() !== locationPick.storeId) return;
- if (locationPick.warehouse !== APPROVER_LOCATION_ALL && (row.warehouse ?? "").trim() !== locationPick.warehouse) return;
- const area = row.area?.trim();
- if (area) set.add(area);
- });
- return withApproverAllOption(Array.from(set).sort(compareApproverLabel));
- }, [roundWarehouses, locationPick.storeId, locationPick.warehouse]);
-
- const slotOptions = useMemo(() => {
- if (!locationPick.storeId || !locationPick.warehouse || !locationPick.area) return [];
- const set = new Set<string>();
- roundWarehouses.forEach((row) => {
- if (locationPick.storeId !== APPROVER_LOCATION_ALL && (row.store_id ?? "").trim() !== locationPick.storeId) return;
- if (locationPick.warehouse !== APPROVER_LOCATION_ALL && (row.warehouse ?? "").trim() !== locationPick.warehouse) return;
- if (locationPick.area !== APPROVER_LOCATION_ALL && (row.area ?? "").trim() !== locationPick.area) return;
- const slot = row.slot?.trim();
- if (slot) set.add(slot);
- });
- return withApproverAllOption(Array.from(set).sort(compareApproverLabel));
- }, [roundWarehouses, locationPick.storeId, locationPick.warehouse, locationPick.area]);
-
- const locationWarehouseRows = useMemo(
- () => chunkApproverOptions(warehouseOptions, APPROVER_LOCATION_BUTTONS_PER_ROW),
- [warehouseOptions]
- );
- const locationAreaRows = useMemo(
- () => chunkApproverOptions(areaOptions, APPROVER_LOCATION_BUTTONS_PER_ROW),
- [areaOptions]
- );
- const locationSlotRows = useMemo(
- () => chunkApproverOptions(slotOptions, APPROVER_LOCATION_BUTTONS_PER_ROW),
- [slotOptions]
- );
-
- const applyLocationPick = useCallback((pick: ApproverLocationPick) => {
- const specificWarehouse = Boolean(pick.warehouse && pick.warehouse !== APPROVER_LOCATION_ALL);
- const specificArea = Boolean(pick.area && pick.area !== APPROVER_LOCATION_ALL);
- const specificSlot = Boolean(pick.slot && pick.slot !== APPROVER_LOCATION_ALL);
- const matching = roundWarehouses.filter((row) => {
- if (pick.storeId && pick.storeId !== APPROVER_LOCATION_ALL && row.store_id !== pick.storeId) return false;
- if (specificWarehouse && row.warehouse !== pick.warehouse) return false;
- if (specificArea && row.area !== pick.area) return false;
- if (specificSlot && row.slot !== pick.slot) return false;
- return true;
- });
- const codes = Array.from(
- new Set(matching.map((row) => row.stockTakeSection?.trim()).filter((code): code is string => Boolean(code)))
- ).sort(compareApproverLabel);
- const owned = specificWarehouse || specificArea || specificSlot ? codes.join(", ") : "";
- locationOwnedSectionRef.current = owned;
- const keyword = approverWarehouseKeyword(pick);
- const storeId = !specificWarehouse && pick.storeId && pick.storeId !== APPROVER_LOCATION_ALL ? pick.storeId : "All";
- setLocationPick(pick);
- handleSearch({
- searchKind: "location",
- stockTakeSession: owned,
- warehouseKeyword: keyword,
- storeId,
- });
- }, [roundWarehouses, handleSearch]);
-
- useEffect(() => {
- if (searchKind !== "location") return;
- if (!locationPick.storeId && !locationPick.warehouse && !locationPick.area && !locationPick.slot) return;
- if (locationWarehouses.length === 0) return;
- const floorOk = !locationPick.storeId || floorOptions.includes(locationPick.storeId);
- const warehouseOk = !locationPick.warehouse || warehouseOptions.includes(locationPick.warehouse);
- const areaOk = !locationPick.area || areaOptions.includes(locationPick.area);
- const slotOk = !locationPick.slot || slotOptions.includes(locationPick.slot);
- if (floorOk && warehouseOk && areaOk && slotOk) return;
- const next: ApproverLocationPick = {
- storeId: floorOk ? locationPick.storeId : "",
- warehouse: floorOk && warehouseOk ? locationPick.warehouse : "",
- area: floorOk && warehouseOk && areaOk ? locationPick.area : "",
- slot: floorOk && warehouseOk && areaOk && slotOk ? locationPick.slot : "",
- };
- applyLocationPick(next);
- }, [applyLocationPick, areaOptions, floorOptions, locationPick, locationWarehouses.length, searchKind, slotOptions, warehouseOptions]);
-
- // 切换模式或盘次时,清空选择与搜索;approved 模式需要以后端结果为准。
- useEffect(() => {
- setQtySelection({});
- setApproverQty({});
- setApproverBadQty({});
- locationOwnedSectionRef.current = "";
- setLocationPick({ storeId: "", warehouse: "", area: "", slot: "" });
- setSearchSectionDescription("All");
- setSearchStockTakeSession("");
- setSearchItemKeyword("");
- setSearchWarehouseKeyword("");
- setSearchStoreId("All");
- setSearchStatus(mode === "pending" ? "pass" : "All");
- setSearchVariancePercentTolerance("5");
- setSearchVarianceFilterInclusive(false);
- setSearchVarianceFilterStrict(false);
- setAppliedFilters(defaultApproverSearchFilters(mode));
- setPage(0);
- }, [mode, selectedSession.stockTakeId]);
-
- useEffect(() => {
- const inferSelection = (
- detail: InventoryLotDetailResponse
- ): QtySelectionType => {
- // 优先使用后端记录的 lastSelect(1=First, 2=Second, 3=Approver Input)
- if (detail.lastSelect != null) {
- if (detail.lastSelect === 1) return "first";
- if (detail.lastSelect === 2) return "second";
- if (detail.lastSelect === 3) return "approver";
- }
-
- // 目标:在 approved 模式下,即使后端把 approver 字段也回填了,
- // 只要 finalQty 来自 first/second(picker 结果),就优先勾选 first/second。
- // 只有匹配不到 first/second 时,才推断为 approver。
- if (detail.finalQty != null) {
- const eps = 1e-6;
- const firstAvailable = detail.firstStockTakeQty;
- const secondAvailable = detail.secondStockTakeQty;
-
- // 如果这一行确实有 approver 结果,那么 approved 时应该优先显示为 approver
- // (尤其是:picker first 后又手动改 approver input 的情况)
- if (detail.approverQty != null) {
- const approverAvailable =
- detail.approverQty - (detail.approverBadQty ?? 0);
- if (Math.abs(approverAvailable - detail.finalQty) <= eps) {
- return "approver";
- }
- }
-
- if (secondAvailable != null && Math.abs(secondAvailable - detail.finalQty) <= eps) {
- return "second";
- }
-
- if (firstAvailable != null && Math.abs(firstAvailable - detail.finalQty) <= eps) {
- return "first";
- }
-
- // approver 字段口径可能是「available」或「total+bad」两种之一,这里同时尝试两种。
- if (detail.approverQty != null) {
- const approverAvailable = detail.approverQty;
- const approverAvailable2 =
- detail.approverQty - (detail.approverBadQty ?? 0);
-
- if (
- Math.abs(approverAvailable - detail.finalQty) <= eps ||
- Math.abs(approverAvailable2 - detail.finalQty) <= eps
- ) {
- return "approver";
- }
- }
- }
-
- // pending/无法反推时:second 存在则默认 second,否则 first
- if (detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0) {
- return "second";
- }
- return "first";
- };
-
- const newSelections: Record<number, QtySelectionType> = {};
- inventoryLotDetails.forEach((detail) => {
- if (qtySelection[detail.id]) return;
- newSelections[detail.id] = inferSelection(detail);
- });
-
- if (Object.keys(newSelections).length > 0) {
- setQtySelection((prev) => ({ ...prev, ...newSelections }));
- }
- }, [inventoryLotDetails, qtySelection]);
-
- // approved 模式下:把已保存的 approver 输入值回填到 TextField,避免“radio 显示了但输入框为空”
- useEffect(() => {
- if (mode !== "approved") return;
-
- const newApproverQty: Record<number, string> = {};
- const newApproverBadQty: Record<number, string> = {};
-
- inventoryLotDetails.forEach((detail) => {
- if (detail.approverQty != null && approverQty[detail.id] == null) {
- newApproverQty[detail.id] = String(detail.approverQty);
- }
- if (
- detail.approverBadQty != null &&
- approverBadQty[detail.id] == null
- ) {
- newApproverBadQty[detail.id] = String(detail.approverBadQty);
- }
- });
-
- if (Object.keys(newApproverQty).length > 0) {
- setApproverQty((prev) => ({ ...prev, ...newApproverQty }));
- }
- if (Object.keys(newApproverBadQty).length > 0) {
- setApproverBadQty((prev) => ({ ...prev, ...newApproverBadQty }));
- }
- }, [mode, inventoryLotDetails, approverQty, approverBadQty]);
-
- const calculateDifference = useCallback(
- (detail: InventoryLotDetailResponse, selection: QtySelectionType): number => {
- let selectedQty = 0;
-
- if (selection === "first") {
- selectedQty = detail.firstStockTakeQty || 0;
- } else if (selection === "second") {
- selectedQty = detail.secondStockTakeQty || 0;
- } else if (selection === "approver") {
- selectedQty =
- (parseFloat(approverQty[detail.id] || "0") -
- parseFloat(approverBadQty[detail.id] || "0")) || 0;
- }
-
- const bookQty = detail.bookQty != null ? detail.bookQty : detail.availableQty || 0;
- return selectedQty - bookQty;
- },
- [approverQty, approverBadQty]
- );
- const filteredDetails = useMemo(() => {
- return inventoryLotDetails.filter((detail) => matchesApproverDetailFilters(detail, appliedFilters, mode));
- }, [inventoryLotDetails, appliedFilters, mode]);
-
- const sortedDetails = useMemo(() => {
- const list = [...filteredDetails];
- if (mode !== "approved") {
- return list.sort((a, b) =>
- (a.stockTakeSection || "").localeCompare(b.stockTakeSection || "", undefined, {
- numeric: true,
- sensitivity: "base",
- })
- );
- }
- const key = approvedSortKey ?? "stockTakeSection";
- const mul = approvedSortDir === "asc" ? 1 : -1;
- return list.sort((a, b) => {
- let cmp = 0;
- switch (key) {
- case "stockTakeEndTime":
- cmp =
- parseDateTimeMs(a.approverTime ?? a.stockTakeEndTime) -
- parseDateTimeMs(b.approverTime ?? b.stockTakeEndTime);
- break;
- case "stockTakeSection":
- cmp = (a.stockTakeSection || "").localeCompare(b.stockTakeSection || "", undefined, {
- numeric: true,
- sensitivity: "base",
- });
- break;
- case "item":
- cmp = `${a.itemCode || ""} ${a.itemName || ""}`.localeCompare(
- `${b.itemCode || ""} ${b.itemName || ""}`,
- undefined,
- { numeric: true, sensitivity: "base" }
- );
- break;
- case "stockTakerName":
- cmp = (a.stockTakerName || "").localeCompare(b.stockTakerName || "", undefined, {
- numeric: true,
- sensitivity: "base",
- });
- break;
- case "variance":
- cmp = Number(a.varianceQty ?? 0) - Number(b.varianceQty ?? 0);
- break;
- default:
- cmp = 0;
- }
- return cmp * mul;
- });
- }, [filteredDetails, mode, approvedSortKey, approvedSortDir]);
- const handleApprovedSort = useCallback((property: ApprovedSortKey) => {
- if (approvedSortKey === property) {
- setApprovedSortDir((d) => (d === "asc" ? "desc" : "asc"));
- } else {
- setApprovedSortKey(property);
- setApprovedSortDir("asc");
- }
- }, [approvedSortKey]);
-
- const handleSaveApproverStockTake = useCallback(
- async (detail: InventoryLotDetailResponse) => {
- if (mode === "approved") return;
- if (!selectedSession || !currentUserId) {
- return;
- }
-
- const built = buildApproverSaveRequest(
- detail,
- qtySelection,
- approverQty,
- approverBadQty,
- currentUserId,
- t
- );
- if (!built.ok) {
- if (built.reason === "skip_approver_empty") {
- onSnackbar(t("Approver input empty; save skipped, row remains pending"), "warning");
- return;
- }
- onSnackbar(built.message, "error");
- return;
- }
-
- const { request, goodQty, finalQty, finalBadQty, selection } = built;
-
- setSaving(true);
- try {
- await saveApproverStockTakeRecord(request, selectedSession.stockTakeId);
-
- 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) =>
- d.id === detail.id
- ? {
- ...d,
- finalQty: goodQty,
- approverQty: selection === "approver" ? finalQty : d.approverQty,
- approverBadQty: selection === "approver" ? finalBadQty : d.approverBadQty,
- stockTakeRecordStatus: "completed",
- }
- : d
- )
- );
- } catch (e: any) {
- console.error("Save approver stock take record error:", e);
- let errorMessage = t("Failed to save approver stock take record");
-
- if (e?.message) {
- errorMessage = e.message;
- } else if (e?.response) {
- try {
- const errorData = await e.response.json();
- errorMessage = errorData.message || errorData.error || errorMessage;
- } catch {
- }
- }
-
- onSnackbar(errorMessage, "error");
- } finally {
- setSaving(false);
- }
- },
- [selectedSession, currentUserId, qtySelection, approverQty, approverBadQty, t, onSnackbar, mode, qtyGapWarnPercent]
- );
-
- const handleUpdateStatusToNotMatch = useCallback(
- async (detail: InventoryLotDetailResponse) => {
- if (mode === "approved") return;
- if (!detail.stockTakeRecordId) {
- onSnackbar(t("Stock take record ID is required"), "error");
- return;
- }
-
- setUpdatingStatus(true);
- try {
- await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId);
-
- onSnackbar(t("Stock take record status updated to not match"), "success");
- setInventoryLotDetails((prev) =>
- prev.map((d) =>
- d.id === detail.id ? { ...d, stockTakeRecordStatus: "notMatch" } : d
- )
- );
- } catch (e: any) {
- console.error("Update stock take record status error:", e);
- let errorMessage = t("Failed to update stock take record status");
-
- if (e?.message) {
- errorMessage = e.message;
- } else if (e?.response) {
- try {
- const errorData = await e.response.json();
- errorMessage = errorData.message || errorData.error || errorMessage;
- } catch {
- }
- }
-
- onSnackbar(errorMessage, "error");
- } finally {
- setUpdatingStatus(false);
- }
- },
- [t, onSnackbar]
- );
- const blockNonIntegerKeys = (e: React.KeyboardEvent<HTMLInputElement>) => {
- // 禁止小数点、逗号、科学计数、正负号
- if ([".", ",", "e", "E", "+", "-"].includes(e.key)) {
- e.preventDefault();
- }
- };
-
- const sanitizeIntegerInput = (value: string) => {
- // 只保留数字
- return value.replace(/[^\d]/g, "");
- };
-
- const sanitizePositiveDecimalInput = (value: string) => {
- const unsigned = value.replace(/[^\d.]/g, "");
- const parts = unsigned.split(".");
- return parts.length <= 1 ? unsigned : `${parts[0]}.${parts.slice(1).join("")}`;
- };
-
- const isIntegerString = (value: string) => /^\d+$/.test(value);
- const batchApproverSaveRecords = useMemo(
- () =>
- currentUserId
- ? buildBatchApproverSaveRequests(
- sortedDetails,
- qtySelection,
- approverQty,
- approverBadQty,
- currentUserId,
- t
- )
- : [],
- [sortedDetails, qtySelection, approverQty, approverBadQty, currentUserId, t]
- );
-
- const batchSaveSectionLabels = useMemo(() => {
- const labels = new Set<string>();
- sortedDetails.forEach((d) => {
- const sec = d.stockTakeSection?.trim();
- if (sec) labels.add(sec);
- });
- return Array.from(labels).sort((a, b) => a.localeCompare(b));
- }, [sortedDetails]);
-
- const handleOpenBatchSaveConfirm = useCallback(() => {
- if (mode === "approved") return;
- if (!selectedSession || !currentUserId) return;
- if (inventoryLotDetails.length === 0) {
- onSnackbar(t("No rows loaded; set search criteria and search first"), "warning");
- return;
- }
- if (batchApproverSaveRecords.length === 0) {
- onSnackbar(t("No valid records to batch save"), "warning");
- return;
- }
- setOpenBatchSaveConfirmDialog(true);
- }, [
- mode,
- selectedSession,
- currentUserId,
- inventoryLotDetails.length,
- batchApproverSaveRecords.length,
- onSnackbar,
- t,
- ]);
-
- const handleBatchSubmitAll = useCallback(async () => {
- if (batchSaveInFlightRef.current) return;
- if (mode === "approved") return;
- if (!selectedSession || !currentUserId) return;
-
- const records = buildBatchApproverSaveRequests(
- sortedDetails,
- qtySelection,
- approverQty,
- approverBadQty,
- currentUserId,
- t
- );
- if (records.length === 0) {
- onSnackbar(t("No valid records to batch save"), "warning");
- return;
- }
-
- batchSaveInFlightRef.current = true;
- setBatchSaving(true);
- setOpenBatchSaveConfirmDialog(false);
-
- try {
- const result = await batchSaveApproverStockTakeRecordsByIds({
- stockTakeId: selectedSession.stockTakeId,
- approverId: currentUserId,
- recordIds: records
- .map((r) => r.stockTakeRecordId)
- .filter((id): id is number => typeof id === "number" && id > 0),
- records,
- });
-
- onSnackbar(
- t("Batch approver save completed: {{success}} success, {{errors}} errors", {
- success: result.successCount,
- errors: result.errorCount,
- }),
- result.errorCount > 0 ? "warning" : "success",
- );
-
- if (result.successCount > 0) {
- await loadDetails();
- }
- } catch (e: unknown) {
- console.error("handleBatchSubmitAll (all): Error:", e);
- let errorMessage = t("Failed to batch save approver stock take records");
- if (e instanceof Error && e.message) {
- errorMessage = e.message;
- }
- onSnackbar(errorMessage, "error");
- } finally {
- setBatchSaving(false);
- batchSaveInFlightRef.current = false;
- }
- }, [
- mode,
- selectedSession,
- currentUserId,
- sortedDetails,
- qtySelection,
- approverQty,
- approverBadQty,
- t,
- onSnackbar,
- loadDetails,
- ]);
-
- const formatNumber = (num: number | null | undefined): string => {
- if (num == null) return "0";
- return num.toLocaleString("en-US", {
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- });
- };
-
- const uniqueWarehouses = useMemo(
- () =>
- Array.from(
- new Set(
- inventoryLotDetails
- .map((detail) => detail.warehouse)
- .filter((warehouse) => warehouse && warehouse.trim() !== "")
- )
- ).join(", "),
- [inventoryLotDetails]
- );
-
- const formatRecordEndTime = (detail: InventoryLotDetailResponse) => {
- const ms = parseDateTimeMs(detail.approverTime ?? detail.stockTakeEndTime);
- if (!ms) return "-";
- return dayjs(ms).format("YYYY-MM-DD HH:mm");
- };
- const rows = sortedDetails;
-
- const columns = useMemo<GridColDef<InventoryLotDetailResponse>[]>(() => {
- const cols: GridColDef<InventoryLotDetailResponse>[] = [];
-
- if (mode === "approved") {
- cols.push({
- field: "approverTime",
- headerName: t("Approver Time"),
- minWidth: 150,
- flex: 0.6,
- sortable: false,
- renderCell: (params) => (
- <Typography variant="caption" color="text.secondary">
- {formatRecordEndTime(params.row)}
- </Typography>
- ),
- });
- }
-
- cols.push(
- {
- field: "warehouseLocation",
- headerName: t("Warehouse Location"),
- minWidth: 220,
- flex: 1,
- sortable: false,
- renderCell: (params) => (
- <Stack spacing={0.5}>
- <Typography variant="body2">
- <strong>
- {params.row.stockTakeSection || "-"}{" "}
- {params.row.stockTakeSectionDescription || "-"}
- </strong>
- </Typography>
- <Typography variant="body2">{params.row.warehouseCode || "-"}</Typography>
- </Stack>
- ),
- },
- {
- field: "itemLot",
- headerName: t("Item-lotNo-ExpiryDate"),
- minWidth: 160,
- flex: 1,
- sortable: false,
- renderCell: (params) => (
- <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 || "-"}
- </Typography>
- <Box>{params.row.lotNo || "-"}</Box>
- <Box>
- {params.row.expiryDate
- ? dayjs(params.row.expiryDate).format(OUTPUT_DATE_FORMAT)
- : "-"}
- </Box>
- </Stack>
- ),
- },
- {
- field: "uom",
- headerName: t("UOM"),
- minWidth: 90,
- flex: 0.4,
- sortable: false,
- valueGetter: (params) => params.row.uom || "-",
- },
- {
- field: "qtyBlock",
- headerName: t("Stock Take Qty Data and Variance Analysis"),
- minWidth: 480,
- flex: 3.2,
- sortable: false,
- renderCell: (params: GridRenderCellParams<InventoryLotDetailResponse>) => {
- const detail = params.row;
-
- const hasFirst =
- detail.firstStockTakeQty != null && detail.firstStockTakeQty >= 0;
- const hasSecond =
- detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0;
-
- const selection =
- qtySelection[detail.id] || (hasSecond ? "second" : "first");
-
- const canApprover =
- mode === "pending"
- ? true
- : selection === "approver" &&
- (detail.approverQty != null || detail.approverBadQty != null);
-
- const showRadioBlock = mode === "approved" || detail.finalQty == null;
- const bookQty = detail.bookQty ?? detail.availableQty ?? 0;
- const selectedQty =
- selection === "first"
- ? (detail.firstStockTakeQty ?? 0)
- : selection === "second"
- ? (detail.secondStockTakeQty ?? 0)
- : (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0")) || 0;
- const difference = selectedQty - bookQty;
- const approverQtyNum = parseFloat(approverQty[detail.id] || "0") || 0;
- const approverBadQtyNum = parseFloat(approverBadQty[detail.id] || "0") || 0;
- const approverGoodQty = approverQtyNum - approverBadQtyNum;
- const variancePercentageRaw =
- bookQty !== 0
- ? (difference / bookQty) * 100
- : difference !== 0
- ? difference > 0
- ? 100
- : -100
- : 0;
- const variancePercentage = roundDownPercent(variancePercentageRaw);
- const hasVariance = difference !== 0;
- const pctLabel = `${variancePercentage}%`;
-
- const summaryLine = (label: string, value: string, valueColor?: string) => (
- <Stack
- key={label}
- direction="row"
- justifyContent="space-between"
- alignItems="center"
- spacing={1}
- >
- <Typography variant="body2" color="text.secondary" noWrap>
- {label}
- </Typography>
- <Typography
- variant="body2"
- fontWeight={700}
- sx={{ color: valueColor ?? "text.primary", whiteSpace: "nowrap" }}
- >
- {value}
- </Typography>
- </Stack>
- );
-
- return (
- <Box sx={{ width: "100%" }}>
- {!showRadioBlock ? (
- <Typography variant="body2" color="text.secondary">
- -
- </Typography>
- ) : (
- <Stack
- direction="row"
- spacing={1}
- alignItems="stretch"
- sx={{ width: "100%", py: 0.5 }}
- >
- <Stack spacing={1} sx={{ minWidth: 0, justifyContent: "center" }}>
- {hasFirst && (
- <Stack direction="row" spacing={0.5} alignItems="center">
- <Radio
- size="small"
- checked={selection === "first"}
- disabled={mode === "approved"}
- onChange={() =>
- setQtySelection({
- ...qtySelection,
- [detail.id]: "first",
- })
- }
- />
- <Typography variant="body2" component="span">
- {t("First")}:{" "}
- <StockTakeQtyWithUnit
- qty={formatNumber(
- (detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0)
- )}
- uomShortDesc={detail.uomShortDesc}
- />
- </Typography>
- </Stack>
- )}
-
- {hasSecond && (
- <Stack direction="row" spacing={0.5} alignItems="center">
- <Radio
- size="small"
- checked={selection === "second"}
- disabled={mode === "approved"}
- onChange={() =>
- setQtySelection({
- ...qtySelection,
- [detail.id]: "second",
- })
- }
- />
- <Typography variant="body2" component="span">
- {t("Second")}:{" "}
- <StockTakeQtyWithUnit
- qty={formatNumber(
- (detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0)
- )}
- uomShortDesc={detail.uomShortDesc}
- />
- </Typography>
-
- </Stack>
- )}
-
- {canApprover && (
- <Stack direction="row" spacing={0.5} alignItems="center" flexWrap="wrap">
- <Radio
- size="small"
- checked={selection === "approver"}
- disabled={mode === "approved"}
- onChange={() =>
- setQtySelection({
- ...qtySelection,
- [detail.id]: "approver",
- })
- }
- />
- <Typography variant="body2" component="span" sx={{ mr: 0.5 }}>
- {t("Approver Input")}:
- </Typography>
- <TextField
- 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);
- setApproverQty({
- ...approverQty,
- [detail.id]: clean,
- });
- }}
- InputProps={{
- endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc),
- }}
- sx={{
- width: 120,
- minWidth: 120,
- "& .MuiInputBase-input": {
- py: 0.5,
- px: 1,
- },
- }}
- // placeholder={t("Stock Take Qty")}
- 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)}
- </Typography>
- */}
- </Stack>
- )}
- </Stack>
-
- <Box
- sx={{
- flexShrink: 0,
- minWidth: 168,
- maxWidth: 200,
- bgcolor: "grey.50",
- borderRadius: 1,
- border: "1px solid",
- borderColor: "divider",
- p: 1.25,
- }}
- >
- <Stack spacing={0.75}>
- {summaryLine(
- `${t("Selected Qty")}:`,
- [formatNumber(selectedQty), detail.uomShortDesc?.trim()].filter(Boolean).join(" ")
- )}
- {summaryLine(`${t("Book Qty")}:`, formatNumber(bookQty))}
- {summaryLine(
- `${t("Inventory Difference")}:`,
- formatNumber(difference),
- hasVariance ? "error.main" : "text.primary"
- )}
- <Box
- sx={{
- mt: 0.25,
- py: 0.5,
- px: 1,
- borderRadius: 1,
- textAlign: "center",
- bgcolor: hasVariance ? "error.light" : "grey.200",
- }}
- >
- <Typography
- variant="caption"
- fontWeight={600}
- sx={{ color: hasVariance ? "error.dark" : "text.secondary" }}
- >
- {t("variance Percentage")}: {pctLabel}
- </Typography>
- </Box>
- </Stack>
- </Box>
- </Stack>
- )}
- </Box>
- );
- },
- }
- );
- {/*
- if (mode === "approved") {
- cols.push({
- field: "varianceQty",
- headerName: t("Variance"),
- minWidth: 110,
- flex: 0.5,
- sortable: false,
- valueGetter: (params) => String(params.row.varianceQty ?? 0),
- });
- }
- */}
- cols.push(
- /*
- {
- field: "remarks",
- headerName: t("Remark"),
- minWidth: 120,
- flex: 0.8,
- sortable: false,
- renderCell: (params) => (
- <Typography variant="body2" sx={{ whiteSpace: "normal", wordBreak: "break-word" }}>
- {params.row.remarks || "-"}
- </Typography>
- ),
- },
- */
- {
- field: "stockTakeRecordStatus",
- headerName: t("Record Status"),
- minWidth: 130,
- flex: 0.6,
- sortable: false,
- renderCell: (params) => {
- const raw = params.row.stockTakeRecordStatus;
- const status = String(raw ?? "").trim().toLowerCase();
-
- const colorMap: Record<string, "success" | "warning" | "info" | "default" | "error"> = {
- completed: "success",
- notmatch: "warning",
- pending: "info",
- "not_match": "warning",
- };
-
- return (
- <Chip
- size="small"
- label={t(`${status}`) || "-"}
- color={colorMap[status] ?? "default"}
- variant="outlined"
- />
- );
- }
- },
- {
- field: "stockTakerName",
- headerName: t("Picker"),
- minWidth: 140,
- flex: 0.7,
- sortable: false,
- valueGetter: (params) => params.row.stockTakerName || "-",
- },
- {
- field: "actions",
- headerName: t("Action"),
- minWidth: 110,
- flex: 0.9,
- sortable: false,
- renderCell: (params) => {
- const detail = params.row;
- const hasSecond = detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0;
- const selection =
- qtySelection[detail.id] || (hasSecond ? "second" : "first");
-
- return (
- <Stack direction="column" spacing={1} sx={{ width: "100%" }}>
- {mode === "pending" && (
- <>
- <Box>
- <Button
- size="small"
- variant="contained"
- onClick={() => handleSaveApproverStockTake(detail)}
- disabled={saving}
- >
- {t("Save")}
- </Button>
- </Box>
- <Box>
- <Button
- size="small"
- variant="outlined"
- color="warning"
- onClick={() => handleUpdateStatusToNotMatch(detail)}
- disabled={updatingStatus||hasSecond}
- >
- {t("Not Match")}
- </Button>
- </Box>
- </>
- )}
-
- {mode === "approved" && (
- <Typography variant="caption" color="text.secondary">
- {selection === "first"
- ? t("First")
- : selection === "second"
- ? t("Second")
- : t("Approver Input")}
- </Typography>
- )}
- </Stack>
- );
- },
- }
- );
-
- return cols;
- }, [
- mode,
- t,
- qtySelection,
- approverQty,
- approverBadQty,
- saving,
- updatingStatus,
- blockNonIntegerKeys,
- sanitizeIntegerInput,
- handleSaveApproverStockTake,
- handleUpdateStatusToNotMatch,
- formatRecordEndTime,
- ]);
- const effectivePageSize =
- pageSize === "all" ? Math.max(total || 0, 0) : (pageSize as number);
-
- return (
- <Box>
- {onBack && (
- <Button
- onClick={onBack}
- sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}
- >
- {t("Back to List")}
- </Button>
- )}
- <Stack
- direction="row"
- justifyContent="space-between"
- alignItems="center"
- sx={{ mb: 2 }}
- >
- <Typography variant="h6" sx={{ mb: 2 }}>
-
- {uniqueWarehouses && (
- <> {t("Warehouse")}: {uniqueWarehouses}</>
- )}
- </Typography>
-
- <Stack direction="row" spacing={2} alignItems="center">
- {mode === "pending" && (
- <Button
- variant="contained"
- color="primary"
- onClick={handleOpenBatchSaveConfirm}
- disabled={batchSaving}
- >
- {t("Batch Save All")}
- </Button>
- )}
- </Stack>
- </Stack>
- <Accordion
- expanded={showFilters}
- onChange={(_, expanded) => setShowFilters(expanded)}
- elevation={0}
- sx={{ mb: 2, border: "1px solid", borderColor: "divider", borderRadius: 1 }}
- >
- <AccordionSummary
- expandIcon={<ExpandMoreIcon />}
- sx={{
- minHeight: 36,
- px: 2,
- "&.Mui-expanded": { minHeight: 36 },
- "& .MuiAccordionSummary-content, & .MuiAccordionSummary-content.Mui-expanded": { my: 0.75 },
- }}
- >
- <Typography variant="body2">
- {(<strong>{t("Search Criteria")}</strong>)}
- </Typography>
- </AccordionSummary>
-
- <AccordionDetails sx={{ pt: 0, pb: 1.5, px: 2 }}>
- <Box sx={{ width: "100%" }}>
- <Card elevation={0}>
- <CardContent sx={{ p: 0, "&:last-child": { pb: 0 } }}>
- <Grid container spacing={2} alignItems="flex-start">
- <Grid item xs={12}>
- <Tabs
- value={searchKind}
- onChange={(_, value: ApproverSearchKind) => switchSearchKind(value)}
- sx={{ minHeight: 36 }}
- >
- <Tab
- value="location"
- label={t("Search by location")}
- sx={{ minHeight: 36, py: 0.5, textTransform: "none" }}
- />
- <Tab
- value="section"
- label={t("Search by stock take section")}
- sx={{ minHeight: 36, py: 0.5, textTransform: "none" }}
- />
- </Tabs>
- </Grid>
- {searchKind === "location" && (
- <Grid item xs={12}>
- <Stack spacing={1.25}>
- <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
- <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600 }}>
- {t("Store ID")}
- </Typography>
- <ToggleButtonGroup
- exclusive
- size="small"
- value={locationPick.storeId || null}
- onChange={(_, next: string | null) => {
- applyLocationPick({ storeId: next ?? "", warehouse: "", area: "", slot: "" });
- }}
- >
- {floorOptions.map((floor) => (
- <ToggleButton key={floor} value={floor} sx={{ px: 1.5, textTransform: "none" }}>
- {floor === APPROVER_LOCATION_ALL ? t("All") : floor}
- </ToggleButton>
- ))}
- </ToggleButtonGroup>
- </Box>
- <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}>
- <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}>
- {t("Warehouse")}
- </Typography>
- <Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, opacity: locationPick.storeId ? 1 : 0.45 }}>
- {locationWarehouseRows.map((row, rowIndex) => (
- <ToggleButtonGroup
- key={row.join("-") || rowIndex}
- exclusive
- size="small"
- disabled={!locationPick.storeId}
- value={locationPick.storeId ? locationPick.warehouse || null : null}
- onChange={(_, next: string | null) => {
- if (next == null) return;
- applyLocationPick({ ...locationPick, warehouse: next, area: "", slot: "" });
- }}
- sx={{
- "& .MuiToggleButtonGroup-grouped": {
- width: APPROVER_LOCATION_BUTTON_WIDTH,
- minWidth: APPROVER_LOCATION_BUTTON_WIDTH,
- maxWidth: APPROVER_LOCATION_BUTTON_WIDTH,
- px: 0,
- },
- }}
- >
- {row.map((zone) => (
- <ToggleButton key={zone} value={zone} sx={{ textTransform: "none" }}>
- {zone === APPROVER_LOCATION_ALL ? t("All") : zone}
- </ToggleButton>
- ))}
- </ToggleButtonGroup>
- ))}
- </Box>
- {!locationPick.storeId ? (
- <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
- {t("Select floor first")}
- </Typography>
- ) : null}
- </Box>
- <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}>
- <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}>
- {t("Area")}
- </Typography>
- <Box
- sx={{
- display: "flex",
- flexDirection: "column",
- gap: 0.75,
- opacity: locationPick.storeId && locationPick.warehouse ? 1 : 0.45,
- }}
- >
- {locationAreaRows.map((row, rowIndex) => (
- <ToggleButtonGroup
- key={row.join("-") || rowIndex}
- exclusive
- size="small"
- disabled={!locationPick.storeId || !locationPick.warehouse}
- value={locationPick.warehouse ? locationPick.area || null : null}
- onChange={(_, next: string | null) => {
- if (next == null) return;
- applyLocationPick({ ...locationPick, area: next, slot: "" });
- }}
- sx={{
- "& .MuiToggleButtonGroup-grouped": {
- width: APPROVER_LOCATION_BUTTON_WIDTH,
- minWidth: APPROVER_LOCATION_BUTTON_WIDTH,
- maxWidth: APPROVER_LOCATION_BUTTON_WIDTH,
- px: 0,
- },
- }}
- >
- {row.map((area) => (
- <ToggleButton key={area} value={area} sx={{ textTransform: "none" }}>
- {area === APPROVER_LOCATION_ALL ? t("All") : area}
- </ToggleButton>
- ))}
- </ToggleButtonGroup>
- ))}
- </Box>
- {!locationPick.warehouse ? (
- <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
- {locationPick.storeId ? t("Select warehouse first") : t("Select floor first")}
- </Typography>
- ) : null}
- </Box>
- <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}>
- <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}>
- {t("Slot")}
- </Typography>
- <Box
- sx={{
- display: "flex",
- flexDirection: "column",
- gap: 0.75,
- opacity: locationPick.storeId && locationPick.warehouse && locationPick.area ? 1 : 0.45,
- }}
- >
- {locationSlotRows.map((row, rowIndex) => (
- <ToggleButtonGroup
- key={row.join("-") || rowIndex}
- exclusive
- size="small"
- disabled={!locationPick.storeId || !locationPick.warehouse || !locationPick.area}
- value={locationPick.area ? locationPick.slot || null : null}
- onChange={(_, next: string | null) => {
- if (next == null) return;
- applyLocationPick({ ...locationPick, slot: next });
- }}
- sx={{
- "& .MuiToggleButtonGroup-grouped": {
- width: APPROVER_LOCATION_BUTTON_WIDTH,
- minWidth: APPROVER_LOCATION_BUTTON_WIDTH,
- maxWidth: APPROVER_LOCATION_BUTTON_WIDTH,
- px: 0,
- },
- }}
- >
- {row.map((slot) => (
- <ToggleButton key={slot} value={slot} sx={{ textTransform: "none" }}>
- {slot === APPROVER_LOCATION_ALL ? t("All") : slot}
- </ToggleButton>
- ))}
- </ToggleButtonGroup>
- ))}
- </Box>
- {!locationPick.area ? (
- <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
- {locationPick.warehouse ? t("Select area first") : locationPick.storeId ? t("Select warehouse first") : t("Select floor first")}
- </Typography>
- ) : null}
- </Box>
- </Stack>
- </Grid>
- )}
- {searchKind === "section" && (
- <Grid item xs={12} md={4}>
- <FormControl fullWidth size="small" sx={approverSearchFieldSx}>
- <InputLabel>{t("Stock Take Section")}</InputLabel>
- <Select
- value={
- searchSectionDescription === "All" ||
- sectionDescriptionOptions.includes(searchSectionDescription)
- ? searchSectionDescription
- : "All"
- }
- MenuProps={approverSearchMenuProps}
- label={t("Stock Take Section")}
- onChange={(e) => {
- const nextDesc = e.target.value;
- const allowed = new Set<string>();
- roundWarehouses.forEach((row) => {
- if (nextDesc !== "All" && (row.stockTakeSectionDescription?.trim() || "") !== nextDesc) return;
- const code = row.stockTakeSection?.trim();
- if (code) allowed.add(code);
- });
- const kept = selectedApproverSectionCodes(searchStockTakeSession).filter((code) => allowed.has(code));
- handleSearch({
- searchKind: "section",
- sectionDescription: nextDesc,
- stockTakeSession: kept.join(", "),
- });
- }}
- >
- <MenuItem value="All">{t("All")}</MenuItem>
- {sectionDescriptionOptions.map((desc) => (
- <MenuItem key={desc} value={desc}>
- {desc}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- </Grid>
- )}
- {searchKind === "section" && (
- <Grid item xs={12} md={4}>
- <FormControl fullWidth size="small" sx={approverSearchFieldSx}>
- <InputLabel>{t("Stock Take Section code")}</InputLabel>
- <Select
- multiple
- MenuProps={approverSearchMenuProps}
- value={selectedApproverSectionCodes(searchStockTakeSession).filter((code) =>
- stockTakeSectionOptions.includes(code)
- )}
- label={t("Stock Take Section code")}
- onChange={(e) => {
- const raw = e.target.value;
- const picked = (typeof raw === "string" ? raw.split(",") : raw)
- .map((part) => part.trim())
- .filter(Boolean);
- const descriptions = new Set<string>();
- if (picked.length > 0) {
- const pickedSet = new Set(picked);
- roundWarehouses.forEach((row) => {
- const code = row.stockTakeSection?.trim();
- if (!code || !pickedSet.has(code)) return;
- const desc = row.stockTakeSectionDescription?.trim();
- if (desc) descriptions.add(desc);
- });
- }
- const nextDesc =
- searchSectionDescription !== "All" &&
- picked.length > 0 &&
- !descriptions.has(searchSectionDescription)
- ? "All"
- : searchSectionDescription;
- handleSearch({
- searchKind: "section",
- sectionDescription: nextDesc,
- stockTakeSession: picked.join(", "),
- });
- }}
- renderValue={(selected) => (selected as string[]).join(", ")}
- >
- {stockTakeSectionOptions.map((code) => (
- <MenuItem key={code} value={code}>
- {code}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- </Grid>
- )}
- <Grid item xs={12} md={4}>
- <TextField
- fullWidth
- sx={approverSearchFieldSx}
- label={t("Item")}
- value={searchItemKeyword}
- onChange={(e) => setSearchItemKeyword(e.target.value)}
- onBlur={() => handleSearch({ itemKeyword: searchItemKeyword })}
- onKeyDown={(e) => {
- if (e.key === "Enter") handleSearch({ itemKeyword: searchItemKeyword });
- }}
- />
- </Grid>
- {mode === "pending" && (
- <Grid item xs={12} md={4}>
- <Stack spacing={0.5}>
- <FormControl fullWidth size="small" sx={approverSearchFieldSx}>
- <InputLabel>{t("Record Status")}</InputLabel>
- <Select
- MenuProps={approverSearchMenuProps}
- value={searchStatus}
- label={t("Record Status")}
- onChange={(e) => handleSearch({ status: e.target.value })}
- >
- <MenuItem value="pending">{t("Pending")}</MenuItem>
- <MenuItem value="notMatch">{t("Not Match")}</MenuItem>
- <MenuItem value="pass">{t("Pass")}</MenuItem>
- </Select>
- </FormControl>
- <Stack direction="row" spacing={1} sx={{ pt: 0.5 }}>
- <Button variant="outlined" onClick={handleResetSearch}>
- {t("Reset")}
- </Button>
- <Button variant="contained" onClick={() => handleSearch()}>
- {t("Search")}
- </Button>
- </Stack>
- </Stack>
- </Grid>
- )}
- <Grid item xs={12} md={4}>
- <Stack spacing={0.5}>
- <TextField
- fullWidth
- size="small"
- type="number"
- label={t("Variance %")}
- value={searchVariancePercentTolerance}
- onChange={(e) => {
- const next = sanitizePositiveDecimalInput(e.target.value);
- setSearchVariancePercentTolerance(next);
- handleSearch({ variancePercentTolerance: next });
- }}
- inputProps={{ min: 0, step: 0.1 }}
- sx={approverSearchFieldSx}
- />
- <FormControlLabel
- sx={{ ml: 0, mr: 0 }}
- control={
- <Checkbox
- size="small"
- checked={searchVarianceFilterInclusive}
- onChange={(e) => {
- setSearchVarianceFilterInclusive(e.target.checked);
- handleSearch({ varianceFilterInclusive: e.target.checked });
- }}
- />
- }
- label={
- <Typography variant="body2">{t("Variance filter inclusive only")}</Typography>
- }
- />
- <Typography variant="caption" color="text.secondary" sx={{ pl: 0.5 }}>
- {searchVarianceFilterInclusive
- ? t("Variance filter inclusive range hint", {
- value: searchVariancePercentTolerance || "0",
- op: searchVarianceFilterStrict ? "<" : "≤",
- })
- : t("Variance filter exclusive range hint", {
- value: searchVariancePercentTolerance || "0",
- op: searchVarianceFilterStrict ? ">" : "≥",
- })}
- </Typography>
- {mode !== "pending" && (
- <Stack direction="row" spacing={1} sx={{ pt: 0.5 }}>
- <Button variant="outlined" onClick={handleResetSearch}>
- {t("Reset")}
- </Button>
- <Button variant="contained" onClick={() => handleSearch()}>
- {t("Search")}
- </Button>
- </Stack>
- )}
- </Stack>
- </Grid>
- </Grid>
- </CardContent>
- </Card>
- </Box>
- </AccordionDetails>
- </Accordion>
- <Typography variant="body2" color="text.secondary">
- {t("Total")}: {total} | {t("Shown")}: {sortedDetails.length}
- </Typography>
- <Box sx={{ width: "100%", height: 700 }}>
- <StyledDataGrid
- rows={sortedDetails}
- columns={columns}
- getRowId={(row) => row.id}
- loading={loadingDetails}
- disableRowSelectionOnClick
- paginationMode="client" // ← important: client-side
- pageSizeOptions={[50, 100, 200, 500]}
- getRowHeight={() => "auto"}
- sx={{
- backgroundColor: "transparent",
- "& .MuiDataGrid-columnHeaders": { backgroundColor: "#fff" },
- "& .MuiDataGrid-cell": { py: 1, alignItems: "flex-start" },
- "& .MuiDataGrid-row": { minHeight: 96 },
- }}
- />
- </Box>
-
- <Dialog
- open={openBatchSaveConfirmDialog}
- onClose={() => {
- if (!batchSaving) setOpenBatchSaveConfirmDialog(false);
- }}
- maxWidth="sm"
- fullWidth
- >
- <DialogTitle>{t("Confirm batch save approver")}</DialogTitle>
- <DialogContent dividers>
- <Typography variant="body2" sx={{ mb: 2 }}>
- {t("Batch save confirm message", { count: batchApproverSaveRecords.length })}
- </Typography>
- {batchSaveSectionLabels.length > 0 ? (
- <Box>
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
- {t("Stock take sections in current list", { count: batchSaveSectionLabels.length })}
- </Typography>
- <Box
- sx={{
- maxHeight: 240,
- overflowY: "auto",
- border: 1,
- borderColor: "divider",
- borderRadius: 1,
- p: 1.5,
- }}
- >
- <Stack spacing={0.5}>
- {batchSaveSectionLabels.map((section) => (
- <Typography key={section} variant="body2">
- {section}
- </Typography>
- ))}
- </Stack>
- </Box>
- </Box>
- ) : null}
- </DialogContent>
- <DialogActions sx={{ px: 3, py: 2 }}>
- <Button onClick={() => setOpenBatchSaveConfirmDialog(false)} disabled={batchSaving}>
- {t("Cancel")}
- </Button>
- <Button
- variant="contained"
- color="primary"
- onClick={handleBatchSubmitAll}
- disabled={batchSaving}
- >
- {batchSaving ? <CircularProgress size={20} /> : t("Confirm")}
- </Button>
- </DialogActions>
- </Dialog>
- </Box>
- );
- };
-
- export default ApproverStockTakeAll;
|