FPSMS-frontend
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

2444 lines
96 KiB

  1. "use client";
  2. import {
  3. Box,
  4. Button,
  5. Card,
  6. CardContent,
  7. CardActions,
  8. Stack,
  9. Typography,
  10. Chip,
  11. CircularProgress,
  12. TablePagination,
  13. Grid,
  14. Dialog,
  15. DialogTitle,
  16. DialogContent,
  17. DialogActions,
  18. TextField,
  19. InputAdornment,
  20. FormControl,
  21. InputLabel,
  22. Select,
  23. MenuItem,
  24. Badge,
  25. IconButton,
  26. Tooltip,
  27. Drawer,
  28. Divider,
  29. Tab,
  30. Tabs,
  31. ToggleButton,
  32. ToggleButtonGroup,
  33. Checkbox,
  34. } from "@mui/material";
  35. import SearchIcon from "@mui/icons-material/Search";
  36. import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
  37. import { useRouter } from "next/navigation";
  38. import { SessionWithTokens } from "@/config/authConfig";
  39. import { useSession } from "next-auth/react";
  40. import { useState, useCallback, useEffect, useRef, useMemo } from "react";
  41. import { useTranslation } from "react-i18next";
  42. import duration from "dayjs/plugin/duration";
  43. import {
  44. AllPickedStockTakeListReponse,
  45. createStockTakeForItems,
  46. createStockTakeForSections,
  47. getStockTakeRecordsPaged,
  48. getLatestStockTakeRoundMeta,
  49. getBusyStockTakeSections,
  50. getStockTakeRoundCards,
  51. } from "@/app/api/stockTake/actions";
  52. import { fetchItemsByPage } from "@/app/api/settings/item/actions";
  53. import { fetchStockTakeSections } from "@/app/api/warehouse/actions";
  54. import { fetchMissingStockTakeSectionIssues, fetchWarehouseListClient } from "@/app/api/warehouse/client";
  55. import type { MissingStockTakeSectionIssueItem, StockTakeSectionInfo, WarehouseResult } from "@/app/api/warehouse";
  56. import dayjs, { type Dayjs } from "dayjs";
  57. import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
  58. import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
  59. import {
  60. OUTPUT_DATE_FORMAT,
  61. OUTPUT_DATETIME_FORMAT,
  62. dayjsToDateTimeString,
  63. } from "@/app/utils/formatUtil";
  64. import { AUTH } from "@/authorities";
  65. const FLOOR_GROUP_UNSET = "__UNSET__";
  66. /** 與 controllQRcodescanWarehouse 選倉相同:樓層 → 倉庫 → 倉位 */
  67. const LOCATION_ALL = "ALL";
  68. const LOCATION_BUTTONS_PER_ROW = 15;
  69. const LOCATION_BUTTON_WIDTH = 80;
  70. const LOCATION_NONE_SECTION = "__NONE__";
  71. function chunkList<T>(items: T[], size: number): T[][] {
  72. const rows: T[][] = [];
  73. for (let i = 0; i < items.length; i += size) {
  74. rows.push(items.slice(i, i + size));
  75. }
  76. return rows;
  77. }
  78. type CreateDialogItemHit = { id: number; code: string; name: string };
  79. function itemRecordsFromPage(res: unknown): { id: string | number; code?: string; name?: string }[] {
  80. const records = (res as { records?: unknown[] })?.records;
  81. return Array.isArray(records)
  82. ? (records as { id: string | number; code?: string; name?: string }[])
  83. : [];
  84. }
  85. function withAllOption(options: string[]): string[] {
  86. return options.length > 1 ? [LOCATION_ALL, ...options] : options;
  87. }
  88. function compareLocationLabel(a: string, b: string): number {
  89. return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
  90. }
  91. function matchesPickerCardFilters(
  92. session: AllPickedStockTakeListReponse,
  93. filters: PickerCardListFilters,
  94. ): boolean {
  95. if (filters.sectionDescription && filters.sectionDescription !== "All") {
  96. if (session.stockTakeSectionDescription !== filters.sectionDescription) return false;
  97. }
  98. const sectionQuery = filters.stockTakeSession?.trim() ?? "";
  99. if (sectionQuery) {
  100. const parts = sectionQuery.split(",").map((part) => part.trim()).filter(Boolean);
  101. const name = (session.stockTakeSession || "").toLowerCase();
  102. const hit = parts.some((part) => {
  103. const key = part.toLowerCase();
  104. return name === key || name.includes(key);
  105. });
  106. if (!hit) return false;
  107. }
  108. if (filters.status && filters.status !== "All") {
  109. const normalized = filters.status.trim().toLowerCase();
  110. if (normalized === "notmatch") {
  111. if (!session.reStockTakeTrueFalse) return false;
  112. } else {
  113. const accepted = normalized === "stocktaking"
  114. ? ["stocktaking", "processing", "in_progress"]
  115. : [normalized];
  116. if (!accepted.includes((session.status || "").trim().toLowerCase())) return false;
  117. }
  118. }
  119. const area = filters.area?.trim() ?? "";
  120. if (area && !(session.warehouseArea || "").toLowerCase().includes(area.toLowerCase())) return false;
  121. if (filters.storeId && filters.storeId !== "All") {
  122. const floor = filters.storeId.trim().toLowerCase();
  123. const itemFloor = (session.storeId || "").toLowerCase();
  124. if (itemFloor !== floor && !itemFloor.includes(floor)) return false;
  125. }
  126. return true;
  127. }
  128. /** 移動超過此距離才視為框選(Windows 橡皮筋),否則視為單擊 */
  129. const MARQUEE_DRAG_THRESHOLD_PX = 4;
  130. /** 左側樓層列表超過此數量才啟用捲動 */
  131. const FLOOR_LIST_SCROLL_THRESHOLD = 8;
  132. const SEARCH_FIELD_HEIGHT = 56;
  133. /** 下拉最多先顯示約 8 列,其餘在選單內捲動 */
  134. const SEARCH_MENU_MAX_HEIGHT = 320;
  135. const searchMenuProps = {
  136. PaperProps: {
  137. sx: { maxHeight: SEARCH_MENU_MAX_HEIGHT },
  138. },
  139. } as const;
  140. const searchFieldSx = {
  141. "& .MuiOutlinedInput-root": {
  142. height: SEARCH_FIELD_HEIGHT,
  143. minHeight: SEARCH_FIELD_HEIGHT,
  144. boxSizing: "border-box",
  145. alignItems: "center",
  146. },
  147. "& .MuiSelect-select": {
  148. height: `${SEARCH_FIELD_HEIGHT}px !important`,
  149. minHeight: "0 !important",
  150. display: "flex",
  151. alignItems: "center",
  152. boxSizing: "border-box",
  153. py: "0 !important",
  154. },
  155. } as const;
  156. function selectedSectionCodes(value: string): string[] {
  157. if (!value.trim() || value.trim() === LOCATION_NONE_SECTION) return [];
  158. return value
  159. .split(",")
  160. .map((part) => part.trim())
  161. .filter((part) => part && part !== LOCATION_NONE_SECTION);
  162. }
  163. const createDialogCompactInputSx = {
  164. mt: 0,
  165. mb: 0,
  166. "& .MuiInputBase-root": {
  167. bgcolor: "background.paper",
  168. },
  169. "& .MuiOutlinedInput-input": {
  170. py: "8.5px",
  171. },
  172. "& .MuiInputAdornment-positionStart": {
  173. marginRight: 0.5,
  174. },
  175. "& .MuiInputBase-input::placeholder": {
  176. opacity: 1, // MUI 預設 opacity 較低,設 1 顏色才準
  177. color: "text.secondary", // 中等灰;要更淡用 "text.disabled"
  178. },
  179. } as const;
  180. type MarqueeRect = { left: number; top: number; width: number; height: number };
  181. function rectsIntersect(a: DOMRect, b: DOMRect): boolean {
  182. return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
  183. }
  184. function clientMarqueeRect(startX: number, startY: number, endX: number, endY: number): DOMRect {
  185. const left = Math.min(startX, endX);
  186. const top = Math.min(startY, endY);
  187. const right = Math.max(startX, endX);
  188. const bottom = Math.max(startY, endY);
  189. return { left, top, right, bottom, width: right - left, height: bottom - top } as DOMRect;
  190. }
  191. interface PickerCardListProps {
  192. /** 由父層保存,從明細返回時仍回到同一頁 */
  193. page: number;
  194. pageSize: number;
  195. onListPageChange: (page: number) => void;
  196. onCardClick: (session: AllPickedStockTakeListReponse) => void;
  197. onReStockTakeClick: (session: AllPickedStockTakeListReponse) => void;
  198. searchFilters: PickerCardListFilters;
  199. appliedFilters: PickerCardListFilters;
  200. onSearchFiltersChange: (next: PickerCardListFilters) => void;
  201. onAppliedFiltersChange: (next: PickerCardListFilters) => void;
  202. /** 樓層/倉庫/倉位,由父層保存,從明細返回時仍顯示 */
  203. locationPick: PickerLocationPick;
  204. onLocationPickChange: (next: PickerLocationPick) => void;
  205. stockTakeRoundId?: number | null;
  206. hideSectionList?: boolean;
  207. autoOpenCreate?: boolean;
  208. onCreateDialogClose?: () => void;
  209. onRoundsChanged?: () => void;
  210. }
  211. export type PickerSearchKind = "location" | "section";
  212. export type PickerCardListFilters = {
  213. sectionDescription: string;
  214. stockTakeSession: string;
  215. status: string;
  216. area: string;
  217. storeId: string;
  218. searchKind: PickerSearchKind;
  219. };
  220. function locationQueryFromPick(
  221. pick: PickerLocationPick,
  222. warehouses: WarehouseResult[],
  223. ): { stockTakeSession: string; storeId: string } {
  224. const useSections =
  225. (pick.warehouse !== "" && pick.warehouse !== LOCATION_ALL) ||
  226. (pick.area !== "" && pick.area !== LOCATION_ALL) ||
  227. (pick.slot !== "" && pick.slot !== LOCATION_ALL);
  228. const sections = new Set<string>();
  229. if (useSections) {
  230. warehouses.forEach((w) => {
  231. const section = w.stockTakeSection?.trim();
  232. if (!section) return;
  233. if (pick.storeId && pick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== pick.storeId) return;
  234. if (pick.warehouse && pick.warehouse !== LOCATION_ALL && (w.warehouse ?? "").trim() !== pick.warehouse) return;
  235. if (pick.area && pick.area !== LOCATION_ALL && (w.area ?? "").trim() !== pick.area) return;
  236. if (pick.slot && pick.slot !== LOCATION_ALL && (w.slot ?? "").trim() !== pick.slot) return;
  237. sections.add(section);
  238. });
  239. }
  240. return {
  241. stockTakeSession: useSections
  242. ? Array.from(sections).sort(compareLocationLabel).join(",") || LOCATION_NONE_SECTION
  243. : "",
  244. storeId: !pick.storeId || pick.storeId === LOCATION_ALL || useSections ? "All" : pick.storeId,
  245. };
  246. }
  247. function appliedPickerFilters(
  248. filters: PickerCardListFilters,
  249. pick: PickerLocationPick,
  250. warehouses: WarehouseResult[],
  251. ): PickerCardListFilters {
  252. const kind = filters.searchKind ?? "location";
  253. if (kind === "section") {
  254. return {
  255. sectionDescription: filters.sectionDescription || "All",
  256. stockTakeSession: filters.stockTakeSession || "",
  257. status: filters.status || "All",
  258. area: "",
  259. storeId: "All",
  260. searchKind: "section",
  261. };
  262. }
  263. const location = locationQueryFromPick(pick, warehouses);
  264. return {
  265. sectionDescription: "All",
  266. stockTakeSession: location.stockTakeSession,
  267. status: filters.status || "All",
  268. area: "",
  269. storeId: location.storeId,
  270. searchKind: "location",
  271. };
  272. }
  273. export type PickerLocationPick = { storeId: string; warehouse: string; area: string; slot: string };
  274. export const EMPTY_PICKER_LOCATION: PickerLocationPick = { storeId: "", warehouse: "", area: "", slot: "" };
  275. const PickerCardList: React.FC<PickerCardListProps> = ({
  276. page,
  277. pageSize,
  278. onListPageChange,
  279. onCardClick,
  280. onReStockTakeClick,
  281. searchFilters,
  282. appliedFilters,
  283. onSearchFiltersChange,
  284. onAppliedFiltersChange,
  285. locationPick,
  286. onLocationPickChange,
  287. stockTakeRoundId = null,
  288. hideSectionList = false,
  289. autoOpenCreate = false,
  290. onCreateDialogClose,
  291. onRoundsChanged,
  292. }) => {
  293. const { t } = useTranslation(["stockTake", "common"]);
  294. const router = useRouter();
  295. dayjs.extend(duration);
  296. const [loading, setLoading] = useState(true);
  297. /** 這一輪全部卡片,篩選在前端做,點選不再重打 API */
  298. const [roundRecords, setRoundRecords] = useState<AllPickedStockTakeListReponse[]>([]);
  299. const { data: session } = useSession() as { data: SessionWithTokens | null };
  300. const abilities = session?.abilities ?? session?.user?.abilities ?? [];
  301. const canManageStockTake = abilities.some((a) => a.trim() === AUTH.ADMIN);
  302. /** 建立盤點後若仍在 page 0,仍強制重新載入 */
  303. const [listRefreshNonce, setListRefreshNonce] = useState(0);
  304. const [globalRoundPlanStartDate, setGlobalRoundPlanStartDate] = useState<string | null>(null);
  305. const [creating, setCreating] = useState(false);
  306. const [openConfirmDialog, setOpenConfirmDialog] = useState(false);
  307. const [busySections, setBusySections] = useState<Set<string>>(() => new Set());
  308. const [openCreateStockTakeSummaryConfirm, setOpenCreateStockTakeSummaryConfirm] = useState(false);
  309. const [missingSectionWarnDrawerOpen, setMissingSectionWarnDrawerOpen] = useState(false);
  310. const [missingSectionCount, setMissingSectionCount] = useState(0);
  311. const [missingSectionItems, setMissingSectionItems] = useState<MissingStockTakeSectionIssueItem[]>([]);
  312. const [missingSectionIssuesLimit, setMissingSectionIssuesLimit] = useState(50);
  313. const [missingSectionIssuesLoading, setMissingSectionIssuesLoading] = useState(false);
  314. const [createDialogSelectedSections, setCreateDialogSelectedSections] = useState<string[]>([]);
  315. const [createDialogRoundName, setCreateDialogRoundName] = useState("");
  316. const [createDialogPlanStart, setCreateDialogPlanStart] = useState<Dayjs | null>(() => dayjs());
  317. /** 建立盤點對話框:目前選中的樓層分頁(對應 sortedFloorKeys 索引) */
  318. const [createFloorTabIndex, setCreateFloorTabIndex] = useState(0);
  319. const [createDialogSearchQuery, setCreateDialogSearchQuery] = useState("");
  320. const [createMode, setCreateMode] = useState<"section" | "item">("section");
  321. const [blockSectionCreate, setBlockSectionCreate] = useState(false);
  322. const [createDialogItemCode, setCreateDialogItemCode] = useState("");
  323. const [createDialogItemName, setCreateDialogItemName] = useState("");
  324. const [createDialogItemHits, setCreateDialogItemHits] = useState<CreateDialogItemHit[]>([]);
  325. const [createDialogSelectedItems, setCreateDialogSelectedItems] = useState<CreateDialogItemHit[]>([]);
  326. const [createDialogItemSearching, setCreateDialogItemSearching] = useState(false);
  327. const [sectionsLoading, setSectionsLoading] = useState(false);
  328. const [stockTakeSectionRows, setStockTakeSectionRows] = useState<StockTakeSectionInfo[]>([]);
  329. const createStockTakeInFlightRef = useRef(false);
  330. const createSectionGridScrollRef = useRef<HTMLDivElement>(null);
  331. const createSectionCardRefs = useRef<Map<string, HTMLElement>>(new Map());
  332. const marqueeDragRef = useRef<{
  333. pointerId: number;
  334. startClientX: number;
  335. startClientY: number;
  336. currentClientX: number;
  337. currentClientY: number;
  338. baseSelection: Set<string>;
  339. isMarquee: boolean;
  340. pointerDownSection: string | null;
  341. sectionIds: string[];
  342. } | null>(null);
  343. const [marqueeRect, setMarqueeRect] = useState<MarqueeRect | null>(null);
  344. const [marqueePreviewIds, setMarqueePreviewIds] = useState<Set<string>>(() => new Set());
  345. const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState<string[]>([]);
  346. const [locationWarehouses, setLocationWarehouses] = useState<WarehouseResult[]>([]);
  347. /** 由選倉寫入的盤點區域字串;使用者改過區域欄後就不再覆寫 */
  348. const locationOwnedSectionRef = useRef<string | null>(
  349. (locationPick.warehouse && locationPick.warehouse !== LOCATION_ALL) ||
  350. (locationPick.area && locationPick.area !== LOCATION_ALL) ||
  351. (locationPick.slot && locationPick.slot !== LOCATION_ALL)
  352. ? searchFilters.stockTakeSession
  353. : null,
  354. );
  355. const statusOptions = ["All", "pending", "stockTaking", "approving", "completed", "notMatch"];
  356. const searchKind = searchFilters.searchKind ?? "location";
  357. const handleResetSearch = () => {
  358. const resetFilters: PickerCardListFilters = {
  359. sectionDescription: "All",
  360. stockTakeSession: "",
  361. status: "All",
  362. area: "",
  363. storeId: "All",
  364. searchKind,
  365. };
  366. onSearchFiltersChange(resetFilters);
  367. onAppliedFiltersChange(resetFilters);
  368. onLocationPickChange(EMPTY_PICKER_LOCATION);
  369. locationOwnedSectionRef.current = null;
  370. onListPageChange(0);
  371. };
  372. useEffect(() => {
  373. let cancelled = false;
  374. fetchWarehouseListClient()
  375. .then((rows) => {
  376. if (!cancelled) setLocationWarehouses(Array.isArray(rows) ? rows : []);
  377. })
  378. .catch((e) => {
  379. console.error("Failed to load warehouses for stock take location filter:", e);
  380. if (!cancelled) setLocationWarehouses([]);
  381. });
  382. return () => {
  383. cancelled = true;
  384. };
  385. }, []);
  386. useEffect(() => {
  387. if (autoOpenCreate) setOpenConfirmDialog(true);
  388. }, [autoOpenCreate]);
  389. useEffect(() => {
  390. if (hideSectionList) {
  391. setLoading(false);
  392. return;
  393. }
  394. let cancelled = false;
  395. setLoading(true);
  396. getStockTakeRecordsPaged(0, 10000, {
  397. sectionDescription: "All",
  398. stockTakeSections: "",
  399. status: "All",
  400. area: "",
  401. storeId: "All",
  402. onlyLatestRound: !(stockTakeRoundId != null && stockTakeRoundId > 0),
  403. stockTakeRoundId: stockTakeRoundId != null && stockTakeRoundId > 0 ? stockTakeRoundId : undefined,
  404. })
  405. .then((res) => {
  406. if (cancelled) return;
  407. setRoundRecords(Array.isArray(res.records) ? res.records : []);
  408. })
  409. .catch((e) => {
  410. console.error("Failed to load this round's stock take sections:", e);
  411. if (!cancelled) setRoundRecords([]);
  412. })
  413. .finally(() => {
  414. if (!cancelled) setLoading(false);
  415. });
  416. return () => {
  417. cancelled = true;
  418. };
  419. }, [hideSectionList, listRefreshNonce, stockTakeRoundId]);
  420. const roundSectionCodes = useMemo(
  421. () =>
  422. Array.from(
  423. new Set(
  424. roundRecords
  425. .map((row) => row.stockTakeSession?.trim())
  426. .filter((code): code is string => Boolean(code)),
  427. ),
  428. ),
  429. [roundRecords],
  430. );
  431. const filteredSessions = useMemo(
  432. () => roundRecords.filter((session) => matchesPickerCardFilters(session, appliedFilters)),
  433. [appliedFilters, roundRecords],
  434. );
  435. const total = filteredSessions.length;
  436. const stockTakeSessions = useMemo(
  437. () => filteredSessions.slice(page * pageSize, page * pageSize + pageSize),
  438. [filteredSessions, page, pageSize],
  439. );
  440. const stockTakeLocationWarehouses = useMemo(() => {
  441. if (roundSectionCodes.length === 0) return [];
  442. const inRound = new Set(roundSectionCodes);
  443. return locationWarehouses.filter((w) => {
  444. const section = w.stockTakeSection?.trim();
  445. return Boolean(section && inRound.has(section));
  446. });
  447. }, [locationWarehouses, roundSectionCodes]);
  448. const locationFloorOptions = useMemo(() => {
  449. const set = new Set<string>();
  450. stockTakeLocationWarehouses.forEach((w) => {
  451. const floor = w.store_id?.trim();
  452. if (floor) set.add(floor);
  453. });
  454. return withAllOption(Array.from(set).sort(compareLocationLabel));
  455. }, [stockTakeLocationWarehouses]);
  456. const locationWarehouseOptions = useMemo(() => {
  457. if (!locationPick.storeId) return [];
  458. const set = new Set<string>();
  459. stockTakeLocationWarehouses.forEach((w) => {
  460. if (locationPick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== locationPick.storeId) return;
  461. const zone = w.warehouse?.trim();
  462. if (zone) set.add(zone);
  463. });
  464. return withAllOption(Array.from(set).sort(compareLocationLabel));
  465. }, [stockTakeLocationWarehouses, locationPick.storeId]);
  466. const locationAreaOptions = useMemo(() => {
  467. if (!locationPick.storeId || !locationPick.warehouse) return [];
  468. const set = new Set<string>();
  469. stockTakeLocationWarehouses.forEach((w) => {
  470. if (locationPick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== locationPick.storeId) return;
  471. if (locationPick.warehouse !== LOCATION_ALL && (w.warehouse ?? "").trim() !== locationPick.warehouse) return;
  472. const area = w.area?.trim();
  473. if (area) set.add(area);
  474. });
  475. return withAllOption(Array.from(set).sort(compareLocationLabel));
  476. }, [stockTakeLocationWarehouses, locationPick.storeId, locationPick.warehouse]);
  477. const locationSlotOptions = useMemo(() => {
  478. if (!locationPick.storeId || !locationPick.warehouse || !locationPick.area) return [];
  479. const set = new Set<string>();
  480. stockTakeLocationWarehouses.forEach((w) => {
  481. if (locationPick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== locationPick.storeId) return;
  482. if (locationPick.warehouse !== LOCATION_ALL && (w.warehouse ?? "").trim() !== locationPick.warehouse) return;
  483. if (locationPick.area !== LOCATION_ALL && (w.area ?? "").trim() !== locationPick.area) return;
  484. const slot = w.slot?.trim();
  485. if (slot) set.add(slot);
  486. });
  487. return withAllOption(Array.from(set).sort(compareLocationLabel));
  488. }, [stockTakeLocationWarehouses, locationPick.storeId, locationPick.warehouse, locationPick.area]);
  489. const locationWarehouseRows = useMemo(
  490. () => chunkList(locationWarehouseOptions, LOCATION_BUTTONS_PER_ROW),
  491. [locationWarehouseOptions],
  492. );
  493. const locationAreaRows = useMemo(
  494. () => chunkList(locationAreaOptions, LOCATION_BUTTONS_PER_ROW),
  495. [locationAreaOptions],
  496. );
  497. const locationSlotRows = useMemo(
  498. () => chunkList(locationSlotOptions, LOCATION_BUTTONS_PER_ROW),
  499. [locationSlotOptions],
  500. );
  501. const applyFilters = useCallback((next: PickerCardListFilters) => {
  502. const withKind = { ...next, searchKind: next.searchKind ?? "location" };
  503. onSearchFiltersChange(withKind);
  504. onAppliedFiltersChange(appliedPickerFilters(withKind, locationPick, stockTakeLocationWarehouses));
  505. onListPageChange(0);
  506. }, [locationPick, onAppliedFiltersChange, onListPageChange, onSearchFiltersChange, stockTakeLocationWarehouses]);
  507. /** 依盤點區域搜索時用這一輪全部區域;依位置搜索時下拉不顯示 */
  508. const searchSectionCodes = useMemo(() => {
  509. if (searchKind === "section" || !locationPick.storeId) return [...roundSectionCodes].sort(compareLocationLabel);
  510. const codes = new Set<string>();
  511. stockTakeLocationWarehouses.forEach((w) => {
  512. const section = w.stockTakeSection?.trim();
  513. if (!section) return;
  514. if (locationPick.storeId !== LOCATION_ALL && (w.store_id ?? "").trim() !== locationPick.storeId) return;
  515. if (locationPick.warehouse && locationPick.warehouse !== LOCATION_ALL && (w.warehouse ?? "").trim() !== locationPick.warehouse) return;
  516. if (locationPick.area && locationPick.area !== LOCATION_ALL && (w.area ?? "").trim() !== locationPick.area) return;
  517. if (locationPick.slot && locationPick.slot !== LOCATION_ALL && (w.slot ?? "").trim() !== locationPick.slot) return;
  518. codes.add(section);
  519. });
  520. return Array.from(codes).sort(compareLocationLabel);
  521. }, [locationPick, roundSectionCodes, searchKind, stockTakeLocationWarehouses]);
  522. const sectionDescriptionByCode = useMemo(() => {
  523. const map = new Map<string, string>();
  524. stockTakeSectionRows.forEach((row) => {
  525. const section = row.stockTakeSection?.trim();
  526. const text = row.stockTakeSectionDescription?.trim();
  527. if (section && text) map.set(section, text);
  528. });
  529. roundRecords.forEach((session) => {
  530. const section = session.stockTakeSession?.trim();
  531. const text = session.stockTakeSectionDescription?.trim();
  532. if (section && text && !map.has(section)) map.set(section, text);
  533. });
  534. return map;
  535. }, [stockTakeSectionRows, roundRecords]);
  536. const searchSectionDescriptionOptions = useMemo(() => {
  537. const picked = selectedSectionCodes(searchFilters.stockTakeSession).filter((code) =>
  538. searchSectionCodes.includes(code)
  539. );
  540. const source = picked.length > 0 ? picked : searchSectionCodes;
  541. const desc = new Set<string>();
  542. source.forEach((code) => {
  543. const text = sectionDescriptionByCode.get(code);
  544. if (text) desc.add(text);
  545. });
  546. return Array.from(desc).sort((a, b) => a.localeCompare(b));
  547. }, [searchFilters.stockTakeSession, searchSectionCodes, sectionDescriptionByCode]);
  548. const sectionCodeMenuOptions = useMemo(() => {
  549. const descFilter = searchFilters.sectionDescription !== "All" ? searchFilters.sectionDescription : "";
  550. if (!descFilter) return searchSectionCodes;
  551. return searchSectionCodes.filter((code) => sectionDescriptionByCode.get(code) === descFilter);
  552. }, [searchFilters.sectionDescription, searchSectionCodes, sectionDescriptionByCode]);
  553. useEffect(() => {
  554. if (searchKind !== "section") return;
  555. const current = searchFilters.sectionDescription;
  556. if (!current || current === "All") return;
  557. if (roundSectionCodes.length === 0) return;
  558. if (searchSectionDescriptionOptions.includes(current)) return;
  559. applyFilters({ ...searchFilters, sectionDescription: "All", searchKind: "section" });
  560. }, [
  561. applyFilters,
  562. roundSectionCodes.length,
  563. searchFilters,
  564. searchKind,
  565. searchSectionDescriptionOptions,
  566. ]);
  567. const applyLocationPick = useCallback(
  568. (pick: PickerLocationPick) => {
  569. const location = locationQueryFromPick(pick, stockTakeLocationWarehouses);
  570. locationOwnedSectionRef.current = location.stockTakeSession || null;
  571. const next: PickerCardListFilters = {
  572. ...searchFilters,
  573. searchKind: "location",
  574. };
  575. onSearchFiltersChange(next);
  576. onAppliedFiltersChange(appliedPickerFilters(next, pick, stockTakeLocationWarehouses));
  577. onListPageChange(0);
  578. },
  579. [onAppliedFiltersChange, onListPageChange, onSearchFiltersChange, searchFilters, stockTakeLocationWarehouses],
  580. );
  581. useEffect(() => {
  582. if (searchKind !== "location") return;
  583. if (!locationPick.storeId && !locationPick.warehouse && !locationPick.area && !locationPick.slot) return;
  584. if (roundSectionCodes.length === 0 || locationWarehouses.length === 0) return;
  585. const floorOk = !locationPick.storeId || locationFloorOptions.includes(locationPick.storeId);
  586. const warehouseOk = !locationPick.warehouse || locationWarehouseOptions.includes(locationPick.warehouse);
  587. const areaOk = !locationPick.area || locationAreaOptions.includes(locationPick.area);
  588. const slotOk = !locationPick.slot || locationSlotOptions.includes(locationPick.slot);
  589. if (floorOk && warehouseOk && areaOk && slotOk) return;
  590. onLocationPickChange(EMPTY_PICKER_LOCATION);
  591. applyLocationPick(EMPTY_PICKER_LOCATION);
  592. }, [applyLocationPick, locationAreaOptions, locationFloorOptions, locationPick, locationSlotOptions, locationWarehouseOptions, locationWarehouses.length, onLocationPickChange, roundSectionCodes.length]);
  593. useEffect(() => {
  594. let cancelled = false;
  595. getLatestStockTakeRoundMeta()
  596. .then((meta) => {
  597. if (cancelled) return;
  598. if (meta?.planStartDate) {
  599. setGlobalRoundPlanStartDate(dayjs(meta.planStartDate).format(OUTPUT_DATE_FORMAT));
  600. } else {
  601. setGlobalRoundPlanStartDate(null);
  602. }
  603. })
  604. .catch((e) => {
  605. console.error("Failed to load latest stock take round meta:", e);
  606. if (!cancelled) setGlobalRoundPlanStartDate(null);
  607. });
  608. return () => {
  609. cancelled = true;
  610. };
  611. }, [listRefreshNonce]);
  612. //const startIdx = page * PER_PAGE;
  613. //const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE);
  614. useEffect(() => {
  615. if (openConfirmDialog) {
  616. setCreateDialogSelectedSections([]);
  617. setCreateDialogRoundName("");
  618. setCreateDialogPlanStart(dayjs());
  619. setCreateFloorTabIndex(0);
  620. setCreateDialogSearchQuery("");
  621. marqueeDragRef.current = null;
  622. setMarqueeRect(null);
  623. setMarqueePreviewIds(new Set());
  624. setOpenCreateStockTakeSummaryConfirm(false);
  625. }
  626. }, [openConfirmDialog]);
  627. const loadMissingStockTakeSectionIssues = useCallback(async () => {
  628. setMissingSectionIssuesLoading(true);
  629. try {
  630. const res = await fetchMissingStockTakeSectionIssues(50);
  631. setMissingSectionCount(res.count);
  632. setMissingSectionItems(Array.isArray(res.items) ? res.items : []);
  633. setMissingSectionIssuesLimit(res.limit ?? 50);
  634. } catch (e) {
  635. console.error("Failed to load missing stock take section issues:", e);
  636. setMissingSectionCount(0);
  637. setMissingSectionItems([]);
  638. } finally {
  639. setMissingSectionIssuesLoading(false);
  640. }
  641. }, []);
  642. useEffect(() => {
  643. if (!openConfirmDialog) return;
  644. void loadMissingStockTakeSectionIssues();
  645. }, [openConfirmDialog, loadMissingStockTakeSectionIssues]);
  646. useEffect(() => {
  647. if (!openConfirmDialog) return;
  648. let cancelled = false;
  649. getBusyStockTakeSections()
  650. .then((list) => {
  651. if (cancelled) return;
  652. setBusySections(new Set((Array.isArray(list) ? list : []).map((s) => s.trim()).filter(Boolean)));
  653. })
  654. .catch((e) => {
  655. console.error("Failed to load busy stock take sections:", e);
  656. if (!cancelled) setBusySections(new Set());
  657. });
  658. return () => {
  659. cancelled = true;
  660. };
  661. }, [openConfirmDialog]);
  662. useEffect(() => {
  663. if (!openConfirmDialog) return;
  664. let cancelled = false;
  665. getStockTakeRoundCards()
  666. .then((res) => {
  667. if (cancelled) return;
  668. const blocked = (res.rounds || []).some(
  669. (round) => round.open && round.stockTakeRoundMode === "item",
  670. );
  671. setBlockSectionCreate(blocked);
  672. if (blocked) setCreateMode("item");
  673. })
  674. .catch((e) => {
  675. console.error("Failed to check open item stock take rounds:", e);
  676. if (!cancelled) setBlockSectionCreate(false);
  677. });
  678. return () => {
  679. cancelled = true;
  680. };
  681. }, [openConfirmDialog]);
  682. const handleGoWarehouseSettings = useCallback(() => {
  683. setMissingSectionWarnDrawerOpen(false);
  684. setOpenConfirmDialog(false);
  685. router.push("/settings/warehouse");
  686. }, [router]);
  687. const createStockTakeSummarySections = useMemo(() => {
  688. return [...createDialogSelectedSections].sort((a, b) => a.localeCompare(b));
  689. }, [createDialogSelectedSections]);
  690. const handleOpenCreateStockTakeSummaryConfirm = useCallback(() => {
  691. if (createDialogPlanStart == null || !createDialogPlanStart.isValid()) return;
  692. if (createDialogPlanStart.startOf("day").isBefore(dayjs().startOf("day"))) return;
  693. if (createMode === "item") {
  694. if (createDialogSelectedItems.length === 0) return;
  695. } else if (createDialogSelectedSections.length === 0) {
  696. return;
  697. }
  698. setOpenCreateStockTakeSummaryConfirm(true);
  699. }, [
  700. createDialogPlanStart,
  701. createDialogSelectedItems.length,
  702. createDialogSelectedSections.length,
  703. createMode,
  704. ]);
  705. /** 盤點區域 → 樓層:優先 API(warehouse 帶 storeId),否則用列表卡片上的 storeId */
  706. const sectionsByStore = useMemo(() => {
  707. const sectionToFloor = new Map<string, string>();
  708. stockTakeSectionRows.forEach((row) => {
  709. const sec = row.stockTakeSection?.trim();
  710. if (!sec) return;
  711. const sid = row.storeId?.trim();
  712. if (sid) sectionToFloor.set(sec, sid);
  713. });
  714. roundRecords.forEach((session) => {
  715. const sec = session.stockTakeSession?.trim();
  716. if (!sec) return;
  717. const sid = session.storeId?.trim();
  718. if (sid && !sectionToFloor.has(sec)) sectionToFloor.set(sec, sid);
  719. });
  720. const allSecs = new Set<string>();
  721. stockTakeSectionRows.forEach((r) => {
  722. const s = r.stockTakeSection?.trim();
  723. if (s) allSecs.add(s);
  724. });
  725. stockTakeSectionOptions.forEach((s) => {
  726. const x = s.trim();
  727. if (x) allSecs.add(x);
  728. });
  729. const byFloor = new Map<string, Set<string>>();
  730. allSecs.forEach((sec) => {
  731. const floorKey = sectionToFloor.get(sec)?.trim() || FLOOR_GROUP_UNSET;
  732. if (!byFloor.has(floorKey)) byFloor.set(floorKey, new Set());
  733. byFloor.get(floorKey)!.add(sec);
  734. });
  735. const out = new Map<string, string[]>();
  736. byFloor.forEach((set, key) => {
  737. out.set(key, Array.from(set).sort((a, b) => a.localeCompare(b)));
  738. });
  739. return out;
  740. }, [stockTakeSectionRows, roundRecords, stockTakeSectionOptions]);
  741. const sortedFloorKeys = useMemo(() => {
  742. const keys = Array.from(sectionsByStore.keys());
  743. keys.sort((a, b) => {
  744. if (a === FLOOR_GROUP_UNSET) return 1;
  745. if (b === FLOOR_GROUP_UNSET) return -1;
  746. return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
  747. });
  748. return keys;
  749. }, [sectionsByStore]);
  750. const isSectionBusy = useCallback(
  751. (section: string) => busySections.has(section.trim()),
  752. [busySections],
  753. );
  754. const allSectionsForCreateDialog = useMemo(() => {
  755. const s = new Set<string>();
  756. sectionsByStore.forEach((arr) => arr.forEach((x) => s.add(x)));
  757. return Array.from(s)
  758. .filter((sec) => !busySections.has(sec.trim()))
  759. .sort((a, b) => a.localeCompare(b));
  760. }, [busySections, sectionsByStore]);
  761. useEffect(() => {
  762. if (createFloorTabIndex >= sortedFloorKeys.length && sortedFloorKeys.length > 0) {
  763. setCreateFloorTabIndex(0);
  764. }
  765. }, [sortedFloorKeys.length, createFloorTabIndex]);
  766. const floorGroupLabel = useCallback(
  767. (storeKey: string) => (storeKey === FLOOR_GROUP_UNSET ? t("Floor unassigned") : storeKey),
  768. [t],
  769. );
  770. const toggleFloorSelectAll = useCallback(
  771. (storeKey: string) => {
  772. const list = (sectionsByStore.get(storeKey) ?? []).filter((s) => !busySections.has(s.trim()));
  773. setCreateDialogSelectedSections((prev) => {
  774. const allIn = list.length > 0 && list.every((s) => prev.includes(s));
  775. if (allIn) {
  776. return prev.filter((s) => !list.includes(s));
  777. }
  778. const next = new Set(prev);
  779. list.forEach((s) => next.add(s));
  780. return Array.from(next).sort((a, b) => a.localeCompare(b));
  781. });
  782. },
  783. [busySections, sectionsByStore],
  784. );
  785. /** 與卡片標題括號內相同:描述 / 區域 / 樓層 */
  786. const getCreateDialogSectionMeta = useCallback(
  787. (section: string) => {
  788. const row = stockTakeSectionRows.find((r) => r.stockTakeSection?.trim() === section);
  789. const fromList = roundRecords.find((s) => s.stockTakeSession?.trim() === section);
  790. const desc = row?.stockTakeSectionDescription?.trim() || fromList?.stockTakeSectionDescription?.trim() || "";
  791. const area = row?.warehouseArea?.trim() || fromList?.warehouseArea?.trim() || "";
  792. // const store = row?.storeId?.trim() || fromList?.storeId?.trim() || "";
  793. const parts = [desc, area].filter((v) => Boolean(v && v.trim()));
  794. return parts.length ? parts.join(" / ") : "";
  795. },
  796. [stockTakeSectionRows, roundRecords],
  797. );
  798. const activeCreateFloorKey = useMemo(() => {
  799. if (sortedFloorKeys.length === 0) return null;
  800. return sortedFloorKeys[Math.min(createFloorTabIndex, sortedFloorKeys.length - 1)];
  801. }, [sortedFloorKeys, createFloorTabIndex]);
  802. const activeCreateFloorSections = useMemo(() => {
  803. if (activeCreateFloorKey == null) return [];
  804. return sectionsByStore.get(activeCreateFloorKey) ?? [];
  805. }, [activeCreateFloorKey, sectionsByStore]);
  806. const createDialogFilteredSections = useMemo(() => {
  807. const q = createDialogSearchQuery.trim().toLowerCase();
  808. if (!q) return activeCreateFloorSections;
  809. return activeCreateFloorSections.filter((section) => {
  810. const meta = getCreateDialogSectionMeta(section);
  811. return section.toLowerCase().includes(q) || meta.toLowerCase().includes(q);
  812. });
  813. }, [activeCreateFloorSections, createDialogSearchQuery, getCreateDialogSectionMeta]);
  814. const getSectionIdsInMarquee = useCallback(
  815. (startX: number, startY: number, endX: number, endY: number, sectionIds: string[]) => {
  816. const marquee = clientMarqueeRect(startX, startY, endX, endY);
  817. const hit = new Set<string>();
  818. sectionIds.forEach((section) => {
  819. const el = createSectionCardRefs.current.get(section);
  820. if (!el) return;
  821. if (rectsIntersect(el.getBoundingClientRect(), marquee)) {
  822. hit.add(section);
  823. }
  824. });
  825. return hit;
  826. },
  827. [],
  828. );
  829. const syncMarqueeDragUi = useCallback(() => {
  830. const drag = marqueeDragRef.current;
  831. const scrollEl = createSectionGridScrollRef.current;
  832. if (!drag || !scrollEl) return;
  833. const dist = Math.hypot(
  834. drag.currentClientX - drag.startClientX,
  835. drag.currentClientY - drag.startClientY,
  836. );
  837. if (!drag.isMarquee && dist < MARQUEE_DRAG_THRESHOLD_PX) {
  838. setMarqueeRect(null);
  839. setMarqueePreviewIds(new Set());
  840. return;
  841. }
  842. drag.isMarquee = true;
  843. const scrollRect = scrollEl.getBoundingClientRect();
  844. setMarqueeRect({
  845. left: Math.min(drag.startClientX, drag.currentClientX) - scrollRect.left + scrollEl.scrollLeft,
  846. top: Math.min(drag.startClientY, drag.currentClientY) - scrollRect.top + scrollEl.scrollTop,
  847. width: Math.abs(drag.currentClientX - drag.startClientX),
  848. height: Math.abs(drag.currentClientY - drag.startClientY),
  849. });
  850. setMarqueePreviewIds(
  851. getSectionIdsInMarquee(
  852. drag.startClientX,
  853. drag.startClientY,
  854. drag.currentClientX,
  855. drag.currentClientY,
  856. drag.sectionIds,
  857. ),
  858. );
  859. }, [getSectionIdsInMarquee]);
  860. const finishMarqueeDrag = useCallback(() => {
  861. const drag = marqueeDragRef.current;
  862. if (drag?.isMarquee) {
  863. const hit = getSectionIdsInMarquee(
  864. drag.startClientX,
  865. drag.startClientY,
  866. drag.currentClientX,
  867. drag.currentClientY,
  868. drag.sectionIds,
  869. );
  870. const merged = new Set(drag.baseSelection);
  871. hit.forEach((id) => {
  872. if (!busySections.has(id.trim())) merged.add(id);
  873. });
  874. setCreateDialogSelectedSections(
  875. Array.from(merged).sort((a, b) => a.localeCompare(b)),
  876. );
  877. } else if (drag?.pointerDownSection) {
  878. const sec = drag.pointerDownSection;
  879. if (busySections.has(sec.trim())) {
  880. /* in progress */
  881. } else {
  882. setCreateDialogSelectedSections((prev) => {
  883. if (prev.includes(sec)) return prev.filter((s) => s !== sec);
  884. return [...prev, sec].sort((a, b) => a.localeCompare(b));
  885. });
  886. }
  887. }
  888. marqueeDragRef.current = null;
  889. setMarqueeRect(null);
  890. setMarqueePreviewIds(new Set());
  891. }, [busySections, getSectionIdsInMarquee]);
  892. const handleCreateSectionGridPointerDown = useCallback(
  893. (e: React.PointerEvent<HTMLDivElement>) => {
  894. if (e.button !== 0) return;
  895. const scrollEl = createSectionGridScrollRef.current;
  896. if (!scrollEl || createDialogFilteredSections.length === 0) return;
  897. const cardEl = (e.target as HTMLElement).closest("[data-section-card]");
  898. const pointerDownSection = cardEl?.getAttribute("data-section") ?? null;
  899. e.preventDefault();
  900. marqueeDragRef.current = {
  901. pointerId: e.pointerId,
  902. startClientX: e.clientX,
  903. startClientY: e.clientY,
  904. currentClientX: e.clientX,
  905. currentClientY: e.clientY,
  906. baseSelection: new Set(createDialogSelectedSections),
  907. isMarquee: false,
  908. pointerDownSection,
  909. sectionIds: createDialogFilteredSections,
  910. };
  911. const onPointerMove = (ev: PointerEvent) => {
  912. const drag = marqueeDragRef.current;
  913. if (!drag || ev.pointerId !== drag.pointerId) return;
  914. drag.currentClientX = ev.clientX;
  915. drag.currentClientY = ev.clientY;
  916. syncMarqueeDragUi();
  917. };
  918. const onPointerEnd = (ev: PointerEvent) => {
  919. const drag = marqueeDragRef.current;
  920. if (!drag || ev.pointerId !== drag.pointerId) return;
  921. drag.currentClientX = ev.clientX;
  922. drag.currentClientY = ev.clientY;
  923. syncMarqueeDragUi();
  924. finishMarqueeDrag();
  925. window.removeEventListener("pointermove", onPointerMove);
  926. window.removeEventListener("pointerup", onPointerEnd);
  927. window.removeEventListener("pointercancel", onPointerEnd);
  928. try {
  929. scrollEl.releasePointerCapture(ev.pointerId);
  930. } catch {
  931. /* ignore */
  932. }
  933. };
  934. try {
  935. scrollEl.setPointerCapture(e.pointerId);
  936. } catch {
  937. /* ignore */
  938. }
  939. window.addEventListener("pointermove", onPointerMove);
  940. window.addEventListener("pointerup", onPointerEnd);
  941. window.addEventListener("pointercancel", onPointerEnd);
  942. },
  943. [createDialogFilteredSections, createDialogSelectedSections, finishMarqueeDrag, syncMarqueeDragUi],
  944. );
  945. const handleCreateStockTake = useCallback(async () => {
  946. if (createStockTakeInFlightRef.current) return;
  947. if (createMode === "item") {
  948. if (createDialogSelectedItems.length === 0) return;
  949. } else if (createDialogSelectedSections.length === 0) {
  950. return;
  951. }
  952. createStockTakeInFlightRef.current = true;
  953. setOpenCreateStockTakeSummaryConfirm(false);
  954. setOpenConfirmDialog(false);
  955. setCreating(true);
  956. try {
  957. const planStart =
  958. createDialogPlanStart != null
  959. ? dayjsToDateTimeString(createDialogPlanStart.startOf("day"))
  960. : dayjsToDateTimeString(dayjs().startOf("day"));
  961. const result =
  962. createMode === "item"
  963. ? await createStockTakeForItems(
  964. createDialogSelectedItems.map((it) => it.id),
  965. createDialogRoundName,
  966. planStart,
  967. )
  968. : await createStockTakeForSections(
  969. createDialogSelectedSections,
  970. createDialogRoundName,
  971. planStart,
  972. );
  973. const createdCount = Object.values(result).filter((msg) => msg.startsWith("Created:")).length;
  974. const skippedCount = Object.values(result).filter((msg) => msg.startsWith("Skipped:")).length;
  975. const errorCount = Object.values(result).filter((msg) => msg.startsWith("Error:")).length;
  976. let message = `${t("Created")}: ${createdCount}, ${t("Skipped")}: ${skippedCount}`;
  977. if (errorCount > 0) {
  978. message += `, ${t("Errors")}: ${errorCount}`;
  979. }
  980. console.log(message);
  981. onListPageChange(0);
  982. setListRefreshNonce((n) => n + 1);
  983. onRoundsChanged?.();
  984. setCreateDialogRoundName("");
  985. setCreateDialogPlanStart(dayjs());
  986. setCreateDialogSelectedItems([]);
  987. setCreateMode("section");
  988. } catch (e) {
  989. console.error(e);
  990. } finally {
  991. setCreating(false);
  992. createStockTakeInFlightRef.current = false;
  993. }
  994. }, [
  995. createDialogPlanStart,
  996. createDialogRoundName,
  997. createDialogSelectedItems,
  998. createDialogSelectedSections,
  999. createMode,
  1000. onListPageChange,
  1001. onRoundsChanged,
  1002. t,
  1003. ]);
  1004. useEffect(() => {
  1005. if (!openConfirmDialog || createMode !== "item") return;
  1006. const code = createDialogItemCode.trim();
  1007. const name = createDialogItemName.trim();
  1008. if (!code && !name) {
  1009. setCreateDialogItemHits([]);
  1010. return;
  1011. }
  1012. let cancelled = false;
  1013. const timer = window.setTimeout(() => {
  1014. setCreateDialogItemSearching(true);
  1015. const params: Record<string, string | number> = { pageSize: 300, pageNum: 1 };
  1016. if (code) params.code = code;
  1017. if (name) params.name = name;
  1018. fetchItemsByPage(params)
  1019. .then((page) => {
  1020. if (cancelled) return;
  1021. const uniq: CreateDialogItemHit[] = [];
  1022. itemRecordsFromPage(page).forEach((row) => {
  1023. const id = Number(row.id);
  1024. if (!id) return;
  1025. uniq.push({ id, code: row.code || "", name: row.name || "" });
  1026. });
  1027. uniq.sort((a, b) =>
  1028. a.code.localeCompare(b.code, undefined, { numeric: true, sensitivity: "base" }),
  1029. );
  1030. setCreateDialogItemHits(uniq);
  1031. })
  1032. .catch((e) => {
  1033. console.error("Failed to search items for stock take create:", e);
  1034. if (!cancelled) setCreateDialogItemHits([]);
  1035. })
  1036. .finally(() => {
  1037. if (!cancelled) setCreateDialogItemSearching(false);
  1038. });
  1039. }, 300);
  1040. return () => {
  1041. cancelled = true;
  1042. window.clearTimeout(timer);
  1043. };
  1044. }, [openConfirmDialog, createMode, createDialogItemCode, createDialogItemName]);
  1045. useEffect(() => {
  1046. setSectionsLoading(true);
  1047. fetchStockTakeSections()
  1048. .then((sections) => {
  1049. setStockTakeSectionRows(Array.isArray(sections) ? sections : []);
  1050. const sectionSet = new Set<string>();
  1051. sections.forEach((s) => {
  1052. const section = s.stockTakeSection?.trim();
  1053. if (section) sectionSet.add(section);
  1054. });
  1055. setStockTakeSectionOptions(Array.from(sectionSet).sort((a, b) => a.localeCompare(b)));
  1056. })
  1057. .catch((e) => {
  1058. console.error("Failed to load section descriptions for filter:", e);
  1059. })
  1060. .finally(() => {
  1061. setSectionsLoading(false);
  1062. });
  1063. }, []);
  1064. useEffect(() => {
  1065. setStockTakeSectionOptions((prev) => {
  1066. const sectionSet = new Set<string>(prev);
  1067. roundRecords.forEach((item) => {
  1068. const section = item.stockTakeSession?.trim();
  1069. if (section) sectionSet.add(section);
  1070. });
  1071. return Array.from(sectionSet).sort((a, b) => a.localeCompare(b));
  1072. });
  1073. }, [roundRecords]);
  1074. const getStatusColor = (status: string) => {
  1075. const statusLower = status.toLowerCase();
  1076. if (statusLower === "completed") return "success";
  1077. if (statusLower === "in_progress" || statusLower === "processing") return "primary";
  1078. if (statusLower === "approving") return "info";
  1079. if (statusLower === "stockTaking") return "primary";
  1080. if (statusLower === "no_cycle") return "default";
  1081. return "warning";
  1082. };
  1083. const TimeDisplay: React.FC<{ startTime: string | null; endTime: string | null }> = ({ startTime, endTime }) => {
  1084. const [currentTime, setCurrentTime] = useState(dayjs());
  1085. useEffect(() => {
  1086. if (!endTime && startTime) {
  1087. const interval = setInterval(() => {
  1088. setCurrentTime(dayjs());
  1089. }, 1000); // 每秒更新一次
  1090. return () => clearInterval(interval);
  1091. }
  1092. }, [startTime, endTime]);
  1093. if (endTime && startTime) {
  1094. // 当有结束时间时,计算从开始到结束的持续时间
  1095. const start = dayjs(startTime);
  1096. const end = dayjs(endTime);
  1097. const duration = dayjs.duration(end.diff(start));
  1098. const hours = Math.floor(duration.asHours());
  1099. const minutes = duration.minutes();
  1100. const seconds = duration.seconds();
  1101. return (
  1102. <>
  1103. {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}
  1104. </>
  1105. );
  1106. } else if (startTime) {
  1107. // 当没有结束时间时,显示实时计时器
  1108. const start = dayjs(startTime);
  1109. const duration = dayjs.duration(currentTime.diff(start));
  1110. const hours = Math.floor(duration.asHours());
  1111. const minutes = duration.minutes();
  1112. const seconds = duration.seconds();
  1113. return (
  1114. <>
  1115. {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}
  1116. </>
  1117. );
  1118. } else {
  1119. return <>-</>;
  1120. }
  1121. };
  1122. const startTimeDisplay = (startTime: string | null) => {
  1123. if (startTime) {
  1124. const start = dayjs(startTime);
  1125. return start.format("HH:mm");
  1126. } else {
  1127. return "-";
  1128. }
  1129. };
  1130. const endTimeDisplay = (endTime: string | null) => {
  1131. if (endTime) {
  1132. const end = dayjs(endTime);
  1133. return end.format("HH:mm");
  1134. } else {
  1135. return "-";
  1136. }
  1137. };
  1138. const getCompletionRate = (session: AllPickedStockTakeListReponse): number => {
  1139. if (session.totalInventoryLotNumber === 0) return 0;
  1140. return Math.round((session.currentStockTakeItemNumber / session.totalInventoryLotNumber) * 100);
  1141. };
  1142. const planStartDate = globalRoundPlanStartDate;
  1143. const closeCreateDialog = () => {
  1144. setOpenConfirmDialog(false);
  1145. setCreateDialogRoundName("");
  1146. setCreateDialogPlanStart(dayjs());
  1147. setCreateMode(blockSectionCreate ? "item" : "section");
  1148. setCreateDialogItemCode("");
  1149. setCreateDialogItemName("");
  1150. setCreateDialogItemHits([]);
  1151. setCreateDialogSelectedItems([]);
  1152. onCreateDialogClose?.();
  1153. };
  1154. return (
  1155. <Box>
  1156. {!hideSectionList && (
  1157. <>
  1158. <Card elevation={0} sx={{ mb: 2 }}>
  1159. <CardContent>
  1160. <Typography variant="overline" sx={{ display: "block", mb: 1 }}>
  1161. {t("Search Criteria")}
  1162. </Typography>
  1163. <Grid container spacing={2} alignItems="flex-start">
  1164. <Grid item xs={12}>
  1165. <Tabs
  1166. value={searchKind}
  1167. onChange={(_, value: PickerSearchKind) => applyFilters({ ...searchFilters, searchKind: value })}
  1168. sx={{ minHeight: 48 }}
  1169. >
  1170. <Tab
  1171. value="location"
  1172. label={t("Search by location")}
  1173. sx={{ minHeight: 48, py: 1, textTransform: "none" }}
  1174. />
  1175. <Tab
  1176. value="section"
  1177. label={t("Search by stock take section")}
  1178. sx={{ minHeight: 48, py: 1, textTransform: "none" }}
  1179. />
  1180. </Tabs>
  1181. </Grid>
  1182. {searchKind === "section" && (
  1183. <Grid item xs={12} md={4} sx={{ order: 1 }}>
  1184. <FormControl fullWidth size="small" sx={searchFieldSx}>
  1185. <InputLabel>{t("Stock Take Section")}</InputLabel>
  1186. <Select
  1187. value={
  1188. searchFilters.sectionDescription === "All" ||
  1189. searchSectionDescriptionOptions.includes(searchFilters.sectionDescription)
  1190. ? searchFilters.sectionDescription
  1191. : "All"
  1192. }
  1193. MenuProps={searchMenuProps}
  1194. label={t("Stock Take Section")}
  1195. onChange={(e) => {
  1196. const nextDesc = e.target.value;
  1197. const allowed = new Set(
  1198. nextDesc === "All"
  1199. ? searchSectionCodes
  1200. : searchSectionCodes.filter((code) => sectionDescriptionByCode.get(code) === nextDesc)
  1201. );
  1202. const kept = selectedSectionCodes(searchFilters.stockTakeSession).filter((code) => allowed.has(code));
  1203. const nextSession = kept.join(",");
  1204. if (nextSession !== (searchFilters.stockTakeSession || "")) {
  1205. locationOwnedSectionRef.current = null;
  1206. }
  1207. applyFilters({
  1208. ...searchFilters,
  1209. sectionDescription: nextDesc,
  1210. stockTakeSession: nextSession,
  1211. });
  1212. }}
  1213. >
  1214. <MenuItem value="All">{t("All")}</MenuItem>
  1215. {searchSectionDescriptionOptions.map((desc) => (
  1216. <MenuItem key={desc} value={desc}>
  1217. {desc}
  1218. </MenuItem>
  1219. ))}
  1220. </Select>
  1221. </FormControl>
  1222. </Grid>
  1223. )}
  1224. {searchKind === "section" && (
  1225. <Grid item xs={12} md={4} sx={{ order: 1 }}>
  1226. <FormControl fullWidth size="small" sx={searchFieldSx}>
  1227. <InputLabel>{t("Stock Take Section code")}</InputLabel>
  1228. <Select
  1229. multiple
  1230. MenuProps={searchMenuProps}
  1231. value={selectedSectionCodes(searchFilters.stockTakeSession).filter((code) =>
  1232. sectionCodeMenuOptions.includes(code)
  1233. )}
  1234. label={t("Stock Take Section code")}
  1235. onChange={(e) => {
  1236. const raw = e.target.value;
  1237. const picked = (typeof raw === "string" ? raw.split(",") : raw)
  1238. .map((part) => part.trim())
  1239. .filter(Boolean);
  1240. if (locationOwnedSectionRef.current != null) locationOwnedSectionRef.current = null;
  1241. const descriptions = new Set<string>();
  1242. picked.forEach((code) => {
  1243. const text = sectionDescriptionByCode.get(code);
  1244. if (text) descriptions.add(text);
  1245. });
  1246. const nextDesc =
  1247. searchFilters.sectionDescription !== "All" &&
  1248. picked.length > 0 &&
  1249. !descriptions.has(searchFilters.sectionDescription)
  1250. ? "All"
  1251. : searchFilters.sectionDescription;
  1252. applyFilters({
  1253. ...searchFilters,
  1254. sectionDescription: nextDesc,
  1255. stockTakeSession: picked.join(","),
  1256. });
  1257. }}
  1258. renderValue={(selected) => (selected as string[]).join(", ")}
  1259. >
  1260. {sectionCodeMenuOptions.map((code) => (
  1261. <MenuItem key={code} value={code}>
  1262. {code}
  1263. </MenuItem>
  1264. ))}
  1265. </Select>
  1266. </FormControl>
  1267. </Grid>
  1268. )}
  1269. <Grid item xs={12} sx={{ order: searchKind === "location" ? 2 : undefined }}>
  1270. <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
  1271. <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600 }}>
  1272. {t("Status")}
  1273. </Typography>
  1274. <ToggleButtonGroup
  1275. exclusive
  1276. value={searchFilters.status || "All"}
  1277. onChange={(_, next: string | null) => {
  1278. if (next == null) return;
  1279. applyFilters({ ...searchFilters, status: next });
  1280. }}
  1281. >
  1282. {statusOptions.map((status) => (
  1283. <ToggleButton key={status} value={status} sx={{ minHeight: 48, px: 2, textTransform: "none" }}>
  1284. {status === "All" ? t("All") : t(status)}
  1285. </ToggleButton>
  1286. ))}
  1287. </ToggleButtonGroup>
  1288. </Box>
  1289. </Grid>
  1290. {searchKind === "location" && (
  1291. <Grid item xs={12} sx={{ order: 1 }}>
  1292. <Stack spacing={1.25}>
  1293. <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
  1294. <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600 }}>
  1295. {t("Store ID")}
  1296. </Typography>
  1297. <ToggleButtonGroup
  1298. exclusive
  1299. value={locationPick.storeId || null}
  1300. onChange={(_, next: string | null) => {
  1301. const pick = { storeId: next ?? "", warehouse: "", area: "", slot: "" };
  1302. onLocationPickChange(pick);
  1303. applyLocationPick(pick);
  1304. }}
  1305. >
  1306. {locationFloorOptions.map((floor) => (
  1307. <ToggleButton key={floor} value={floor} sx={{ minHeight: 48, px: 2, textTransform: "none" }}>
  1308. {floor === LOCATION_ALL ? t("All") : floor}
  1309. </ToggleButton>
  1310. ))}
  1311. </ToggleButtonGroup>
  1312. </Box>
  1313. <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}>
  1314. <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}>
  1315. {t("Warehouse")}
  1316. </Typography>
  1317. <Box sx={{ display: "flex", flexDirection: "column", gap: 0.75, opacity: locationPick.storeId ? 1 : 0.45 }}>
  1318. {locationWarehouseRows.map((row, rowIndex) => (
  1319. <ToggleButtonGroup
  1320. key={row.join("-") || rowIndex}
  1321. exclusive
  1322. disabled={!locationPick.storeId}
  1323. value={locationPick.storeId ? locationPick.warehouse || null : null}
  1324. onChange={(_, next: string | null) => {
  1325. if (next == null) return;
  1326. const pick = { ...locationPick, warehouse: next, area: "", slot: "" };
  1327. onLocationPickChange(pick);
  1328. applyLocationPick(pick);
  1329. }}
  1330. sx={{
  1331. "& .MuiToggleButtonGroup-grouped": {
  1332. width: LOCATION_BUTTON_WIDTH,
  1333. minWidth: LOCATION_BUTTON_WIDTH,
  1334. maxWidth: LOCATION_BUTTON_WIDTH,
  1335. px: 0,
  1336. },
  1337. }}
  1338. >
  1339. {row.map((zone) => (
  1340. <ToggleButton key={zone} value={zone} sx={{ minHeight: 48, textTransform: "none" }}>
  1341. {zone === LOCATION_ALL ? t("All") : zone}
  1342. </ToggleButton>
  1343. ))}
  1344. </ToggleButtonGroup>
  1345. ))}
  1346. </Box>
  1347. {!locationPick.storeId ? (
  1348. <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
  1349. {t("Select floor first")}
  1350. </Typography>
  1351. ) : null}
  1352. </Box>
  1353. <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}>
  1354. <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}>
  1355. {t("Area")}
  1356. </Typography>
  1357. <Box
  1358. sx={{
  1359. display: "flex",
  1360. flexDirection: "column",
  1361. gap: 0.75,
  1362. opacity: locationPick.storeId && locationPick.warehouse ? 1 : 0.45,
  1363. }}
  1364. >
  1365. {locationAreaRows.map((row, rowIndex) => (
  1366. <ToggleButtonGroup
  1367. key={row.join("-") || rowIndex}
  1368. exclusive
  1369. disabled={!locationPick.storeId || !locationPick.warehouse}
  1370. value={locationPick.warehouse ? locationPick.area || null : null}
  1371. onChange={(_, next: string | null) => {
  1372. if (next == null) return;
  1373. const pick = { ...locationPick, area: next, slot: "" };
  1374. onLocationPickChange(pick);
  1375. applyLocationPick(pick);
  1376. }}
  1377. sx={{
  1378. "& .MuiToggleButtonGroup-grouped": {
  1379. width: LOCATION_BUTTON_WIDTH,
  1380. minWidth: LOCATION_BUTTON_WIDTH,
  1381. maxWidth: LOCATION_BUTTON_WIDTH,
  1382. px: 0,
  1383. },
  1384. }}
  1385. >
  1386. {row.map((area) => (
  1387. <ToggleButton key={area} value={area} sx={{ minHeight: 48, textTransform: "none" }}>
  1388. {area === LOCATION_ALL ? t("All") : area}
  1389. </ToggleButton>
  1390. ))}
  1391. </ToggleButtonGroup>
  1392. ))}
  1393. </Box>
  1394. {!locationPick.warehouse ? (
  1395. <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
  1396. {locationPick.storeId ? t("Select warehouse first") : t("Select floor first")}
  1397. </Typography>
  1398. ) : null}
  1399. </Box>
  1400. <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}>
  1401. <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}>
  1402. {t("Slot")}
  1403. </Typography>
  1404. <Box
  1405. sx={{
  1406. display: "flex",
  1407. flexDirection: "column",
  1408. gap: 0.75,
  1409. opacity: locationPick.storeId && locationPick.warehouse && locationPick.area ? 1 : 0.45,
  1410. }}
  1411. >
  1412. {locationSlotRows.map((row, rowIndex) => (
  1413. <ToggleButtonGroup
  1414. key={row.join("-") || rowIndex}
  1415. exclusive
  1416. disabled={!locationPick.storeId || !locationPick.warehouse || !locationPick.area}
  1417. value={locationPick.area ? locationPick.slot || null : null}
  1418. onChange={(_, next: string | null) => {
  1419. if (next == null) return;
  1420. const pick = { ...locationPick, slot: next };
  1421. onLocationPickChange(pick);
  1422. applyLocationPick(pick);
  1423. }}
  1424. sx={{
  1425. "& .MuiToggleButtonGroup-grouped": {
  1426. width: LOCATION_BUTTON_WIDTH,
  1427. minWidth: LOCATION_BUTTON_WIDTH,
  1428. maxWidth: LOCATION_BUTTON_WIDTH,
  1429. px: 0,
  1430. },
  1431. }}
  1432. >
  1433. {row.map((slot) => (
  1434. <ToggleButton key={slot} value={slot} sx={{ minHeight: 48, textTransform: "none" }}>
  1435. {slot === LOCATION_ALL ? t("All") : slot}
  1436. </ToggleButton>
  1437. ))}
  1438. </ToggleButtonGroup>
  1439. ))}
  1440. </Box>
  1441. {!locationPick.area ? (
  1442. <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
  1443. {locationPick.warehouse ? t("Select area first") : locationPick.storeId ? t("Select warehouse first") : t("Select floor first")}
  1444. </Typography>
  1445. ) : null}
  1446. </Box>
  1447. </Stack>
  1448. </Grid>
  1449. )}
  1450. </Grid>
  1451. <CardActions sx={{ px: 0, pt: 2, gap: 1 }}>
  1452. <Button variant="outlined" onClick={handleResetSearch} sx={{ minHeight: 48, px: 2 }}>
  1453. {t("Reset")}
  1454. </Button>
  1455. </CardActions>
  1456. </CardContent>
  1457. </Card>
  1458. <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
  1459. <Typography variant="body2" color="text.secondary">
  1460. {roundRecords.some((s) => s.listMode === "item") ? t("Total Items") : t("Total Sections")}: {total}
  1461. </Typography>
  1462. <Typography variant="body2" color="text.secondary">
  1463. {t("Start Stock Take Date")}: {planStartDate || "-"}
  1464. </Typography>
  1465. {stockTakeRoundId != null && stockTakeRoundId > 0 ? null : (
  1466. <Button
  1467. variant="contained"
  1468. color="primary"
  1469. onClick={() => setOpenConfirmDialog(true)}
  1470. disabled={creating || !canManageStockTake}
  1471. >
  1472. {creating ? <CircularProgress size={20} /> : t("Create Stock Take (Select Sections)")}
  1473. </Button>
  1474. )}
  1475. </Box>
  1476. {loading ? (
  1477. <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
  1478. <CircularProgress />
  1479. </Box>
  1480. ) : (
  1481. <Grid container spacing={2}>
  1482. {stockTakeSessions.map((session: AllPickedStockTakeListReponse) => {
  1483. const statusColor = getStatusColor(session.status || "");
  1484. const lastStockTakeDate = session.lastStockTakeDate
  1485. ? dayjs(session.lastStockTakeDate).format(OUTPUT_DATE_FORMAT)
  1486. : "-";
  1487. const completionRate = getCompletionRate(session);
  1488. const sectionMeta = [
  1489. session.stockTakeSectionDescription,
  1490. session.warehouseArea,
  1491. session.storeId,
  1492. ].filter((v): v is string => Boolean(v && v.trim())).join(" / ");
  1493. return (
  1494. <Grid key={session.id} item xs={12} md={6}>
  1495. <Card
  1496. sx={{
  1497. minHeight: 200,
  1498. display: "flex",
  1499. flexDirection: "column",
  1500. border: "1px solid",
  1501. borderColor: statusColor === "success" ? "success.main" : "primary.main",
  1502. }}
  1503. >
  1504. <CardContent sx={{ pb: 1, flexGrow: 1 }}>
  1505. <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
  1506. <Typography variant="subtitle1" fontWeight={600}>
  1507. {session.listMode === "item"
  1508. ? `${t("Item")}: ${session.stockTakeSession}${
  1509. session.stockTakeSectionDescription ? ` (${session.stockTakeSectionDescription})` : ""
  1510. }`
  1511. : `${t("Section")}: ${session.stockTakeSession}${sectionMeta ? ` (${sectionMeta})` : ""}`}
  1512. </Typography>
  1513. </Stack>
  1514. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
  1515. {t("Last Stock Take Date")}: {lastStockTakeDate || "-"}
  1516. </Typography>
  1517. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Stock Taker")}: {session.stockTakerName}</Typography>
  1518. <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
  1519. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("start time")}: {startTimeDisplay(session.startTime) || "-"}</Typography>
  1520. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("end time")}: {endTimeDisplay(session.endTime) || "-"}</Typography>
  1521. </Stack>
  1522. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
  1523. {t("Control Time")}: <TimeDisplay startTime={session.startTime} endTime={session.endTime} />
  1524. </Typography>
  1525. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Total Item Kind Number")}: {session.totalItemNumber}</Typography>
  1526. </CardContent>
  1527. <CardActions sx={{ pt: 0.5 ,justifyContent: "space-between"}}>
  1528. <Stack direction="row" spacing={1}>
  1529. <Button
  1530. variant="contained"
  1531. sx={{ minHeight: 48, px: 2 }}
  1532. onClick={() => onCardClick(session)}
  1533. >
  1534. {t("View Details")}
  1535. </Button>
  1536. <Button
  1537. variant="contained"
  1538. sx={{ minHeight: 48, px: 2 }}
  1539. onClick={() => onReStockTakeClick(session)}
  1540. disabled={!session.reStockTakeTrueFalse}
  1541. >
  1542. {t("View ReStockTake")}
  1543. </Button>
  1544. </Stack>
  1545. <Stack direction="row" spacing={0.5} flexWrap="wrap" justifyContent="flex-end">
  1546. {session.reStockTakeTrueFalse ? (
  1547. <Chip size="small" label={t("notMatch")} color="warning" />
  1548. ) : null}
  1549. <Chip size="small" label={t(session.status || "")} color={statusColor as any} />
  1550. </Stack>
  1551. </CardActions>
  1552. </Card>
  1553. </Grid>
  1554. );
  1555. })}
  1556. </Grid>
  1557. )}
  1558. {total > 0 && (
  1559. <TablePagination
  1560. component="div"
  1561. count={total}
  1562. page={page}
  1563. rowsPerPage={pageSize}
  1564. onPageChange={(e, newPage) => {
  1565. onListPageChange(newPage);
  1566. }}
  1567. rowsPerPageOptions={[pageSize]} // 如果暂时不让用户改 pageSize,就写死
  1568. />
  1569. )}
  1570. </>
  1571. )}
  1572. {/* Create Stock Take 確認 Dialog */}
  1573. <Dialog
  1574. open={openConfirmDialog}
  1575. onClose={closeCreateDialog}
  1576. maxWidth={false}
  1577. fullWidth
  1578. PaperProps={{
  1579. sx: {
  1580. width: "100%",
  1581. maxWidth: { xs: "100%", sm: 960, md: 1120 },
  1582. maxHeight: "90vh",
  1583. display: "flex",
  1584. flexDirection: "column",
  1585. },
  1586. }}
  1587. >
  1588. <DialogTitle
  1589. sx={{
  1590. pb: 1,
  1591. flexShrink: 0,
  1592. display: "flex",
  1593. alignItems: "center",
  1594. justifyContent: "space-between",
  1595. gap: 1,
  1596. }}
  1597. >
  1598. <Typography component="span" variant="h6" sx={{ fontSize: "1.25rem", fontWeight: 500 }}>
  1599. {t("Create Stock Take (Select Sections)")}
  1600. </Typography>
  1601. <Tooltip
  1602. title={
  1603. missingSectionCount > 0
  1604. ? t("Warehouse missing stock take section tooltip has", { count: missingSectionCount })
  1605. : t("Warehouse missing stock take section tooltip none")
  1606. }
  1607. >
  1608. <Box component="span" sx={{ flexShrink: 0, display: "inline-flex" }}>
  1609. <IconButton
  1610. size="small"
  1611. aria-label={t("Warehouse missing stock take section warn title")}
  1612. onClick={() => setMissingSectionWarnDrawerOpen(true)}
  1613. sx={{
  1614. color: missingSectionCount > 0 ? "warning.main" : "text.secondary",
  1615. }}
  1616. >
  1617. <Badge
  1618. color="error"
  1619. badgeContent={missingSectionCount > 99 ? "99+" : missingSectionCount}
  1620. invisible={missingSectionCount === 0}
  1621. >
  1622. <NotificationsNoneOutlinedIcon />
  1623. </Badge>
  1624. </IconButton>
  1625. </Box>
  1626. </Tooltip>
  1627. </DialogTitle>
  1628. <Box sx={{ px: 2, flexShrink: 0 }}>
  1629. <Tabs
  1630. value={createMode}
  1631. onChange={(_, v: "section" | "item") => {
  1632. if (!v) return;
  1633. setCreateMode(v);
  1634. }}
  1635. >
  1636. {blockSectionCreate ? null : (
  1637. <Tab value="section" label={t("Create by section")} />
  1638. )}
  1639. <Tab value="item" label={t("Create by item")} />
  1640. </Tabs>
  1641. </Box>
  1642. <DialogContent
  1643. dividers
  1644. sx={{
  1645. p: 0,
  1646. flex: 1,
  1647. minHeight: 0,
  1648. overflow: "hidden",
  1649. display: "flex",
  1650. flexDirection: "column",
  1651. }}
  1652. >
  1653. <Box
  1654. sx={{
  1655. display: "flex",
  1656. flex: 1,
  1657. minHeight: 0,
  1658. flexDirection: { xs: "column", md: "row" },
  1659. height: "100%",
  1660. }}
  1661. >
  1662. {/* 左側:盤點設定與樓層 */}
  1663. <Box
  1664. sx={{
  1665. width: { xs: "100%", md: 280 },
  1666. flexShrink: 0,
  1667. minHeight: 0,
  1668. maxHeight: { xs: "38vh", md: "100%" },
  1669. overflowY: "auto",
  1670. borderRight: { md: 1 },
  1671. borderBottom: { xs: 1, md: 0 },
  1672. borderColor: "divider",
  1673. p: 2.5,
  1674. display: "flex",
  1675. flexDirection: "column",
  1676. gap: 2,
  1677. bgcolor: "grey.50",
  1678. }}
  1679. >
  1680. <Box>
  1681. <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
  1682. {t("Stock take round name")}
  1683. </Typography>
  1684. <TextField
  1685. fullWidth
  1686. size="small"
  1687. hiddenLabel
  1688. // placeholder={t("Stock take round name placeholder")}
  1689. value={createDialogRoundName}
  1690. onChange={(e) => setCreateDialogRoundName(e.target.value)}
  1691. inputProps={{ maxLength: 255, "aria-label": t("Stock take round name") }}
  1692. sx={createDialogCompactInputSx}
  1693. />
  1694. </Box>
  1695. <Box>
  1696. <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
  1697. {t("Creation date")}
  1698. </Typography>
  1699. <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
  1700. <DatePicker
  1701. value={createDialogPlanStart}
  1702. onChange={(newValue) => setCreateDialogPlanStart(newValue)}
  1703. format={OUTPUT_DATE_FORMAT}
  1704. minDate={dayjs().startOf("day")}
  1705. slotProps={{
  1706. textField: {
  1707. fullWidth: true,
  1708. size: "small",
  1709. hiddenLabel: true,
  1710. inputProps: { "aria-label": t("Creation date") },
  1711. sx: createDialogCompactInputSx,
  1712. },
  1713. }}
  1714. />
  1715. </LocalizationProvider>
  1716. </Box>
  1717. {createMode === "item" ? (
  1718. <Typography variant="body2" color="text.secondary">
  1719. {t("Create by item hint")}
  1720. </Typography>
  1721. ) : sortedFloorKeys.length === 0 && !sectionsLoading ? (
  1722. <Typography variant="body2" color="text.secondary">
  1723. {t("No stock take sections from warehouse")}
  1724. </Typography>
  1725. ) : (
  1726. <Stack
  1727. spacing={1}
  1728. sx={{
  1729. flexShrink: 0,
  1730. ...(sortedFloorKeys.length > FLOOR_LIST_SCROLL_THRESHOLD
  1731. ? { maxHeight: 300, overflowY: "auto", pr: 0.5 }
  1732. : {}),
  1733. }}
  1734. >
  1735. {sortedFloorKeys.map((floorKey, idx) => {
  1736. const floorSections = sectionsByStore.get(floorKey) ?? [];
  1737. const selected = idx === createFloorTabIndex;
  1738. return (
  1739. <Button
  1740. key={floorKey}
  1741. fullWidth
  1742. variant="outlined"
  1743. onClick={() => {
  1744. setCreateFloorTabIndex(idx);
  1745. setCreateDialogSearchQuery("");
  1746. }}
  1747. sx={{
  1748. justifyContent: "space-between",
  1749. textTransform: "none",
  1750. py: 1.25,
  1751. px: 1.5,
  1752. borderRadius: 2,
  1753. borderWidth: selected ? 2 : 1,
  1754. borderColor: selected ? "primary.main" : "divider",
  1755. color: selected ? "primary.main" : "text.primary",
  1756. bgcolor: "background.paper",
  1757. "&:hover": {
  1758. borderColor: selected ? "primary.main" : "divider",
  1759. bgcolor: "background.paper",
  1760. },
  1761. }}
  1762. >
  1763. <Typography variant="body2" fontWeight={selected ? 700 : 500}>
  1764. {floorGroupLabel(floorKey)}
  1765. </Typography>
  1766. <Chip
  1767. size="small"
  1768. label={floorSections.length}
  1769. sx={{
  1770. height: 22,
  1771. minWidth: 28,
  1772. bgcolor: selected ? "primary.main" : "grey.200",
  1773. color: selected ? "primary.contrastText" : "text.secondary",
  1774. }}
  1775. />
  1776. </Button>
  1777. );
  1778. })}
  1779. </Stack>
  1780. )}
  1781. {createMode === "item" ? null : (
  1782. <Stack spacing={1}>
  1783. <Button
  1784. fullWidth
  1785. variant="outlined"
  1786. onClick={() => setCreateDialogSelectedSections([...allSectionsForCreateDialog])}
  1787. disabled={allSectionsForCreateDialog.length === 0}
  1788. sx={{ bgcolor: "background.paper" }}
  1789. >
  1790. {t("Select all sections all floors")}
  1791. </Button>
  1792. <Button
  1793. fullWidth
  1794. variant="outlined"
  1795. onClick={() => setCreateDialogSelectedSections([])}
  1796. sx={{ bgcolor: "background.paper" }}
  1797. >
  1798. {t("Clear selection all floors")}
  1799. </Button>
  1800. </Stack>
  1801. )}
  1802. <Typography variant="body2" color="text.secondary">
  1803. {createMode === "item" ? t("Total selected items label") : t("Total selected sections label")}{" "}
  1804. <Typography component="span" variant="body2" color="primary.main" fontWeight={700}>
  1805. {createMode === "item"
  1806. ? createDialogSelectedItems.length
  1807. : createDialogSelectedSections.length}
  1808. </Typography>{" "}
  1809. {createMode === "item" ? t("items unit") : t("sections unit")}
  1810. </Typography>
  1811. </Box>
  1812. {/* 右側:區域卡片網格 */}
  1813. <Box
  1814. sx={{
  1815. flex: 1,
  1816. minHeight: 0,
  1817. display: "flex",
  1818. flexDirection: "column",
  1819. minWidth: 0,
  1820. p: 2.5,
  1821. bgcolor: "background.paper",
  1822. overflow: "hidden",
  1823. }}
  1824. >
  1825. {createMode === "item" ? (
  1826. <Stack spacing={1.5} sx={{ flex: 1, minHeight: 0 }}>
  1827. <Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
  1828. <TextField
  1829. fullWidth
  1830. size="small"
  1831. hiddenLabel
  1832. placeholder={t("Item Code")}
  1833. value={createDialogItemCode}
  1834. onChange={(e) => setCreateDialogItemCode(e.target.value)}
  1835. InputProps={{
  1836. startAdornment: (
  1837. <InputAdornment position="start">
  1838. <SearchIcon fontSize="small" color="action" />
  1839. </InputAdornment>
  1840. ),
  1841. }}
  1842. sx={createDialogCompactInputSx}
  1843. />
  1844. <TextField
  1845. fullWidth
  1846. size="small"
  1847. hiddenLabel
  1848. placeholder={t("Item Name")}
  1849. value={createDialogItemName}
  1850. onChange={(e) => setCreateDialogItemName(e.target.value)}
  1851. InputProps={{
  1852. startAdornment: (
  1853. <InputAdornment position="start">
  1854. <SearchIcon fontSize="small" color="action" />
  1855. </InputAdornment>
  1856. ),
  1857. }}
  1858. sx={createDialogCompactInputSx}
  1859. />
  1860. </Stack>
  1861. <Typography variant="caption" color="text.secondary">
  1862. {t("Create by item selected count", { count: createDialogSelectedItems.length })}
  1863. </Typography>
  1864. <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}>
  1865. {createDialogSelectedItems.map((it) => (
  1866. <Chip
  1867. key={it.id}
  1868. size="small"
  1869. label={`${it.code} ${it.name}`.trim()}
  1870. onDelete={() =>
  1871. setCreateDialogSelectedItems((prev) => prev.filter((x) => x.id !== it.id))
  1872. }
  1873. />
  1874. ))}
  1875. </Box>
  1876. <Box sx={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
  1877. {createDialogItemSearching ? (
  1878. <Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
  1879. <CircularProgress size={24} />
  1880. </Box>
  1881. ) : createDialogItemHits.length === 0 ? (
  1882. <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
  1883. {createDialogItemCode.trim() || createDialogItemName.trim()
  1884. ? t("No data")
  1885. : t("Create by item search hint")}
  1886. </Typography>
  1887. ) : (
  1888. <Stack>
  1889. {createDialogItemHits.map((it) => {
  1890. const checked = createDialogSelectedItems.some((x) => x.id === it.id);
  1891. return (
  1892. <Box
  1893. key={it.id}
  1894. sx={{
  1895. display: "flex",
  1896. alignItems: "center",
  1897. gap: 1,
  1898. py: 0.5,
  1899. borderBottom: 1,
  1900. borderColor: "divider",
  1901. }}
  1902. >
  1903. <Checkbox
  1904. checked={checked}
  1905. onChange={() => {
  1906. setCreateDialogSelectedItems((prev) =>
  1907. checked ? prev.filter((x) => x.id !== it.id) : [...prev, it],
  1908. );
  1909. }}
  1910. />
  1911. <Box>
  1912. <Typography variant="body2" fontWeight={600}>
  1913. {it.code}
  1914. </Typography>
  1915. <Typography variant="caption" color="text.secondary">
  1916. {it.name}
  1917. </Typography>
  1918. </Box>
  1919. </Box>
  1920. );
  1921. })}
  1922. </Stack>
  1923. )}
  1924. </Box>
  1925. </Stack>
  1926. ) : activeCreateFloorKey != null ? (
  1927. <>
  1928. <Stack
  1929. direction="row"
  1930. alignItems="center"
  1931. justifyContent="space-between"
  1932. flexWrap="wrap"
  1933. gap={1}
  1934. sx={{ mb: 1 }}
  1935. >
  1936. <Typography variant="subtitle1" fontWeight={700}>
  1937. {t("Floor area selection header", {
  1938. floor: floorGroupLabel(activeCreateFloorKey),
  1939. count: activeCreateFloorSections.length,
  1940. })}
  1941. </Typography>
  1942. <Button
  1943. size="small"
  1944. variant="outlined"
  1945. onClick={() => toggleFloorSelectAll(activeCreateFloorKey)}
  1946. disabled={activeCreateFloorSections.length === 0}
  1947. >
  1948. {activeCreateFloorSections.length > 0 &&
  1949. activeCreateFloorSections.every((s) => createDialogSelectedSections.includes(s))
  1950. ? t("Deselect all on this floor", {
  1951. floor: floorGroupLabel(activeCreateFloorKey),
  1952. })
  1953. : t("Select all on this floor", {
  1954. floor: floorGroupLabel(activeCreateFloorKey),
  1955. })}
  1956. </Button>
  1957. </Stack>
  1958. <TextField
  1959. fullWidth
  1960. size="small"
  1961. hiddenLabel
  1962. placeholder={t("Search section code or name")}
  1963. value={createDialogSearchQuery}
  1964. onChange={(e) => setCreateDialogSearchQuery(e.target.value)}
  1965. inputProps={{ "aria-label": t("Search section code or name") }}
  1966. InputProps={{
  1967. startAdornment: (
  1968. <InputAdornment position="start">
  1969. <SearchIcon fontSize="small" color="action" />
  1970. </InputAdornment>
  1971. ),
  1972. }}
  1973. sx={createDialogCompactInputSx}
  1974. />
  1975. <Box
  1976. ref={createSectionGridScrollRef}
  1977. onPointerDown={handleCreateSectionGridPointerDown}
  1978. sx={{
  1979. position: "relative",
  1980. flex: 1,
  1981. minHeight: 0,
  1982. mt: 2,
  1983. overflowY: "auto",
  1984. pr: 0.5,
  1985. userSelect: marqueeRect ? "none" : "auto",
  1986. touchAction: "pan-y",
  1987. cursor: "default",
  1988. }}
  1989. >
  1990. {sectionsLoading ? (
  1991. <Box
  1992. sx={{
  1993. display: "flex",
  1994. alignItems: "center",
  1995. justifyContent: "center",
  1996. minHeight: 240,
  1997. }}
  1998. >
  1999. <CircularProgress />
  2000. </Box>
  2001. ) : createDialogFilteredSections.length === 0 ? (
  2002. <Typography variant="body2" color="text.secondary" sx={{ py: 4, textAlign: "center" }}>
  2003. {createDialogSearchQuery.trim()
  2004. ? t("No sections match search")
  2005. : t("No stock take sections from warehouse")}
  2006. </Typography>
  2007. ) : (
  2008. <>
  2009. {marqueeRect ? (
  2010. <Box
  2011. aria-hidden
  2012. sx={{
  2013. position: "absolute",
  2014. left: marqueeRect.left,
  2015. top: marqueeRect.top,
  2016. width: marqueeRect.width,
  2017. height: marqueeRect.height,
  2018. border: "1px solid",
  2019. borderColor: "primary.main",
  2020. bgcolor: "primary.main",
  2021. opacity: 0.28,
  2022. pointerEvents: "none",
  2023. zIndex: 3,
  2024. boxSizing: "border-box",
  2025. }}
  2026. />
  2027. ) : null}
  2028. <Box
  2029. sx={{
  2030. display: "grid",
  2031. gridTemplateColumns: {
  2032. xs: "repeat(2, 1fr)",
  2033. sm: "repeat(3, 1fr)",
  2034. md: "repeat(4, 1fr)",
  2035. },
  2036. gap: 1.5,
  2037. alignItems: "start",
  2038. //minHeight: "100%",
  2039. }}
  2040. >
  2041. {createDialogFilteredSections.map((section) => {
  2042. const meta = getCreateDialogSectionMeta(section);
  2043. const busy = isSectionBusy(section);
  2044. const checked = createDialogSelectedSections.includes(section);
  2045. const previewSelected = !busy && marqueePreviewIds.has(section);
  2046. const visuallySelected = !busy && (checked || previewSelected);
  2047. return (
  2048. <Card
  2049. key={section}
  2050. data-section-card
  2051. data-section={section}
  2052. variant="outlined"
  2053. ref={(el) => {
  2054. if (el) {
  2055. createSectionCardRefs.current.set(section, el);
  2056. } else {
  2057. createSectionCardRefs.current.delete(section);
  2058. }
  2059. }}
  2060. sx={{
  2061. cursor: busy ? "not-allowed" : "default",
  2062. opacity: busy ? 0.55 : 1,
  2063. borderWidth: visuallySelected ? 2 : 1,
  2064. borderColor: visuallySelected ? "primary.main" : "divider",
  2065. bgcolor: visuallySelected
  2066. ? previewSelected && !checked
  2067. ? "action.hover"
  2068. : "action.selected"
  2069. : "background.paper",
  2070. transition: marqueeRect ? "none" : "border-color 0.15s, background-color 0.15s",
  2071. "&:hover": {
  2072. borderColor: visuallySelected ? "primary.main" : "grey.400",
  2073. },
  2074. }}
  2075. >
  2076. <CardContent sx={{ p: 1.5, "&:last-child": { pb: 1.5 } }}>
  2077. <Typography variant="body2" fontWeight={700} noWrap title={section}>
  2078. {section}
  2079. </Typography>
  2080. {busy ? (
  2081. <Typography variant="caption" color="warning.main" display="block">
  2082. {t("In progress")}
  2083. </Typography>
  2084. ) : null}
  2085. {meta ? (
  2086. <Typography
  2087. variant="caption"
  2088. color="text.secondary"
  2089. sx={{
  2090. display: "-webkit-box",
  2091. WebkitLineClamp: 2,
  2092. WebkitBoxOrient: "vertical",
  2093. overflow: "hidden",
  2094. lineHeight: 1.35,
  2095. mt: 0.25,
  2096. }}
  2097. >
  2098. {meta}
  2099. </Typography>
  2100. ) : null}
  2101. </CardContent>
  2102. </Card>
  2103. );
  2104. })}
  2105. </Box>
  2106. </>
  2107. )}
  2108. </Box>
  2109. </>
  2110. ) : (
  2111. <Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", flex: 1 }}>
  2112. {sectionsLoading ? (
  2113. <CircularProgress />
  2114. ) : (
  2115. <Typography variant="body2" color="text.secondary">
  2116. {t("No stock take sections from warehouse")}
  2117. </Typography>
  2118. )}
  2119. </Box>
  2120. )}
  2121. </Box>
  2122. </Box>
  2123. </DialogContent>
  2124. <DialogActions sx={{ px: 3, py: 2, flexShrink: 0 }}>
  2125. <Button
  2126. onClick={closeCreateDialog}
  2127. >
  2128. {t("Cancel")}
  2129. </Button>
  2130. <Button
  2131. variant="contained"
  2132. color="primary"
  2133. onClick={handleOpenCreateStockTakeSummaryConfirm}
  2134. disabled={
  2135. creating ||
  2136. (createMode === "item"
  2137. ? createDialogSelectedItems.length === 0
  2138. : createDialogSelectedSections.length === 0) ||
  2139. createDialogPlanStart == null ||
  2140. !createDialogPlanStart.isValid() ||
  2141. createDialogPlanStart.startOf("day").isBefore(dayjs().startOf("day"))
  2142. }
  2143. >
  2144. {t("Confirm")}
  2145. </Button>
  2146. </DialogActions>
  2147. </Dialog>
  2148. <Dialog
  2149. open={openCreateStockTakeSummaryConfirm}
  2150. onClose={() => {
  2151. if (!creating) setOpenCreateStockTakeSummaryConfirm(false);
  2152. }}
  2153. maxWidth="sm"
  2154. fullWidth
  2155. >
  2156. <DialogTitle>{t("Confirm create stock take")}</DialogTitle>
  2157. <DialogContent dividers>
  2158. <Stack spacing={1.5} sx={{ mb: 2 }}>
  2159. <Typography variant="body2">
  2160. <Typography component="span" color="text.secondary">
  2161. {t("Stock take round name")}:{" "}
  2162. </Typography>
  2163. {createDialogRoundName.trim() || t("Not filled")}
  2164. </Typography>
  2165. <Typography variant="body2">
  2166. <Typography component="span" color="text.secondary">
  2167. {t("Creation date")}:{" "}
  2168. </Typography>
  2169. {createDialogPlanStart?.isValid()
  2170. ? createDialogPlanStart.format(OUTPUT_DATE_FORMAT)
  2171. : "-"}
  2172. </Typography>
  2173. <Typography variant="body2" fontWeight={600}>
  2174. {createMode === "item" ? t("Total selected items label") : t("Total selected sections label")}{" "}
  2175. <Typography component="span"fontWeight={600} >
  2176. {createMode === "item"
  2177. ? createDialogSelectedItems.length
  2178. : createStockTakeSummarySections.length}
  2179. </Typography>{" "}
  2180. {createMode === "item" ? t("items unit") : t("sections unit")}
  2181. </Typography>
  2182. </Stack>
  2183. <Box
  2184. sx={{
  2185. maxHeight: 280,
  2186. overflowY: "auto",
  2187. border: 1,
  2188. borderColor: "divider",
  2189. borderRadius: 1,
  2190. p: 1.5,
  2191. }}
  2192. >
  2193. <Stack spacing={1}>
  2194. {createMode === "item"
  2195. ? createDialogSelectedItems.map((it) => (
  2196. <Box key={it.id}>
  2197. <Typography variant="body2" fontWeight={600}>
  2198. {it.code}
  2199. </Typography>
  2200. <Typography variant="caption" color="text.secondary">
  2201. {it.name}
  2202. </Typography>
  2203. </Box>
  2204. ))
  2205. : createStockTakeSummarySections.map((section) => {
  2206. const meta = getCreateDialogSectionMeta(section);
  2207. return (
  2208. <Box key={section}>
  2209. <Typography variant="body2" fontWeight={600}>
  2210. {section}
  2211. </Typography>
  2212. {meta ? (
  2213. <Typography variant="caption" color="text.secondary">
  2214. {meta}
  2215. </Typography>
  2216. ) : null}
  2217. </Box>
  2218. );
  2219. })}
  2220. </Stack>
  2221. </Box>
  2222. </DialogContent>
  2223. <DialogActions sx={{ px: 3, py: 2 }}>
  2224. <Button
  2225. onClick={() => setOpenCreateStockTakeSummaryConfirm(false)}
  2226. disabled={creating}
  2227. >
  2228. {t("Cancel")}
  2229. </Button>
  2230. <Button
  2231. variant="contained"
  2232. color="primary"
  2233. onClick={handleCreateStockTake}
  2234. disabled={
  2235. creating ||
  2236. (createMode === "item"
  2237. ? createDialogSelectedItems.length === 0
  2238. : createDialogSelectedSections.length === 0)
  2239. }
  2240. >
  2241. {creating ? <CircularProgress size={20} /> : t("Confirm")}
  2242. </Button>
  2243. </DialogActions>
  2244. </Dialog>
  2245. <Drawer
  2246. anchor="right"
  2247. open={missingSectionWarnDrawerOpen}
  2248. onClose={() => setMissingSectionWarnDrawerOpen(false)}
  2249. ModalProps={{
  2250. sx: (theme) => ({
  2251. // Drawer 預設 z-index 1200,低於 Dialog (1300),會被建立盤點對話框遮住
  2252. zIndex: theme.zIndex.modal + 2,
  2253. }),
  2254. }}
  2255. PaperProps={{
  2256. sx: {
  2257. width: { xs: "100%", sm: 400 },
  2258. p: 0,
  2259. display: "flex",
  2260. flexDirection: "column",
  2261. maxHeight: "100vh",
  2262. },
  2263. }}
  2264. >
  2265. <Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
  2266. <Typography variant="h6" fontWeight={700}>
  2267. {t("Warehouse missing stock take section warn title")}
  2268. </Typography>
  2269. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
  2270. {t("Warehouse missing stock take section drawer hint")}
  2271. </Typography>
  2272. {missingSectionCount > 0 ? (
  2273. <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}>
  2274. {t("Warehouse missing stock take section showing", {
  2275. shown: Math.min(missingSectionItems.length, missingSectionIssuesLimit),
  2276. count: missingSectionCount,
  2277. })}
  2278. </Typography>
  2279. ) : null}
  2280. </Box>
  2281. <Box sx={{ flex: 1, overflowY: "auto", px: 2, py: 1 }}>
  2282. {missingSectionIssuesLoading ? (
  2283. <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
  2284. <CircularProgress size={28} />
  2285. </Box>
  2286. ) : missingSectionItems.length === 0 ? (
  2287. <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
  2288. {t("Warehouse missing stock take section empty")}
  2289. </Typography>
  2290. ) : (
  2291. <Stack spacing={1.5} divider={<Divider flexItem />}>
  2292. {missingSectionItems.map((row) => (
  2293. <Box key={row.id}>
  2294. <Typography variant="body2" fontWeight={600}>
  2295. {row.code || `#${row.id}`}
  2296. </Typography>
  2297. <Typography variant="caption" color="text.secondary" display="block">
  2298. {[row.storeId, row.warehouse, row.area, row.slot].filter(Boolean).join(" / ") ||
  2299. "—"}
  2300. </Typography>
  2301. {row.order ? (
  2302. <Typography variant="caption" color="text.secondary" display="block">
  2303. {row.order}
  2304. </Typography>
  2305. ) : null}
  2306. </Box>
  2307. ))}
  2308. </Stack>
  2309. )}
  2310. </Box>
  2311. <Box
  2312. sx={{
  2313. p: 2,
  2314. borderTop: 1,
  2315. borderColor: "divider",
  2316. display: "flex",
  2317. gap: 1,
  2318. flexWrap: "wrap",
  2319. }}
  2320. >
  2321. <Button variant="outlined" onClick={() => setMissingSectionWarnDrawerOpen(false)}>
  2322. {t("Cancel")}
  2323. </Button>
  2324. <Button
  2325. variant="contained"
  2326. color="primary"
  2327. onClick={handleGoWarehouseSettings}
  2328. disabled={missingSectionCount === 0}
  2329. >
  2330. {t("Warehouse missing stock take section go settings")}
  2331. </Button>
  2332. </Box>
  2333. </Drawer>
  2334. </Box>
  2335. );
  2336. };
  2337. export default PickerCardList;