FPSMS-frontend
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 

2131 rader
79 KiB

  1. "use client";
  2. import {
  3. Box,
  4. Button,
  5. Stack,
  6. Typography,
  7. Chip,
  8. CircularProgress,
  9. Table,
  10. TableBody,
  11. TableCell,
  12. TableContainer,
  13. TableHead,
  14. TableRow,
  15. Paper,
  16. TextField,
  17. Radio,
  18. TablePagination,
  19. TableSortLabel,
  20. Card,
  21. CardContent,
  22. Grid,
  23. FormControl,
  24. InputLabel,
  25. Select,
  26. MenuItem,
  27. FormControlLabel,
  28. Tab,
  29. Tabs,
  30. ToggleButton,
  31. ToggleButtonGroup,
  32. Checkbox,
  33. Dialog,
  34. DialogTitle,
  35. DialogContent,
  36. DialogActions,
  37. } from "@mui/material";
  38. import { useState, useCallback, useEffect, useMemo, useRef } from "react";
  39. import { Collapse } from "@mui/material";
  40. import Accordion from "@mui/material/Accordion";
  41. import AccordionSummary from "@mui/material/AccordionSummary";
  42. import AccordionDetails from "@mui/material/AccordionDetails";
  43. import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
  44. import { useTranslation } from "react-i18next";
  45. import {
  46. AllPickedStockTakeListReponse,
  47. InventoryLotDetailResponse,
  48. SaveApproverStockTakeRecordRequest,
  49. saveApproverStockTakeRecord,
  50. batchSaveApproverStockTakeRecordsByIds,
  51. getApproverInventoryLotDetailsAllPending,
  52. getApproverInventoryLotDetailsAllApproved,
  53. updateStockTakeRecordStatusToNotMatch,
  54. } from "@/app/api/stockTake/actions";
  55. import { fetchWarehouseListClient } from "@/app/api/warehouse/client";
  56. import type { WarehouseResult } from "@/app/api/warehouse";
  57. import { stockTakeQtyEndAdornment, StockTakeQtyWithUnit } from "./stockTakeQtyAdornment";
  58. import StockTakeQtyGapHint from "./StockTakeQtyGapHint";
  59. import { stockTakeHiddenOnHand, stockTakeQtyGapWarnText } from "./stockTakeQtyGapWarning";
  60. import { useStockTakeQtyGapWarnPercent } from "./useStockTakeQtyGapWarnPercent";
  61. import { useSession } from "next-auth/react";
  62. import { SessionWithTokens } from "@/config/authConfig";
  63. import dayjs from "dayjs";
  64. import { OUTPUT_DATE_FORMAT, roundDownPercent } from "@/app/utils/formatUtil";
  65. import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid';
  66. import StyledDataGrid from "@/components/StyledDataGrid/StyledDataGrid";
  67. import type {
  68. GridPaginationModel,
  69. GridSortModel,
  70. } from "@mui/x-data-grid";
  71. interface ApproverStockTakeAllProps {
  72. selectedSession: AllPickedStockTakeListReponse;
  73. mode: "pending" | "approved";
  74. onBack?: () => void;
  75. onSnackbar: (message: string, severity: "success" | "error" | "warning") => void;
  76. }
  77. type QtySelectionType = "first" | "second" | "approver";
  78. type ApproverSearchKind = "location" | "section";
  79. type ApprovedSortKey =
  80. | "stockTakeEndTime"
  81. | "stockTakeSection"
  82. | "item"
  83. | "stockTakerName"
  84. | "variance";
  85. const APPROVER_SEARCH_FIELD_HEIGHT = 56;
  86. const APPROVER_MENU_MAX_HEIGHT = 320;
  87. const APPROVER_LOCATION_ALL = "ALL";
  88. const APPROVER_LOCATION_BUTTONS_PER_ROW = 15;
  89. const APPROVER_LOCATION_BUTTON_WIDTH = 80;
  90. const approverSearchFieldSx = {
  91. "& .MuiOutlinedInput-root": {
  92. height: APPROVER_SEARCH_FIELD_HEIGHT,
  93. minHeight: APPROVER_SEARCH_FIELD_HEIGHT,
  94. boxSizing: "border-box",
  95. alignItems: "center",
  96. },
  97. "& .MuiSelect-select": {
  98. height: `${APPROVER_SEARCH_FIELD_HEIGHT}px !important`,
  99. minHeight: "0 !important",
  100. display: "flex",
  101. alignItems: "center",
  102. boxSizing: "border-box",
  103. py: "0 !important",
  104. },
  105. } as const;
  106. const approverSearchMenuProps = {
  107. PaperProps: { sx: { maxHeight: APPROVER_MENU_MAX_HEIGHT } },
  108. } as const;
  109. function chunkApproverOptions<T>(items: T[], size: number): T[][] {
  110. const rows: T[][] = [];
  111. for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));
  112. return rows;
  113. }
  114. function withApproverAllOption(options: string[]): string[] {
  115. return options.length > 1 ? [APPROVER_LOCATION_ALL, ...options] : options;
  116. }
  117. function compareApproverLabel(a: string, b: string): number {
  118. return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
  119. }
  120. function selectedApproverSectionCodes(value: string): string[] {
  121. return value.split(",").map((part) => part.trim()).filter(Boolean);
  122. }
  123. type ApproverLocationPick = { storeId: string; warehouse: string; area: string; slot: string };
  124. function approverWarehouseKeyword(pick: ApproverLocationPick): string {
  125. const warehouse = pick.warehouse && pick.warehouse !== APPROVER_LOCATION_ALL ? pick.warehouse : "";
  126. const area = pick.area && pick.area !== APPROVER_LOCATION_ALL ? pick.area : "";
  127. const slot = pick.slot && pick.slot !== APPROVER_LOCATION_ALL ? pick.slot : "";
  128. if (warehouse && area && slot) return `${warehouse}-${area}-${slot}`;
  129. if (warehouse && area) return `${warehouse}-${area}`;
  130. if (warehouse) return warehouse;
  131. if (pick.storeId && pick.storeId !== APPROVER_LOCATION_ALL) return pick.storeId;
  132. return "";
  133. }
  134. type ApproverSearchFilters = {
  135. sectionDescription: string;
  136. stockTakeSession: string;
  137. itemKeyword: string;
  138. warehouseKeyword: string;
  139. storeId: string;
  140. status: string;
  141. variancePercentTolerance: string;
  142. varianceFilterInclusive: boolean;
  143. varianceFilterStrict: boolean;
  144. };
  145. function defaultApproverSearchFilters(mode: "pending" | "approved"): ApproverSearchFilters {
  146. return {
  147. sectionDescription: "All",
  148. stockTakeSession: "",
  149. itemKeyword: "",
  150. warehouseKeyword: "",
  151. storeId: "All",
  152. status: mode === "pending" ? "pass" : "All",
  153. variancePercentTolerance: "5",
  154. varianceFilterInclusive: false,
  155. varianceFilterStrict: false,
  156. };
  157. }
  158. function approverVariancePercent(detail: InventoryLotDetailResponse): number {
  159. const second = detail.secondStockTakeQty;
  160. const first = detail.firstStockTakeQty;
  161. const selected = second != null && second >= 0 ? second : first ?? 0;
  162. const book = detail.bookQty != null ? detail.bookQty : detail.availableQty ?? 0;
  163. const difference = selected - book;
  164. const raw =
  165. book !== 0 ? (difference / book) * 100 : difference === 0 ? 0 : difference > 0 ? 100 : -100;
  166. return roundDownPercent(raw);
  167. }
  168. function matchesApproverDetailFilters(
  169. detail: InventoryLotDetailResponse,
  170. filters: ApproverSearchFilters,
  171. mode: "pending" | "approved"
  172. ): boolean {
  173. if (filters.sectionDescription && filters.sectionDescription !== "All") {
  174. const desc = (detail.stockTakeSectionDescription || "").trim().toLowerCase();
  175. if (desc !== filters.sectionDescription.trim().toLowerCase()) return false;
  176. }
  177. const sections = selectedApproverSectionCodes(filters.stockTakeSession);
  178. if (sections.length > 0) {
  179. const sec = (detail.stockTakeSection || "").trim().toLowerCase();
  180. const hit = sections.some((part) => {
  181. const wanted = part.toLowerCase();
  182. return sec === wanted || sec.includes(wanted);
  183. });
  184. if (!hit) return false;
  185. }
  186. const itemParts = filters.itemKeyword.split(",").map((part) => part.trim()).filter(Boolean);
  187. if (itemParts.length > 0) {
  188. const code = detail.itemCode || "";
  189. const name = detail.itemName || "";
  190. const hit = itemParts.some(
  191. (part) =>
  192. code.toLowerCase().includes(part.toLowerCase()) || name.toLowerCase().includes(part.toLowerCase())
  193. );
  194. if (!hit) return false;
  195. }
  196. const warehouseParts = filters.warehouseKeyword.split(",").map((part) => part.trim()).filter(Boolean);
  197. if (warehouseParts.length > 0) {
  198. const code = detail.warehouseCode || "";
  199. if (!warehouseParts.some((part) => code.toLowerCase().includes(part.toLowerCase()))) return false;
  200. }
  201. if (filters.storeId && filters.storeId !== "All") {
  202. if ((detail.storeId || "").trim().toLowerCase() !== filters.storeId.trim().toLowerCase()) return false;
  203. }
  204. const statusFilter = mode === "pending" ? filters.status || "pass" : "All";
  205. if (statusFilter !== "All") {
  206. const rowStatus = String(detail.stockTakeRecordStatus ?? "")
  207. .trim()
  208. .toLowerCase()
  209. .replaceAll("_", "");
  210. const wanted = statusFilter.trim().toLowerCase().replaceAll("_", "");
  211. if (rowStatus !== wanted) return false;
  212. }
  213. const tolerance = Number(filters.variancePercentTolerance);
  214. if (filters.variancePercentTolerance.trim() !== "" && Number.isFinite(tolerance) && Math.abs(tolerance) > 0) {
  215. const vp = approverVariancePercent(detail);
  216. const limit = Math.abs(tolerance);
  217. const negative = -limit;
  218. const inclusive = filters.varianceFilterInclusive;
  219. const strict = filters.varianceFilterStrict;
  220. const keep = inclusive
  221. ? strict
  222. ? vp > negative && vp < limit
  223. : vp >= negative && vp <= limit
  224. : strict
  225. ? vp < negative || vp > limit
  226. : vp <= negative || vp >= limit;
  227. if (!keep) return false;
  228. }
  229. return true;
  230. }
  231. function isBlankApproverField(value: string | undefined): boolean {
  232. return value == null || String(value).trim() === "";
  233. }
  234. /** 審核員「盤點數」與「不良數」皆未輸入時應跳過儲存,維持 pending */
  235. function isApproverInputsBothEmpty(
  236. detailId: number,
  237. approverQty: Record<number, string>,
  238. approverBadQty: Record<number, string>
  239. ): boolean {
  240. return isBlankApproverField(approverQty[detailId]) && isBlankApproverField(approverBadQty[detailId]);
  241. }
  242. type ApproverSaveBuildResult =
  243. | {
  244. ok: true;
  245. request: SaveApproverStockTakeRecordRequest;
  246. finalQty: number;
  247. finalBadQty: number;
  248. goodQty: number;
  249. selection: QtySelectionType;
  250. }
  251. | { ok: false; reason: "skip_approver_empty" }
  252. | { ok: false; reason: "error"; message: string };
  253. function buildApproverSaveRequest(
  254. detail: InventoryLotDetailResponse,
  255. qtySelection: Record<number, QtySelectionType>,
  256. approverQty: Record<number, string>,
  257. approverBadQty: Record<number, string>,
  258. currentUserId: number,
  259. t: (key: string) => string
  260. ): ApproverSaveBuildResult {
  261. const selection = qtySelection[detail.id] || "first";
  262. let finalQty: number;
  263. let finalBadQty: number;
  264. if (selection === "first") {
  265. if (detail.firstStockTakeQty == null) {
  266. return { ok: false, reason: "error", message: t("First QTY is not available") };
  267. }
  268. finalQty = detail.firstStockTakeQty;
  269. finalBadQty = detail.firstBadQty || 0;
  270. } else if (selection === "second") {
  271. if (detail.secondStockTakeQty == null) {
  272. return { ok: false, reason: "error", message: t("Second QTY is not available") };
  273. }
  274. finalQty = detail.secondStockTakeQty;
  275. finalBadQty = detail.secondBadQty || 0;
  276. } else {
  277. if (isApproverInputsBothEmpty(detail.id, approverQty, approverBadQty)) {
  278. return { ok: false, reason: "skip_approver_empty" };
  279. }
  280. const approverQtyValue = approverQty[detail.id] || "0";
  281. const approverBadQtyValue = approverBadQty[detail.id] || "0";
  282. finalQty = parseFloat(approverQtyValue) || 0;
  283. finalBadQty = parseFloat(approverBadQtyValue) || 0;
  284. }
  285. const request: SaveApproverStockTakeRecordRequest = {
  286. stockTakeRecordId: detail.stockTakeRecordId || null,
  287. qty: finalQty,
  288. badQty: finalBadQty,
  289. approverId: currentUserId,
  290. approverQty: selection === "approver" ? finalQty : null,
  291. approverBadQty: selection === "approver" ? finalBadQty : null,
  292. lastSelect: selection === "first" ? 1 : selection === "second" ? 2 : 3,
  293. };
  294. const goodQty = finalQty - finalBadQty;
  295. return { ok: true, request, finalQty, finalBadQty, goodQty, selection };
  296. }
  297. function buildBatchApproverSaveRequests(
  298. details: InventoryLotDetailResponse[],
  299. qtySelection: Record<number, QtySelectionType>,
  300. approverQty: Record<number, string>,
  301. approverBadQty: Record<number, string>,
  302. currentUserId: number,
  303. t: (key: string) => string
  304. ): SaveApproverStockTakeRecordRequest[] {
  305. const records: SaveApproverStockTakeRecordRequest[] = [];
  306. for (const detail of details) {
  307. if (!detail.stockTakeRecordId || detail.stockTakeRecordId <= 0) {
  308. continue;
  309. }
  310. const built = buildApproverSaveRequest(
  311. detail,
  312. qtySelection,
  313. approverQty,
  314. approverBadQty,
  315. currentUserId,
  316. t
  317. );
  318. if (!built.ok) {
  319. continue;
  320. }
  321. records.push(built.request);
  322. }
  323. return records;
  324. }
  325. function parseDateTimeMs(
  326. v: string | string[] | null | undefined
  327. ): number {
  328. if (v == null) return 0;
  329. if (Array.isArray(v)) {
  330. const arr = v as unknown as number[];
  331. const [y, m, d, h = 0, min = 0, s = 0] = arr;
  332. return dayjs(`${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")} ${h}:${min}:${s}`).valueOf();
  333. }
  334. return dayjs(v as string).valueOf();
  335. }
  336. const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
  337. selectedSession,
  338. mode,
  339. onBack,
  340. onSnackbar,
  341. }) => {
  342. const { t } = useTranslation(["stockTake", "common"]);
  343. const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent();
  344. const { data: session } = useSession() as { data: SessionWithTokens | null };
  345. const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]);
  346. const [loadingDetails, setLoadingDetails] = useState(false);
  347. const [searchVariancePercentTolerance, setSearchVariancePercentTolerance] = useState<string>("5");
  348. const [searchVarianceFilterInclusive, setSearchVarianceFilterInclusive] = useState(false);
  349. const [searchVarianceFilterStrict, setSearchVarianceFilterStrict] = useState(false);
  350. const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({});
  351. const [approverQty, setApproverQty] = useState<Record<number, string>>({});
  352. const [gapCheckOpen, setGapCheckOpen] = useState<Record<string, boolean>>({});
  353. const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({});
  354. const [saving, setSaving] = useState(false);
  355. const [batchSaving, setBatchSaving] = useState(false);
  356. const [updatingStatus, setUpdatingStatus] = useState(false);
  357. const [page, setPage] = useState(0);
  358. const [pageSize, setPageSize] = useState<number | string>(50);
  359. const [total, setTotal] = useState(0);
  360. const [approvedSortKey, setApprovedSortKey] = useState<ApprovedSortKey | null>(null);
  361. const [approvedSortDir, setApprovedSortDir] = useState<"asc" | "desc">("asc");
  362. const [locationWarehouses, setLocationWarehouses] = useState<WarehouseResult[]>([]);
  363. const [searchKind, setSearchKind] = useState<ApproverSearchKind>("location");
  364. const [locationPick, setLocationPick] = useState<ApproverLocationPick>({ storeId: "", warehouse: "", area: "", slot: "" });
  365. const [searchSectionDescription, setSearchSectionDescription] = useState<string>("All");
  366. const [searchStockTakeSession, setSearchStockTakeSession] = useState<string>("");
  367. const [searchItemKeyword, setSearchItemKeyword] = useState<string>("");
  368. const [searchWarehouseKeyword, setSearchWarehouseKeyword] = useState<string>("");
  369. const [searchStoreId, setSearchStoreId] = useState<string>("All");
  370. const [searchStatus, setSearchStatus] = useState<string>(mode === "pending" ? "pass" : "All");
  371. const locationOwnedSectionRef = useRef("");
  372. const [showFilters, setShowFilters] = useState(true)
  373. const [appliedFilters, setAppliedFilters] = useState<ApproverSearchFilters>(() =>
  374. defaultApproverSearchFilters(mode)
  375. );
  376. const [openBatchSaveConfirmDialog, setOpenBatchSaveConfirmDialog] = useState(false);
  377. const batchSaveInFlightRef = useRef(false);
  378. const currentUserId = session?.id ? parseInt(session.id) : undefined;
  379. const handleChangePage = useCallback((_: unknown, newPage: number) => {
  380. setPage(newPage);
  381. }, []);
  382. const handleChangeRowsPerPage = useCallback(
  383. (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
  384. const newSize = parseInt(event.target.value, 10);
  385. if (newSize === -1) {
  386. setPageSize("all");
  387. } else if (!isNaN(newSize)) {
  388. setPageSize(newSize);
  389. }
  390. setPage(0);
  391. },
  392. []
  393. );
  394. const handleSearch = useCallback((override?: Partial<ApproverSearchFilters> & { searchKind?: ApproverSearchKind }) => {
  395. const kind = override?.searchKind ?? searchKind;
  396. const sectionDescription = kind === "location"
  397. ? "All"
  398. : (override?.sectionDescription ?? (searchSectionDescription || "All"));
  399. const stockTakeSession = kind === "location"
  400. ? (override?.stockTakeSession ?? locationOwnedSectionRef.current)
  401. : (override?.stockTakeSession ?? (searchStockTakeSession || ""));
  402. const warehouseKeyword = kind === "location"
  403. ? (override?.warehouseKeyword ?? (searchWarehouseKeyword || ""))
  404. : "";
  405. const storeId = kind === "location"
  406. ? (override?.storeId ?? (searchStoreId || "All"))
  407. : "All";
  408. const next: ApproverSearchFilters = {
  409. sectionDescription,
  410. stockTakeSession,
  411. itemKeyword: override?.itemKeyword ?? (searchItemKeyword || ""),
  412. warehouseKeyword,
  413. storeId,
  414. status: mode === "pending" ? (override?.status ?? searchStatus ?? "pass") : "All",
  415. variancePercentTolerance: override?.variancePercentTolerance ?? searchVariancePercentTolerance,
  416. varianceFilterInclusive: override?.varianceFilterInclusive ?? searchVarianceFilterInclusive,
  417. varianceFilterStrict: override?.varianceFilterStrict ?? searchVarianceFilterStrict,
  418. };
  419. if (kind === "section" && override?.sectionDescription != null) setSearchSectionDescription(override.sectionDescription);
  420. if (kind === "section" && override?.stockTakeSession != null) setSearchStockTakeSession(override.stockTakeSession);
  421. if (kind === "location" && override?.warehouseKeyword != null) setSearchWarehouseKeyword(override.warehouseKeyword);
  422. if (kind === "location" && override?.storeId != null) setSearchStoreId(override.storeId);
  423. if (override?.status != null) setSearchStatus(override.status);
  424. setAppliedFilters(next);
  425. setPage(0);
  426. }, [
  427. searchKind,
  428. searchSectionDescription,
  429. searchStockTakeSession,
  430. searchItemKeyword,
  431. searchWarehouseKeyword,
  432. searchStoreId,
  433. searchStatus,
  434. searchVariancePercentTolerance,
  435. searchVarianceFilterInclusive,
  436. searchVarianceFilterStrict,
  437. mode,
  438. ]);
  439. const handleResetSearch = useCallback(() => {
  440. const defaultStatus = mode === "pending" ? "pass" : "All";
  441. locationOwnedSectionRef.current = "";
  442. setLocationPick({ storeId: "", warehouse: "", area: "", slot: "" });
  443. setSearchSectionDescription("All");
  444. setSearchStockTakeSession("");
  445. setSearchItemKeyword("");
  446. setSearchWarehouseKeyword("");
  447. setSearchStoreId("All");
  448. setSearchStatus(defaultStatus);
  449. setSearchVariancePercentTolerance("5");
  450. setSearchVarianceFilterInclusive(false);
  451. setSearchVarianceFilterStrict(false);
  452. setAppliedFilters(defaultApproverSearchFilters(mode));
  453. setPage(0);
  454. }, [mode]);
  455. const switchSearchKind = useCallback((nextKind: ApproverSearchKind) => {
  456. if (nextKind === searchKind) return;
  457. setSearchKind(nextKind);
  458. if (nextKind === "location") {
  459. const specificWarehouse = Boolean(locationPick.warehouse && locationPick.warehouse !== APPROVER_LOCATION_ALL);
  460. handleSearch({
  461. searchKind: nextKind,
  462. stockTakeSession: locationOwnedSectionRef.current,
  463. warehouseKeyword: approverWarehouseKeyword(locationPick),
  464. storeId: !specificWarehouse && locationPick.storeId && locationPick.storeId !== APPROVER_LOCATION_ALL ? locationPick.storeId : "All",
  465. });
  466. return;
  467. }
  468. handleSearch({ searchKind: nextKind });
  469. }, [handleSearch, locationPick, searchKind]);
  470. const loadDetails = useCallback(async () => {
  471. setLoadingDetails(true);
  472. try {
  473. // Load this round once; search filters run on the loaded rows.
  474. const response = mode === "approved"
  475. ? await getApproverInventoryLotDetailsAllApproved(
  476. selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null,
  477. 0,
  478. 5000
  479. )
  480. : await getApproverInventoryLotDetailsAllPending(
  481. selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null,
  482. 0,
  483. 5000
  484. );
  485. setInventoryLotDetails(Array.isArray(response.records) ? response.records : []);
  486. setTotal(response.total ?? response.records?.length ?? 0);
  487. } catch (e) {
  488. console.error(e);
  489. setInventoryLotDetails([]);
  490. } finally {
  491. setLoadingDetails(false);
  492. }
  493. }, [selectedSession, mode]);
  494. useEffect(() => {
  495. loadDetails();
  496. }, [loadDetails]);
  497. useEffect(() => {
  498. let cancelled = false;
  499. fetchWarehouseListClient()
  500. .then((rows) => {
  501. if (!cancelled) setLocationWarehouses(rows ?? []);
  502. })
  503. .catch((e) => console.error("Failed to load warehouses for approver search:", e));
  504. return () => {
  505. cancelled = true;
  506. };
  507. }, []);
  508. const roundSectionCodeSet = useMemo(() => {
  509. const set = new Set<string>();
  510. inventoryLotDetails.forEach((row) => {
  511. const code = row.stockTakeSection?.trim();
  512. if (code) set.add(code);
  513. });
  514. return set;
  515. }, [inventoryLotDetails]);
  516. const roundWarehouses = useMemo(
  517. () =>
  518. locationWarehouses.filter((row) => {
  519. const code = row.stockTakeSection?.trim();
  520. if (!code) return false;
  521. if (roundSectionCodeSet.size === 0) return true;
  522. return roundSectionCodeSet.has(code);
  523. }),
  524. [locationWarehouses, roundSectionCodeSet]
  525. );
  526. const sectionDescriptionOptions = useMemo(() => {
  527. const pickedCodes = new Set(selectedApproverSectionCodes(searchStockTakeSession));
  528. const set = new Set<string>();
  529. roundWarehouses.forEach((row) => {
  530. const code = row.stockTakeSection?.trim();
  531. if (pickedCodes.size > 0 && (!code || !pickedCodes.has(code))) return;
  532. const desc = row.stockTakeSectionDescription?.trim();
  533. if (desc) set.add(desc);
  534. });
  535. return Array.from(set).sort(compareApproverLabel);
  536. }, [roundWarehouses, searchStockTakeSession]);
  537. const stockTakeSectionOptions = useMemo(() => {
  538. const descFilter = searchSectionDescription !== "All" ? searchSectionDescription : "";
  539. const set = new Set<string>();
  540. roundWarehouses.forEach((row) => {
  541. if (descFilter && (row.stockTakeSectionDescription?.trim() || "") !== descFilter) return;
  542. const code = row.stockTakeSection?.trim();
  543. if (code) set.add(code);
  544. });
  545. return Array.from(set).sort(compareApproverLabel);
  546. }, [roundWarehouses, searchSectionDescription]);
  547. const floorOptions = useMemo(() => {
  548. const set = new Set<string>();
  549. roundWarehouses.forEach((row) => {
  550. if (row.store_id) set.add(row.store_id);
  551. });
  552. return withApproverAllOption(Array.from(set).sort(compareApproverLabel));
  553. }, [roundWarehouses]);
  554. const warehouseOptions = useMemo(() => {
  555. if (!locationPick.storeId) return [];
  556. const set = new Set<string>();
  557. roundWarehouses.forEach((row) => {
  558. if (locationPick.storeId !== APPROVER_LOCATION_ALL && (row.store_id ?? "").trim() !== locationPick.storeId) return;
  559. const zone = row.warehouse?.trim();
  560. if (zone) set.add(zone);
  561. });
  562. return withApproverAllOption(Array.from(set).sort(compareApproverLabel));
  563. }, [roundWarehouses, locationPick.storeId]);
  564. const areaOptions = useMemo(() => {
  565. if (!locationPick.storeId || !locationPick.warehouse) return [];
  566. const set = new Set<string>();
  567. roundWarehouses.forEach((row) => {
  568. if (locationPick.storeId !== APPROVER_LOCATION_ALL && (row.store_id ?? "").trim() !== locationPick.storeId) return;
  569. if (locationPick.warehouse !== APPROVER_LOCATION_ALL && (row.warehouse ?? "").trim() !== locationPick.warehouse) return;
  570. const area = row.area?.trim();
  571. if (area) set.add(area);
  572. });
  573. return withApproverAllOption(Array.from(set).sort(compareApproverLabel));
  574. }, [roundWarehouses, locationPick.storeId, locationPick.warehouse]);
  575. const slotOptions = useMemo(() => {
  576. if (!locationPick.storeId || !locationPick.warehouse || !locationPick.area) return [];
  577. const set = new Set<string>();
  578. roundWarehouses.forEach((row) => {
  579. if (locationPick.storeId !== APPROVER_LOCATION_ALL && (row.store_id ?? "").trim() !== locationPick.storeId) return;
  580. if (locationPick.warehouse !== APPROVER_LOCATION_ALL && (row.warehouse ?? "").trim() !== locationPick.warehouse) return;
  581. if (locationPick.area !== APPROVER_LOCATION_ALL && (row.area ?? "").trim() !== locationPick.area) return;
  582. const slot = row.slot?.trim();
  583. if (slot) set.add(slot);
  584. });
  585. return withApproverAllOption(Array.from(set).sort(compareApproverLabel));
  586. }, [roundWarehouses, locationPick.storeId, locationPick.warehouse, locationPick.area]);
  587. const locationWarehouseRows = useMemo(
  588. () => chunkApproverOptions(warehouseOptions, APPROVER_LOCATION_BUTTONS_PER_ROW),
  589. [warehouseOptions]
  590. );
  591. const locationAreaRows = useMemo(
  592. () => chunkApproverOptions(areaOptions, APPROVER_LOCATION_BUTTONS_PER_ROW),
  593. [areaOptions]
  594. );
  595. const locationSlotRows = useMemo(
  596. () => chunkApproverOptions(slotOptions, APPROVER_LOCATION_BUTTONS_PER_ROW),
  597. [slotOptions]
  598. );
  599. const applyLocationPick = useCallback((pick: ApproverLocationPick) => {
  600. const specificWarehouse = Boolean(pick.warehouse && pick.warehouse !== APPROVER_LOCATION_ALL);
  601. const specificArea = Boolean(pick.area && pick.area !== APPROVER_LOCATION_ALL);
  602. const specificSlot = Boolean(pick.slot && pick.slot !== APPROVER_LOCATION_ALL);
  603. const matching = roundWarehouses.filter((row) => {
  604. if (pick.storeId && pick.storeId !== APPROVER_LOCATION_ALL && row.store_id !== pick.storeId) return false;
  605. if (specificWarehouse && row.warehouse !== pick.warehouse) return false;
  606. if (specificArea && row.area !== pick.area) return false;
  607. if (specificSlot && row.slot !== pick.slot) return false;
  608. return true;
  609. });
  610. const codes = Array.from(
  611. new Set(matching.map((row) => row.stockTakeSection?.trim()).filter((code): code is string => Boolean(code)))
  612. ).sort(compareApproverLabel);
  613. const owned = specificWarehouse || specificArea || specificSlot ? codes.join(", ") : "";
  614. locationOwnedSectionRef.current = owned;
  615. const keyword = approverWarehouseKeyword(pick);
  616. const storeId = !specificWarehouse && pick.storeId && pick.storeId !== APPROVER_LOCATION_ALL ? pick.storeId : "All";
  617. setLocationPick(pick);
  618. handleSearch({
  619. searchKind: "location",
  620. stockTakeSession: owned,
  621. warehouseKeyword: keyword,
  622. storeId,
  623. });
  624. }, [roundWarehouses, handleSearch]);
  625. useEffect(() => {
  626. if (searchKind !== "location") return;
  627. if (!locationPick.storeId && !locationPick.warehouse && !locationPick.area && !locationPick.slot) return;
  628. if (locationWarehouses.length === 0) return;
  629. const floorOk = !locationPick.storeId || floorOptions.includes(locationPick.storeId);
  630. const warehouseOk = !locationPick.warehouse || warehouseOptions.includes(locationPick.warehouse);
  631. const areaOk = !locationPick.area || areaOptions.includes(locationPick.area);
  632. const slotOk = !locationPick.slot || slotOptions.includes(locationPick.slot);
  633. if (floorOk && warehouseOk && areaOk && slotOk) return;
  634. const next: ApproverLocationPick = {
  635. storeId: floorOk ? locationPick.storeId : "",
  636. warehouse: floorOk && warehouseOk ? locationPick.warehouse : "",
  637. area: floorOk && warehouseOk && areaOk ? locationPick.area : "",
  638. slot: floorOk && warehouseOk && areaOk && slotOk ? locationPick.slot : "",
  639. };
  640. applyLocationPick(next);
  641. }, [applyLocationPick, areaOptions, floorOptions, locationPick, locationWarehouses.length, searchKind, slotOptions, warehouseOptions]);
  642. // 切换模式或盘次时,清空选择与搜索;approved 模式需要以后端结果为准。
  643. useEffect(() => {
  644. setQtySelection({});
  645. setApproverQty({});
  646. setApproverBadQty({});
  647. locationOwnedSectionRef.current = "";
  648. setLocationPick({ storeId: "", warehouse: "", area: "", slot: "" });
  649. setSearchSectionDescription("All");
  650. setSearchStockTakeSession("");
  651. setSearchItemKeyword("");
  652. setSearchWarehouseKeyword("");
  653. setSearchStoreId("All");
  654. setSearchStatus(mode === "pending" ? "pass" : "All");
  655. setSearchVariancePercentTolerance("5");
  656. setSearchVarianceFilterInclusive(false);
  657. setSearchVarianceFilterStrict(false);
  658. setAppliedFilters(defaultApproverSearchFilters(mode));
  659. setPage(0);
  660. }, [mode, selectedSession.stockTakeId]);
  661. useEffect(() => {
  662. const inferSelection = (
  663. detail: InventoryLotDetailResponse
  664. ): QtySelectionType => {
  665. // 优先使用后端记录的 lastSelect(1=First, 2=Second, 3=Approver Input)
  666. if (detail.lastSelect != null) {
  667. if (detail.lastSelect === 1) return "first";
  668. if (detail.lastSelect === 2) return "second";
  669. if (detail.lastSelect === 3) return "approver";
  670. }
  671. // 目标:在 approved 模式下,即使后端把 approver 字段也回填了,
  672. // 只要 finalQty 来自 first/second(picker 结果),就优先勾选 first/second。
  673. // 只有匹配不到 first/second 时,才推断为 approver。
  674. if (detail.finalQty != null) {
  675. const eps = 1e-6;
  676. const firstAvailable = detail.firstStockTakeQty;
  677. const secondAvailable = detail.secondStockTakeQty;
  678. // 如果这一行确实有 approver 结果,那么 approved 时应该优先显示为 approver
  679. // (尤其是:picker first 后又手动改 approver input 的情况)
  680. if (detail.approverQty != null) {
  681. const approverAvailable =
  682. detail.approverQty - (detail.approverBadQty ?? 0);
  683. if (Math.abs(approverAvailable - detail.finalQty) <= eps) {
  684. return "approver";
  685. }
  686. }
  687. if (secondAvailable != null && Math.abs(secondAvailable - detail.finalQty) <= eps) {
  688. return "second";
  689. }
  690. if (firstAvailable != null && Math.abs(firstAvailable - detail.finalQty) <= eps) {
  691. return "first";
  692. }
  693. // approver 字段口径可能是「available」或「total+bad」两种之一,这里同时尝试两种。
  694. if (detail.approverQty != null) {
  695. const approverAvailable = detail.approverQty;
  696. const approverAvailable2 =
  697. detail.approverQty - (detail.approverBadQty ?? 0);
  698. if (
  699. Math.abs(approverAvailable - detail.finalQty) <= eps ||
  700. Math.abs(approverAvailable2 - detail.finalQty) <= eps
  701. ) {
  702. return "approver";
  703. }
  704. }
  705. }
  706. // pending/无法反推时:second 存在则默认 second,否则 first
  707. if (detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0) {
  708. return "second";
  709. }
  710. return "first";
  711. };
  712. const newSelections: Record<number, QtySelectionType> = {};
  713. inventoryLotDetails.forEach((detail) => {
  714. if (qtySelection[detail.id]) return;
  715. newSelections[detail.id] = inferSelection(detail);
  716. });
  717. if (Object.keys(newSelections).length > 0) {
  718. setQtySelection((prev) => ({ ...prev, ...newSelections }));
  719. }
  720. }, [inventoryLotDetails, qtySelection]);
  721. // approved 模式下:把已保存的 approver 输入值回填到 TextField,避免“radio 显示了但输入框为空”
  722. useEffect(() => {
  723. if (mode !== "approved") return;
  724. const newApproverQty: Record<number, string> = {};
  725. const newApproverBadQty: Record<number, string> = {};
  726. inventoryLotDetails.forEach((detail) => {
  727. if (detail.approverQty != null && approverQty[detail.id] == null) {
  728. newApproverQty[detail.id] = String(detail.approverQty);
  729. }
  730. if (
  731. detail.approverBadQty != null &&
  732. approverBadQty[detail.id] == null
  733. ) {
  734. newApproverBadQty[detail.id] = String(detail.approverBadQty);
  735. }
  736. });
  737. if (Object.keys(newApproverQty).length > 0) {
  738. setApproverQty((prev) => ({ ...prev, ...newApproverQty }));
  739. }
  740. if (Object.keys(newApproverBadQty).length > 0) {
  741. setApproverBadQty((prev) => ({ ...prev, ...newApproverBadQty }));
  742. }
  743. }, [mode, inventoryLotDetails, approverQty, approverBadQty]);
  744. const calculateDifference = useCallback(
  745. (detail: InventoryLotDetailResponse, selection: QtySelectionType): number => {
  746. let selectedQty = 0;
  747. if (selection === "first") {
  748. selectedQty = detail.firstStockTakeQty || 0;
  749. } else if (selection === "second") {
  750. selectedQty = detail.secondStockTakeQty || 0;
  751. } else if (selection === "approver") {
  752. selectedQty =
  753. (parseFloat(approverQty[detail.id] || "0") -
  754. parseFloat(approverBadQty[detail.id] || "0")) || 0;
  755. }
  756. const bookQty = detail.bookQty != null ? detail.bookQty : detail.availableQty || 0;
  757. return selectedQty - bookQty;
  758. },
  759. [approverQty, approverBadQty]
  760. );
  761. const filteredDetails = useMemo(() => {
  762. return inventoryLotDetails.filter((detail) => matchesApproverDetailFilters(detail, appliedFilters, mode));
  763. }, [inventoryLotDetails, appliedFilters, mode]);
  764. const sortedDetails = useMemo(() => {
  765. const list = [...filteredDetails];
  766. if (mode !== "approved") {
  767. return list.sort((a, b) =>
  768. (a.stockTakeSection || "").localeCompare(b.stockTakeSection || "", undefined, {
  769. numeric: true,
  770. sensitivity: "base",
  771. })
  772. );
  773. }
  774. const key = approvedSortKey ?? "stockTakeSection";
  775. const mul = approvedSortDir === "asc" ? 1 : -1;
  776. return list.sort((a, b) => {
  777. let cmp = 0;
  778. switch (key) {
  779. case "stockTakeEndTime":
  780. cmp =
  781. parseDateTimeMs(a.approverTime ?? a.stockTakeEndTime) -
  782. parseDateTimeMs(b.approverTime ?? b.stockTakeEndTime);
  783. break;
  784. case "stockTakeSection":
  785. cmp = (a.stockTakeSection || "").localeCompare(b.stockTakeSection || "", undefined, {
  786. numeric: true,
  787. sensitivity: "base",
  788. });
  789. break;
  790. case "item":
  791. cmp = `${a.itemCode || ""} ${a.itemName || ""}`.localeCompare(
  792. `${b.itemCode || ""} ${b.itemName || ""}`,
  793. undefined,
  794. { numeric: true, sensitivity: "base" }
  795. );
  796. break;
  797. case "stockTakerName":
  798. cmp = (a.stockTakerName || "").localeCompare(b.stockTakerName || "", undefined, {
  799. numeric: true,
  800. sensitivity: "base",
  801. });
  802. break;
  803. case "variance":
  804. cmp = Number(a.varianceQty ?? 0) - Number(b.varianceQty ?? 0);
  805. break;
  806. default:
  807. cmp = 0;
  808. }
  809. return cmp * mul;
  810. });
  811. }, [filteredDetails, mode, approvedSortKey, approvedSortDir]);
  812. const handleApprovedSort = useCallback((property: ApprovedSortKey) => {
  813. if (approvedSortKey === property) {
  814. setApprovedSortDir((d) => (d === "asc" ? "desc" : "asc"));
  815. } else {
  816. setApprovedSortKey(property);
  817. setApprovedSortDir("asc");
  818. }
  819. }, [approvedSortKey]);
  820. const handleSaveApproverStockTake = useCallback(
  821. async (detail: InventoryLotDetailResponse) => {
  822. if (mode === "approved") return;
  823. if (!selectedSession || !currentUserId) {
  824. return;
  825. }
  826. const built = buildApproverSaveRequest(
  827. detail,
  828. qtySelection,
  829. approverQty,
  830. approverBadQty,
  831. currentUserId,
  832. t
  833. );
  834. if (!built.ok) {
  835. if (built.reason === "skip_approver_empty") {
  836. onSnackbar(t("Approver input empty; save skipped, row remains pending"), "warning");
  837. return;
  838. }
  839. onSnackbar(built.message, "error");
  840. return;
  841. }
  842. const { request, goodQty, finalQty, finalBadQty, selection } = built;
  843. setSaving(true);
  844. try {
  845. await saveApproverStockTakeRecord(request, selectedSession.stockTakeId);
  846. const gapText =
  847. selection === "approver"
  848. ? stockTakeQtyGapWarnText(t, approverQty[detail.id] || "", stockTakeHiddenOnHand(detail), qtyGapWarnPercent)
  849. : null;
  850. if (selection === "approver") {
  851. setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: true }));
  852. }
  853. onSnackbar(
  854. gapText
  855. ? `${t("Approver stock take record saved successfully")} ${gapText}`
  856. : t("Approver stock take record saved successfully"),
  857. gapText ? "warning" : "success",
  858. );
  859. setInventoryLotDetails((prev) =>
  860. prev.map((d) =>
  861. d.id === detail.id
  862. ? {
  863. ...d,
  864. finalQty: goodQty,
  865. approverQty: selection === "approver" ? finalQty : d.approverQty,
  866. approverBadQty: selection === "approver" ? finalBadQty : d.approverBadQty,
  867. stockTakeRecordStatus: "completed",
  868. }
  869. : d
  870. )
  871. );
  872. } catch (e: any) {
  873. console.error("Save approver stock take record error:", e);
  874. let errorMessage = t("Failed to save approver stock take record");
  875. if (e?.message) {
  876. errorMessage = e.message;
  877. } else if (e?.response) {
  878. try {
  879. const errorData = await e.response.json();
  880. errorMessage = errorData.message || errorData.error || errorMessage;
  881. } catch {
  882. }
  883. }
  884. onSnackbar(errorMessage, "error");
  885. } finally {
  886. setSaving(false);
  887. }
  888. },
  889. [selectedSession, currentUserId, qtySelection, approverQty, approverBadQty, t, onSnackbar, mode, qtyGapWarnPercent]
  890. );
  891. const handleUpdateStatusToNotMatch = useCallback(
  892. async (detail: InventoryLotDetailResponse) => {
  893. if (mode === "approved") return;
  894. if (!detail.stockTakeRecordId) {
  895. onSnackbar(t("Stock take record ID is required"), "error");
  896. return;
  897. }
  898. setUpdatingStatus(true);
  899. try {
  900. await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId);
  901. onSnackbar(t("Stock take record status updated to not match"), "success");
  902. setInventoryLotDetails((prev) =>
  903. prev.map((d) =>
  904. d.id === detail.id ? { ...d, stockTakeRecordStatus: "notMatch" } : d
  905. )
  906. );
  907. } catch (e: any) {
  908. console.error("Update stock take record status error:", e);
  909. let errorMessage = t("Failed to update stock take record status");
  910. if (e?.message) {
  911. errorMessage = e.message;
  912. } else if (e?.response) {
  913. try {
  914. const errorData = await e.response.json();
  915. errorMessage = errorData.message || errorData.error || errorMessage;
  916. } catch {
  917. }
  918. }
  919. onSnackbar(errorMessage, "error");
  920. } finally {
  921. setUpdatingStatus(false);
  922. }
  923. },
  924. [t, onSnackbar]
  925. );
  926. const blockNonIntegerKeys = (e: React.KeyboardEvent<HTMLInputElement>) => {
  927. // 禁止小数点、逗号、科学计数、正负号
  928. if ([".", ",", "e", "E", "+", "-"].includes(e.key)) {
  929. e.preventDefault();
  930. }
  931. };
  932. const sanitizeIntegerInput = (value: string) => {
  933. // 只保留数字
  934. return value.replace(/[^\d]/g, "");
  935. };
  936. const sanitizePositiveDecimalInput = (value: string) => {
  937. const unsigned = value.replace(/[^\d.]/g, "");
  938. const parts = unsigned.split(".");
  939. return parts.length <= 1 ? unsigned : `${parts[0]}.${parts.slice(1).join("")}`;
  940. };
  941. const isIntegerString = (value: string) => /^\d+$/.test(value);
  942. const batchApproverSaveRecords = useMemo(
  943. () =>
  944. currentUserId
  945. ? buildBatchApproverSaveRequests(
  946. sortedDetails,
  947. qtySelection,
  948. approverQty,
  949. approverBadQty,
  950. currentUserId,
  951. t
  952. )
  953. : [],
  954. [sortedDetails, qtySelection, approverQty, approverBadQty, currentUserId, t]
  955. );
  956. const batchSaveSectionLabels = useMemo(() => {
  957. const labels = new Set<string>();
  958. sortedDetails.forEach((d) => {
  959. const sec = d.stockTakeSection?.trim();
  960. if (sec) labels.add(sec);
  961. });
  962. return Array.from(labels).sort((a, b) => a.localeCompare(b));
  963. }, [sortedDetails]);
  964. const handleOpenBatchSaveConfirm = useCallback(() => {
  965. if (mode === "approved") return;
  966. if (!selectedSession || !currentUserId) return;
  967. if (inventoryLotDetails.length === 0) {
  968. onSnackbar(t("No rows loaded; set search criteria and search first"), "warning");
  969. return;
  970. }
  971. if (batchApproverSaveRecords.length === 0) {
  972. onSnackbar(t("No valid records to batch save"), "warning");
  973. return;
  974. }
  975. setOpenBatchSaveConfirmDialog(true);
  976. }, [
  977. mode,
  978. selectedSession,
  979. currentUserId,
  980. inventoryLotDetails.length,
  981. batchApproverSaveRecords.length,
  982. onSnackbar,
  983. t,
  984. ]);
  985. const handleBatchSubmitAll = useCallback(async () => {
  986. if (batchSaveInFlightRef.current) return;
  987. if (mode === "approved") return;
  988. if (!selectedSession || !currentUserId) return;
  989. const records = buildBatchApproverSaveRequests(
  990. sortedDetails,
  991. qtySelection,
  992. approverQty,
  993. approverBadQty,
  994. currentUserId,
  995. t
  996. );
  997. if (records.length === 0) {
  998. onSnackbar(t("No valid records to batch save"), "warning");
  999. return;
  1000. }
  1001. batchSaveInFlightRef.current = true;
  1002. setBatchSaving(true);
  1003. setOpenBatchSaveConfirmDialog(false);
  1004. try {
  1005. const result = await batchSaveApproverStockTakeRecordsByIds({
  1006. stockTakeId: selectedSession.stockTakeId,
  1007. approverId: currentUserId,
  1008. recordIds: records
  1009. .map((r) => r.stockTakeRecordId)
  1010. .filter((id): id is number => typeof id === "number" && id > 0),
  1011. records,
  1012. });
  1013. onSnackbar(
  1014. t("Batch approver save completed: {{success}} success, {{errors}} errors", {
  1015. success: result.successCount,
  1016. errors: result.errorCount,
  1017. }),
  1018. result.errorCount > 0 ? "warning" : "success",
  1019. );
  1020. if (result.successCount > 0) {
  1021. await loadDetails();
  1022. }
  1023. } catch (e: unknown) {
  1024. console.error("handleBatchSubmitAll (all): Error:", e);
  1025. let errorMessage = t("Failed to batch save approver stock take records");
  1026. if (e instanceof Error && e.message) {
  1027. errorMessage = e.message;
  1028. }
  1029. onSnackbar(errorMessage, "error");
  1030. } finally {
  1031. setBatchSaving(false);
  1032. batchSaveInFlightRef.current = false;
  1033. }
  1034. }, [
  1035. mode,
  1036. selectedSession,
  1037. currentUserId,
  1038. sortedDetails,
  1039. qtySelection,
  1040. approverQty,
  1041. approverBadQty,
  1042. t,
  1043. onSnackbar,
  1044. loadDetails,
  1045. ]);
  1046. const formatNumber = (num: number | null | undefined): string => {
  1047. if (num == null) return "0";
  1048. return num.toLocaleString("en-US", {
  1049. minimumFractionDigits: 0,
  1050. maximumFractionDigits: 0,
  1051. });
  1052. };
  1053. const uniqueWarehouses = useMemo(
  1054. () =>
  1055. Array.from(
  1056. new Set(
  1057. inventoryLotDetails
  1058. .map((detail) => detail.warehouse)
  1059. .filter((warehouse) => warehouse && warehouse.trim() !== "")
  1060. )
  1061. ).join(", "),
  1062. [inventoryLotDetails]
  1063. );
  1064. const formatRecordEndTime = (detail: InventoryLotDetailResponse) => {
  1065. const ms = parseDateTimeMs(detail.approverTime ?? detail.stockTakeEndTime);
  1066. if (!ms) return "-";
  1067. return dayjs(ms).format("YYYY-MM-DD HH:mm");
  1068. };
  1069. const rows = sortedDetails;
  1070. const columns = useMemo<GridColDef<InventoryLotDetailResponse>[]>(() => {
  1071. const cols: GridColDef<InventoryLotDetailResponse>[] = [];
  1072. if (mode === "approved") {
  1073. cols.push({
  1074. field: "approverTime",
  1075. headerName: t("Approver Time"),
  1076. minWidth: 150,
  1077. flex: 0.6,
  1078. sortable: false,
  1079. renderCell: (params) => (
  1080. <Typography variant="caption" color="text.secondary">
  1081. {formatRecordEndTime(params.row)}
  1082. </Typography>
  1083. ),
  1084. });
  1085. }
  1086. cols.push(
  1087. {
  1088. field: "warehouseLocation",
  1089. headerName: t("Warehouse Location"),
  1090. minWidth: 220,
  1091. flex: 1,
  1092. sortable: false,
  1093. renderCell: (params) => (
  1094. <Stack spacing={0.5}>
  1095. <Typography variant="body2">
  1096. <strong>
  1097. {params.row.stockTakeSection || "-"}{" "}
  1098. {params.row.stockTakeSectionDescription || "-"}
  1099. </strong>
  1100. </Typography>
  1101. <Typography variant="body2">{params.row.warehouseCode || "-"}</Typography>
  1102. </Stack>
  1103. ),
  1104. },
  1105. {
  1106. field: "itemLot",
  1107. headerName: t("Item-lotNo-ExpiryDate"),
  1108. minWidth: 160,
  1109. flex: 1,
  1110. sortable: false,
  1111. renderCell: (params) => (
  1112. <Stack spacing={0.5} sx={{ lineHeight: 1.5, py: 0.5 }}>
  1113. <Typography
  1114. component="div"
  1115. sx={{ fontWeight: 800, fontSize: "1.15rem", lineHeight: 1.3, color: "text.primary" }}
  1116. >
  1117. {params.row.itemCode || "-"} {params.row.itemName || "-"}
  1118. </Typography>
  1119. <Box>{params.row.lotNo || "-"}</Box>
  1120. <Box>
  1121. {params.row.expiryDate
  1122. ? dayjs(params.row.expiryDate).format(OUTPUT_DATE_FORMAT)
  1123. : "-"}
  1124. </Box>
  1125. </Stack>
  1126. ),
  1127. },
  1128. {
  1129. field: "uom",
  1130. headerName: t("UOM"),
  1131. minWidth: 90,
  1132. flex: 0.4,
  1133. sortable: false,
  1134. valueGetter: (params) => params.row.uom || "-",
  1135. },
  1136. {
  1137. field: "qtyBlock",
  1138. headerName: t("Stock Take Qty Data and Variance Analysis"),
  1139. minWidth: 480,
  1140. flex: 3.2,
  1141. sortable: false,
  1142. renderCell: (params: GridRenderCellParams<InventoryLotDetailResponse>) => {
  1143. const detail = params.row;
  1144. const hasFirst =
  1145. detail.firstStockTakeQty != null && detail.firstStockTakeQty >= 0;
  1146. const hasSecond =
  1147. detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0;
  1148. const selection =
  1149. qtySelection[detail.id] || (hasSecond ? "second" : "first");
  1150. const canApprover =
  1151. mode === "pending"
  1152. ? true
  1153. : selection === "approver" &&
  1154. (detail.approverQty != null || detail.approverBadQty != null);
  1155. const showRadioBlock = mode === "approved" || detail.finalQty == null;
  1156. const bookQty = detail.bookQty ?? detail.availableQty ?? 0;
  1157. const selectedQty =
  1158. selection === "first"
  1159. ? (detail.firstStockTakeQty ?? 0)
  1160. : selection === "second"
  1161. ? (detail.secondStockTakeQty ?? 0)
  1162. : (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0")) || 0;
  1163. const difference = selectedQty - bookQty;
  1164. const approverQtyNum = parseFloat(approverQty[detail.id] || "0") || 0;
  1165. const approverBadQtyNum = parseFloat(approverBadQty[detail.id] || "0") || 0;
  1166. const approverGoodQty = approverQtyNum - approverBadQtyNum;
  1167. const variancePercentageRaw =
  1168. bookQty !== 0
  1169. ? (difference / bookQty) * 100
  1170. : difference !== 0
  1171. ? difference > 0
  1172. ? 100
  1173. : -100
  1174. : 0;
  1175. const variancePercentage = roundDownPercent(variancePercentageRaw);
  1176. const hasVariance = difference !== 0;
  1177. const pctLabel = `${variancePercentage}%`;
  1178. const summaryLine = (label: string, value: string, valueColor?: string) => (
  1179. <Stack
  1180. key={label}
  1181. direction="row"
  1182. justifyContent="space-between"
  1183. alignItems="center"
  1184. spacing={1}
  1185. >
  1186. <Typography variant="body2" color="text.secondary" noWrap>
  1187. {label}
  1188. </Typography>
  1189. <Typography
  1190. variant="body2"
  1191. fontWeight={700}
  1192. sx={{ color: valueColor ?? "text.primary", whiteSpace: "nowrap" }}
  1193. >
  1194. {value}
  1195. </Typography>
  1196. </Stack>
  1197. );
  1198. return (
  1199. <Box sx={{ width: "100%" }}>
  1200. {!showRadioBlock ? (
  1201. <Typography variant="body2" color="text.secondary">
  1202. -
  1203. </Typography>
  1204. ) : (
  1205. <Stack
  1206. direction="row"
  1207. spacing={1}
  1208. alignItems="stretch"
  1209. sx={{ width: "100%", py: 0.5 }}
  1210. >
  1211. <Stack spacing={1} sx={{ minWidth: 0, justifyContent: "center" }}>
  1212. {hasFirst && (
  1213. <Stack direction="row" spacing={0.5} alignItems="center">
  1214. <Radio
  1215. size="small"
  1216. checked={selection === "first"}
  1217. disabled={mode === "approved"}
  1218. onChange={() =>
  1219. setQtySelection({
  1220. ...qtySelection,
  1221. [detail.id]: "first",
  1222. })
  1223. }
  1224. />
  1225. <Typography variant="body2" component="span">
  1226. {t("First")}:{" "}
  1227. <StockTakeQtyWithUnit
  1228. qty={formatNumber(
  1229. (detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0)
  1230. )}
  1231. uomShortDesc={detail.uomShortDesc}
  1232. />
  1233. </Typography>
  1234. </Stack>
  1235. )}
  1236. {hasSecond && (
  1237. <Stack direction="row" spacing={0.5} alignItems="center">
  1238. <Radio
  1239. size="small"
  1240. checked={selection === "second"}
  1241. disabled={mode === "approved"}
  1242. onChange={() =>
  1243. setQtySelection({
  1244. ...qtySelection,
  1245. [detail.id]: "second",
  1246. })
  1247. }
  1248. />
  1249. <Typography variant="body2" component="span">
  1250. {t("Second")}:{" "}
  1251. <StockTakeQtyWithUnit
  1252. qty={formatNumber(
  1253. (detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0)
  1254. )}
  1255. uomShortDesc={detail.uomShortDesc}
  1256. />
  1257. </Typography>
  1258. </Stack>
  1259. )}
  1260. {canApprover && (
  1261. <Stack direction="row" spacing={0.5} alignItems="center" flexWrap="wrap">
  1262. <Radio
  1263. size="small"
  1264. checked={selection === "approver"}
  1265. disabled={mode === "approved"}
  1266. onChange={() =>
  1267. setQtySelection({
  1268. ...qtySelection,
  1269. [detail.id]: "approver",
  1270. })
  1271. }
  1272. />
  1273. <Typography variant="body2" component="span" sx={{ mr: 0.5 }}>
  1274. {t("Approver Input")}:
  1275. </Typography>
  1276. <TextField
  1277. size="small"
  1278. type="number"
  1279. value={approverQty[detail.id] || ""}
  1280. onFocus={() => {
  1281. setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: false }));
  1282. if (mode !== "approved" && selection !== "approver") {
  1283. setQtySelection({
  1284. ...qtySelection,
  1285. [detail.id]: "approver",
  1286. });
  1287. }
  1288. }}
  1289. onBlur={() =>
  1290. setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: true }))
  1291. }
  1292. onKeyDown={blockNonIntegerKeys}
  1293. onChange={(e) => {
  1294. const clean = sanitizeIntegerInput(e.target.value);
  1295. setApproverQty({
  1296. ...approverQty,
  1297. [detail.id]: clean,
  1298. });
  1299. }}
  1300. InputProps={{
  1301. endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc),
  1302. }}
  1303. sx={{
  1304. width: 120,
  1305. minWidth: 120,
  1306. "& .MuiInputBase-input": {
  1307. py: 0.5,
  1308. px: 1,
  1309. },
  1310. }}
  1311. // placeholder={t("Stock Take Qty")}
  1312. disabled={mode === "approved"}
  1313. inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
  1314. />
  1315. <Box sx={{ flexBasis: "100%" }}>
  1316. <StockTakeQtyGapHint
  1317. open={selection === "approver" && !!gapCheckOpen[`${detail.id}:approver`]}
  1318. entered={approverQty[detail.id] || ""}
  1319. currentQty={stockTakeHiddenOnHand(detail)}
  1320. threshold={qtyGapWarnPercent}
  1321. />
  1322. </Box>
  1323. {/*
  1324. <Typography variant="body2" component="span" sx={{ ml: 0.5 }}>
  1325. = {formatNumber(approverGoodQty)}
  1326. </Typography>
  1327. */}
  1328. </Stack>
  1329. )}
  1330. </Stack>
  1331. <Box
  1332. sx={{
  1333. flexShrink: 0,
  1334. minWidth: 168,
  1335. maxWidth: 200,
  1336. bgcolor: "grey.50",
  1337. borderRadius: 1,
  1338. border: "1px solid",
  1339. borderColor: "divider",
  1340. p: 1.25,
  1341. }}
  1342. >
  1343. <Stack spacing={0.75}>
  1344. {summaryLine(
  1345. `${t("Selected Qty")}:`,
  1346. [formatNumber(selectedQty), detail.uomShortDesc?.trim()].filter(Boolean).join(" ")
  1347. )}
  1348. {summaryLine(`${t("Book Qty")}:`, formatNumber(bookQty))}
  1349. {summaryLine(
  1350. `${t("Inventory Difference")}:`,
  1351. formatNumber(difference),
  1352. hasVariance ? "error.main" : "text.primary"
  1353. )}
  1354. <Box
  1355. sx={{
  1356. mt: 0.25,
  1357. py: 0.5,
  1358. px: 1,
  1359. borderRadius: 1,
  1360. textAlign: "center",
  1361. bgcolor: hasVariance ? "error.light" : "grey.200",
  1362. }}
  1363. >
  1364. <Typography
  1365. variant="caption"
  1366. fontWeight={600}
  1367. sx={{ color: hasVariance ? "error.dark" : "text.secondary" }}
  1368. >
  1369. {t("variance Percentage")}: {pctLabel}
  1370. </Typography>
  1371. </Box>
  1372. </Stack>
  1373. </Box>
  1374. </Stack>
  1375. )}
  1376. </Box>
  1377. );
  1378. },
  1379. }
  1380. );
  1381. {/*
  1382. if (mode === "approved") {
  1383. cols.push({
  1384. field: "varianceQty",
  1385. headerName: t("Variance"),
  1386. minWidth: 110,
  1387. flex: 0.5,
  1388. sortable: false,
  1389. valueGetter: (params) => String(params.row.varianceQty ?? 0),
  1390. });
  1391. }
  1392. */}
  1393. cols.push(
  1394. /*
  1395. {
  1396. field: "remarks",
  1397. headerName: t("Remark"),
  1398. minWidth: 120,
  1399. flex: 0.8,
  1400. sortable: false,
  1401. renderCell: (params) => (
  1402. <Typography variant="body2" sx={{ whiteSpace: "normal", wordBreak: "break-word" }}>
  1403. {params.row.remarks || "-"}
  1404. </Typography>
  1405. ),
  1406. },
  1407. */
  1408. {
  1409. field: "stockTakeRecordStatus",
  1410. headerName: t("Record Status"),
  1411. minWidth: 130,
  1412. flex: 0.6,
  1413. sortable: false,
  1414. renderCell: (params) => {
  1415. const raw = params.row.stockTakeRecordStatus;
  1416. const status = String(raw ?? "").trim().toLowerCase();
  1417. const colorMap: Record<string, "success" | "warning" | "info" | "default" | "error"> = {
  1418. completed: "success",
  1419. notmatch: "warning",
  1420. pending: "info",
  1421. "not_match": "warning",
  1422. };
  1423. return (
  1424. <Chip
  1425. size="small"
  1426. label={t(`${status}`) || "-"}
  1427. color={colorMap[status] ?? "default"}
  1428. variant="outlined"
  1429. />
  1430. );
  1431. }
  1432. },
  1433. {
  1434. field: "stockTakerName",
  1435. headerName: t("Picker"),
  1436. minWidth: 140,
  1437. flex: 0.7,
  1438. sortable: false,
  1439. valueGetter: (params) => params.row.stockTakerName || "-",
  1440. },
  1441. {
  1442. field: "actions",
  1443. headerName: t("Action"),
  1444. minWidth: 110,
  1445. flex: 0.9,
  1446. sortable: false,
  1447. renderCell: (params) => {
  1448. const detail = params.row;
  1449. const hasSecond = detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0;
  1450. const selection =
  1451. qtySelection[detail.id] || (hasSecond ? "second" : "first");
  1452. return (
  1453. <Stack direction="column" spacing={1} sx={{ width: "100%" }}>
  1454. {mode === "pending" && (
  1455. <>
  1456. <Box>
  1457. <Button
  1458. size="small"
  1459. variant="contained"
  1460. onClick={() => handleSaveApproverStockTake(detail)}
  1461. disabled={saving}
  1462. >
  1463. {t("Save")}
  1464. </Button>
  1465. </Box>
  1466. <Box>
  1467. <Button
  1468. size="small"
  1469. variant="outlined"
  1470. color="warning"
  1471. onClick={() => handleUpdateStatusToNotMatch(detail)}
  1472. disabled={updatingStatus||hasSecond}
  1473. >
  1474. {t("Not Match")}
  1475. </Button>
  1476. </Box>
  1477. </>
  1478. )}
  1479. {mode === "approved" && (
  1480. <Typography variant="caption" color="text.secondary">
  1481. {selection === "first"
  1482. ? t("First")
  1483. : selection === "second"
  1484. ? t("Second")
  1485. : t("Approver Input")}
  1486. </Typography>
  1487. )}
  1488. </Stack>
  1489. );
  1490. },
  1491. }
  1492. );
  1493. return cols;
  1494. }, [
  1495. mode,
  1496. t,
  1497. qtySelection,
  1498. approverQty,
  1499. approverBadQty,
  1500. saving,
  1501. updatingStatus,
  1502. blockNonIntegerKeys,
  1503. sanitizeIntegerInput,
  1504. handleSaveApproverStockTake,
  1505. handleUpdateStatusToNotMatch,
  1506. formatRecordEndTime,
  1507. ]);
  1508. const effectivePageSize =
  1509. pageSize === "all" ? Math.max(total || 0, 0) : (pageSize as number);
  1510. return (
  1511. <Box>
  1512. {onBack && (
  1513. <Button
  1514. onClick={onBack}
  1515. sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}
  1516. >
  1517. {t("Back to List")}
  1518. </Button>
  1519. )}
  1520. <Stack
  1521. direction="row"
  1522. justifyContent="space-between"
  1523. alignItems="center"
  1524. sx={{ mb: 2 }}
  1525. >
  1526. <Typography variant="h6" sx={{ mb: 2 }}>
  1527. {uniqueWarehouses && (
  1528. <> {t("Warehouse")}: {uniqueWarehouses}</>
  1529. )}
  1530. </Typography>
  1531. <Stack direction="row" spacing={2} alignItems="center">
  1532. {mode === "pending" && (
  1533. <Button
  1534. variant="contained"
  1535. color="primary"
  1536. onClick={handleOpenBatchSaveConfirm}
  1537. disabled={batchSaving}
  1538. >
  1539. {t("Batch Save All")}
  1540. </Button>
  1541. )}
  1542. </Stack>
  1543. </Stack>
  1544. <Accordion
  1545. expanded={showFilters}
  1546. onChange={(_, expanded) => setShowFilters(expanded)}
  1547. elevation={0}
  1548. sx={{ mb: 2, border: "1px solid", borderColor: "divider", borderRadius: 1 }}
  1549. >
  1550. <AccordionSummary
  1551. expandIcon={<ExpandMoreIcon />}
  1552. sx={{
  1553. minHeight: 36,
  1554. px: 2,
  1555. "&.Mui-expanded": { minHeight: 36 },
  1556. "& .MuiAccordionSummary-content, & .MuiAccordionSummary-content.Mui-expanded": { my: 0.75 },
  1557. }}
  1558. >
  1559. <Typography variant="body2">
  1560. {(<strong>{t("Search Criteria")}</strong>)}
  1561. </Typography>
  1562. </AccordionSummary>
  1563. <AccordionDetails sx={{ pt: 0, pb: 1.5, px: 2 }}>
  1564. <Box sx={{ width: "100%" }}>
  1565. <Card elevation={0}>
  1566. <CardContent sx={{ p: 0, "&:last-child": { pb: 0 } }}>
  1567. <Grid container spacing={2} alignItems="flex-start">
  1568. <Grid item xs={12}>
  1569. <Tabs
  1570. value={searchKind}
  1571. onChange={(_, value: ApproverSearchKind) => switchSearchKind(value)}
  1572. sx={{ minHeight: 36 }}
  1573. >
  1574. <Tab
  1575. value="location"
  1576. label={t("Search by location")}
  1577. sx={{ minHeight: 36, py: 0.5, textTransform: "none" }}
  1578. />
  1579. <Tab
  1580. value="section"
  1581. label={t("Search by stock take section")}
  1582. sx={{ minHeight: 36, py: 0.5, textTransform: "none" }}
  1583. />
  1584. </Tabs>
  1585. </Grid>
  1586. {searchKind === "location" && (
  1587. <Grid item xs={12}>
  1588. <Stack spacing={1.25}>
  1589. <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
  1590. <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600 }}>
  1591. {t("Store ID")}
  1592. </Typography>
  1593. <ToggleButtonGroup
  1594. exclusive
  1595. size="small"
  1596. value={locationPick.storeId || null}
  1597. onChange={(_, next: string | null) => {
  1598. applyLocationPick({ storeId: next ?? "", warehouse: "", area: "", slot: "" });
  1599. }}
  1600. >
  1601. {floorOptions.map((floor) => (
  1602. <ToggleButton key={floor} value={floor} sx={{ px: 1.5, textTransform: "none" }}>
  1603. {floor === APPROVER_LOCATION_ALL ? t("All") : floor}
  1604. </ToggleButton>
  1605. ))}
  1606. </ToggleButtonGroup>
  1607. </Box>
  1608. <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}>
  1609. <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}>
  1610. {t("Warehouse")}
  1611. </Typography>
  1612. <Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, opacity: locationPick.storeId ? 1 : 0.45 }}>
  1613. {locationWarehouseRows.map((row, rowIndex) => (
  1614. <ToggleButtonGroup
  1615. key={row.join("-") || rowIndex}
  1616. exclusive
  1617. size="small"
  1618. disabled={!locationPick.storeId}
  1619. value={locationPick.storeId ? locationPick.warehouse || null : null}
  1620. onChange={(_, next: string | null) => {
  1621. if (next == null) return;
  1622. applyLocationPick({ ...locationPick, warehouse: next, area: "", slot: "" });
  1623. }}
  1624. sx={{
  1625. "& .MuiToggleButtonGroup-grouped": {
  1626. width: APPROVER_LOCATION_BUTTON_WIDTH,
  1627. minWidth: APPROVER_LOCATION_BUTTON_WIDTH,
  1628. maxWidth: APPROVER_LOCATION_BUTTON_WIDTH,
  1629. px: 0,
  1630. },
  1631. }}
  1632. >
  1633. {row.map((zone) => (
  1634. <ToggleButton key={zone} value={zone} sx={{ textTransform: "none" }}>
  1635. {zone === APPROVER_LOCATION_ALL ? t("All") : zone}
  1636. </ToggleButton>
  1637. ))}
  1638. </ToggleButtonGroup>
  1639. ))}
  1640. </Box>
  1641. {!locationPick.storeId ? (
  1642. <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
  1643. {t("Select floor first")}
  1644. </Typography>
  1645. ) : null}
  1646. </Box>
  1647. <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}>
  1648. <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}>
  1649. {t("Area")}
  1650. </Typography>
  1651. <Box
  1652. sx={{
  1653. display: "flex",
  1654. flexDirection: "column",
  1655. gap: 0.75,
  1656. opacity: locationPick.storeId && locationPick.warehouse ? 1 : 0.45,
  1657. }}
  1658. >
  1659. {locationAreaRows.map((row, rowIndex) => (
  1660. <ToggleButtonGroup
  1661. key={row.join("-") || rowIndex}
  1662. exclusive
  1663. size="small"
  1664. disabled={!locationPick.storeId || !locationPick.warehouse}
  1665. value={locationPick.warehouse ? locationPick.area || null : null}
  1666. onChange={(_, next: string | null) => {
  1667. if (next == null) return;
  1668. applyLocationPick({ ...locationPick, area: next, slot: "" });
  1669. }}
  1670. sx={{
  1671. "& .MuiToggleButtonGroup-grouped": {
  1672. width: APPROVER_LOCATION_BUTTON_WIDTH,
  1673. minWidth: APPROVER_LOCATION_BUTTON_WIDTH,
  1674. maxWidth: APPROVER_LOCATION_BUTTON_WIDTH,
  1675. px: 0,
  1676. },
  1677. }}
  1678. >
  1679. {row.map((area) => (
  1680. <ToggleButton key={area} value={area} sx={{ textTransform: "none" }}>
  1681. {area === APPROVER_LOCATION_ALL ? t("All") : area}
  1682. </ToggleButton>
  1683. ))}
  1684. </ToggleButtonGroup>
  1685. ))}
  1686. </Box>
  1687. {!locationPick.warehouse ? (
  1688. <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
  1689. {locationPick.storeId ? t("Select warehouse first") : t("Select floor first")}
  1690. </Typography>
  1691. ) : null}
  1692. </Box>
  1693. <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}>
  1694. <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}>
  1695. {t("Slot")}
  1696. </Typography>
  1697. <Box
  1698. sx={{
  1699. display: "flex",
  1700. flexDirection: "column",
  1701. gap: 0.75,
  1702. opacity: locationPick.storeId && locationPick.warehouse && locationPick.area ? 1 : 0.45,
  1703. }}
  1704. >
  1705. {locationSlotRows.map((row, rowIndex) => (
  1706. <ToggleButtonGroup
  1707. key={row.join("-") || rowIndex}
  1708. exclusive
  1709. size="small"
  1710. disabled={!locationPick.storeId || !locationPick.warehouse || !locationPick.area}
  1711. value={locationPick.area ? locationPick.slot || null : null}
  1712. onChange={(_, next: string | null) => {
  1713. if (next == null) return;
  1714. applyLocationPick({ ...locationPick, slot: next });
  1715. }}
  1716. sx={{
  1717. "& .MuiToggleButtonGroup-grouped": {
  1718. width: APPROVER_LOCATION_BUTTON_WIDTH,
  1719. minWidth: APPROVER_LOCATION_BUTTON_WIDTH,
  1720. maxWidth: APPROVER_LOCATION_BUTTON_WIDTH,
  1721. px: 0,
  1722. },
  1723. }}
  1724. >
  1725. {row.map((slot) => (
  1726. <ToggleButton key={slot} value={slot} sx={{ textTransform: "none" }}>
  1727. {slot === APPROVER_LOCATION_ALL ? t("All") : slot}
  1728. </ToggleButton>
  1729. ))}
  1730. </ToggleButtonGroup>
  1731. ))}
  1732. </Box>
  1733. {!locationPick.area ? (
  1734. <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
  1735. {locationPick.warehouse ? t("Select area first") : locationPick.storeId ? t("Select warehouse first") : t("Select floor first")}
  1736. </Typography>
  1737. ) : null}
  1738. </Box>
  1739. </Stack>
  1740. </Grid>
  1741. )}
  1742. {searchKind === "section" && (
  1743. <Grid item xs={12} md={4}>
  1744. <FormControl fullWidth size="small" sx={approverSearchFieldSx}>
  1745. <InputLabel>{t("Stock Take Section")}</InputLabel>
  1746. <Select
  1747. value={
  1748. searchSectionDescription === "All" ||
  1749. sectionDescriptionOptions.includes(searchSectionDescription)
  1750. ? searchSectionDescription
  1751. : "All"
  1752. }
  1753. MenuProps={approverSearchMenuProps}
  1754. label={t("Stock Take Section")}
  1755. onChange={(e) => {
  1756. const nextDesc = e.target.value;
  1757. const allowed = new Set<string>();
  1758. roundWarehouses.forEach((row) => {
  1759. if (nextDesc !== "All" && (row.stockTakeSectionDescription?.trim() || "") !== nextDesc) return;
  1760. const code = row.stockTakeSection?.trim();
  1761. if (code) allowed.add(code);
  1762. });
  1763. const kept = selectedApproverSectionCodes(searchStockTakeSession).filter((code) => allowed.has(code));
  1764. handleSearch({
  1765. searchKind: "section",
  1766. sectionDescription: nextDesc,
  1767. stockTakeSession: kept.join(", "),
  1768. });
  1769. }}
  1770. >
  1771. <MenuItem value="All">{t("All")}</MenuItem>
  1772. {sectionDescriptionOptions.map((desc) => (
  1773. <MenuItem key={desc} value={desc}>
  1774. {desc}
  1775. </MenuItem>
  1776. ))}
  1777. </Select>
  1778. </FormControl>
  1779. </Grid>
  1780. )}
  1781. {searchKind === "section" && (
  1782. <Grid item xs={12} md={4}>
  1783. <FormControl fullWidth size="small" sx={approverSearchFieldSx}>
  1784. <InputLabel>{t("Stock Take Section code")}</InputLabel>
  1785. <Select
  1786. multiple
  1787. MenuProps={approverSearchMenuProps}
  1788. value={selectedApproverSectionCodes(searchStockTakeSession).filter((code) =>
  1789. stockTakeSectionOptions.includes(code)
  1790. )}
  1791. label={t("Stock Take Section code")}
  1792. onChange={(e) => {
  1793. const raw = e.target.value;
  1794. const picked = (typeof raw === "string" ? raw.split(",") : raw)
  1795. .map((part) => part.trim())
  1796. .filter(Boolean);
  1797. const descriptions = new Set<string>();
  1798. if (picked.length > 0) {
  1799. const pickedSet = new Set(picked);
  1800. roundWarehouses.forEach((row) => {
  1801. const code = row.stockTakeSection?.trim();
  1802. if (!code || !pickedSet.has(code)) return;
  1803. const desc = row.stockTakeSectionDescription?.trim();
  1804. if (desc) descriptions.add(desc);
  1805. });
  1806. }
  1807. const nextDesc =
  1808. searchSectionDescription !== "All" &&
  1809. picked.length > 0 &&
  1810. !descriptions.has(searchSectionDescription)
  1811. ? "All"
  1812. : searchSectionDescription;
  1813. handleSearch({
  1814. searchKind: "section",
  1815. sectionDescription: nextDesc,
  1816. stockTakeSession: picked.join(", "),
  1817. });
  1818. }}
  1819. renderValue={(selected) => (selected as string[]).join(", ")}
  1820. >
  1821. {stockTakeSectionOptions.map((code) => (
  1822. <MenuItem key={code} value={code}>
  1823. {code}
  1824. </MenuItem>
  1825. ))}
  1826. </Select>
  1827. </FormControl>
  1828. </Grid>
  1829. )}
  1830. <Grid item xs={12} md={4}>
  1831. <TextField
  1832. fullWidth
  1833. sx={approverSearchFieldSx}
  1834. label={t("Item")}
  1835. value={searchItemKeyword}
  1836. onChange={(e) => setSearchItemKeyword(e.target.value)}
  1837. onBlur={() => handleSearch({ itemKeyword: searchItemKeyword })}
  1838. onKeyDown={(e) => {
  1839. if (e.key === "Enter") handleSearch({ itemKeyword: searchItemKeyword });
  1840. }}
  1841. />
  1842. </Grid>
  1843. {mode === "pending" && (
  1844. <Grid item xs={12} md={4}>
  1845. <Stack spacing={0.5}>
  1846. <FormControl fullWidth size="small" sx={approverSearchFieldSx}>
  1847. <InputLabel>{t("Record Status")}</InputLabel>
  1848. <Select
  1849. MenuProps={approverSearchMenuProps}
  1850. value={searchStatus}
  1851. label={t("Record Status")}
  1852. onChange={(e) => handleSearch({ status: e.target.value })}
  1853. >
  1854. <MenuItem value="pending">{t("Pending")}</MenuItem>
  1855. <MenuItem value="notMatch">{t("Not Match")}</MenuItem>
  1856. <MenuItem value="pass">{t("Pass")}</MenuItem>
  1857. </Select>
  1858. </FormControl>
  1859. <Stack direction="row" spacing={1} sx={{ pt: 0.5 }}>
  1860. <Button variant="outlined" onClick={handleResetSearch}>
  1861. {t("Reset")}
  1862. </Button>
  1863. <Button variant="contained" onClick={() => handleSearch()}>
  1864. {t("Search")}
  1865. </Button>
  1866. </Stack>
  1867. </Stack>
  1868. </Grid>
  1869. )}
  1870. <Grid item xs={12} md={4}>
  1871. <Stack spacing={0.5}>
  1872. <TextField
  1873. fullWidth
  1874. size="small"
  1875. type="number"
  1876. label={t("Variance %")}
  1877. value={searchVariancePercentTolerance}
  1878. onChange={(e) => {
  1879. const next = sanitizePositiveDecimalInput(e.target.value);
  1880. setSearchVariancePercentTolerance(next);
  1881. handleSearch({ variancePercentTolerance: next });
  1882. }}
  1883. inputProps={{ min: 0, step: 0.1 }}
  1884. sx={approverSearchFieldSx}
  1885. />
  1886. <FormControlLabel
  1887. sx={{ ml: 0, mr: 0 }}
  1888. control={
  1889. <Checkbox
  1890. size="small"
  1891. checked={searchVarianceFilterInclusive}
  1892. onChange={(e) => {
  1893. setSearchVarianceFilterInclusive(e.target.checked);
  1894. handleSearch({ varianceFilterInclusive: e.target.checked });
  1895. }}
  1896. />
  1897. }
  1898. label={
  1899. <Typography variant="body2">{t("Variance filter inclusive only")}</Typography>
  1900. }
  1901. />
  1902. <Typography variant="caption" color="text.secondary" sx={{ pl: 0.5 }}>
  1903. {searchVarianceFilterInclusive
  1904. ? t("Variance filter inclusive range hint", {
  1905. value: searchVariancePercentTolerance || "0",
  1906. op: searchVarianceFilterStrict ? "<" : "≤",
  1907. })
  1908. : t("Variance filter exclusive range hint", {
  1909. value: searchVariancePercentTolerance || "0",
  1910. op: searchVarianceFilterStrict ? ">" : "≥",
  1911. })}
  1912. </Typography>
  1913. {mode !== "pending" && (
  1914. <Stack direction="row" spacing={1} sx={{ pt: 0.5 }}>
  1915. <Button variant="outlined" onClick={handleResetSearch}>
  1916. {t("Reset")}
  1917. </Button>
  1918. <Button variant="contained" onClick={() => handleSearch()}>
  1919. {t("Search")}
  1920. </Button>
  1921. </Stack>
  1922. )}
  1923. </Stack>
  1924. </Grid>
  1925. </Grid>
  1926. </CardContent>
  1927. </Card>
  1928. </Box>
  1929. </AccordionDetails>
  1930. </Accordion>
  1931. <Typography variant="body2" color="text.secondary">
  1932. {t("Total")}: {total} | {t("Shown")}: {sortedDetails.length}
  1933. </Typography>
  1934. <Box sx={{ width: "100%", height: 700 }}>
  1935. <StyledDataGrid
  1936. rows={sortedDetails}
  1937. columns={columns}
  1938. getRowId={(row) => row.id}
  1939. loading={loadingDetails}
  1940. disableRowSelectionOnClick
  1941. paginationMode="client" // ← important: client-side
  1942. pageSizeOptions={[50, 100, 200, 500]}
  1943. getRowHeight={() => "auto"}
  1944. sx={{
  1945. backgroundColor: "transparent",
  1946. "& .MuiDataGrid-columnHeaders": { backgroundColor: "#fff" },
  1947. "& .MuiDataGrid-cell": { py: 1, alignItems: "flex-start" },
  1948. "& .MuiDataGrid-row": { minHeight: 96 },
  1949. }}
  1950. />
  1951. </Box>
  1952. <Dialog
  1953. open={openBatchSaveConfirmDialog}
  1954. onClose={() => {
  1955. if (!batchSaving) setOpenBatchSaveConfirmDialog(false);
  1956. }}
  1957. maxWidth="sm"
  1958. fullWidth
  1959. >
  1960. <DialogTitle>{t("Confirm batch save approver")}</DialogTitle>
  1961. <DialogContent dividers>
  1962. <Typography variant="body2" sx={{ mb: 2 }}>
  1963. {t("Batch save confirm message", { count: batchApproverSaveRecords.length })}
  1964. </Typography>
  1965. {batchSaveSectionLabels.length > 0 ? (
  1966. <Box>
  1967. <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
  1968. {t("Stock take sections in current list", { count: batchSaveSectionLabels.length })}
  1969. </Typography>
  1970. <Box
  1971. sx={{
  1972. maxHeight: 240,
  1973. overflowY: "auto",
  1974. border: 1,
  1975. borderColor: "divider",
  1976. borderRadius: 1,
  1977. p: 1.5,
  1978. }}
  1979. >
  1980. <Stack spacing={0.5}>
  1981. {batchSaveSectionLabels.map((section) => (
  1982. <Typography key={section} variant="body2">
  1983. {section}
  1984. </Typography>
  1985. ))}
  1986. </Stack>
  1987. </Box>
  1988. </Box>
  1989. ) : null}
  1990. </DialogContent>
  1991. <DialogActions sx={{ px: 3, py: 2 }}>
  1992. <Button onClick={() => setOpenBatchSaveConfirmDialog(false)} disabled={batchSaving}>
  1993. {t("Cancel")}
  1994. </Button>
  1995. <Button
  1996. variant="contained"
  1997. color="primary"
  1998. onClick={handleBatchSubmitAll}
  1999. disabled={batchSaving}
  2000. >
  2001. {batchSaving ? <CircularProgress size={20} /> : t("Confirm")}
  2002. </Button>
  2003. </DialogActions>
  2004. </Dialog>
  2005. </Box>
  2006. );
  2007. };
  2008. export default ApproverStockTakeAll;