FPSMS-frontend
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 

1586 строки
59 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. Autocomplete,
  25. Badge,
  26. IconButton,
  27. Tooltip,
  28. Drawer,
  29. Divider,
  30. } from "@mui/material";
  31. import SearchIcon from "@mui/icons-material/Search";
  32. import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined";
  33. import { useRouter } from "next/navigation";
  34. import { SessionWithTokens } from "@/config/authConfig";
  35. import { useSession } from "next-auth/react";
  36. import { useState, useCallback, useEffect, useRef, useMemo } from "react";
  37. import { useTranslation } from "react-i18next";
  38. import duration from "dayjs/plugin/duration";
  39. import {
  40. AllPickedStockTakeListReponse,
  41. createStockTakeForSections,
  42. getStockTakeRecordsPaged,
  43. getLatestStockTakeRoundMeta,
  44. } from "@/app/api/stockTake/actions";
  45. import { fetchStockTakeSections } from "@/app/api/warehouse/actions";
  46. import { fetchMissingStockTakeSectionIssues } from "@/app/api/warehouse/client";
  47. import type { MissingStockTakeSectionIssueItem, StockTakeSectionInfo } from "@/app/api/warehouse";
  48. import dayjs, { type Dayjs } from "dayjs";
  49. import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
  50. import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
  51. import {
  52. OUTPUT_DATE_FORMAT,
  53. OUTPUT_DATETIME_FORMAT,
  54. dayjsToDateTimeString,
  55. } from "@/app/utils/formatUtil";
  56. import { AUTH } from "@/authorities";
  57. const FLOOR_GROUP_UNSET = "__UNSET__";
  58. /** 移動超過此距離才視為框選(Windows 橡皮筋),否則視為單擊 */
  59. const MARQUEE_DRAG_THRESHOLD_PX = 4;
  60. /** 左側樓層列表超過此數量才啟用捲動 */
  61. const FLOOR_LIST_SCROLL_THRESHOLD = 8;
  62. const createDialogCompactInputSx = {
  63. mt: 0,
  64. mb: 0,
  65. "& .MuiInputBase-root": {
  66. bgcolor: "background.paper",
  67. },
  68. "& .MuiOutlinedInput-input": {
  69. py: "8.5px",
  70. },
  71. "& .MuiInputAdornment-positionStart": {
  72. marginRight: 0.5,
  73. },
  74. "& .MuiInputBase-input::placeholder": {
  75. opacity: 1, // MUI 預設 opacity 較低,設 1 顏色才準
  76. color: "text.secondary", // 中等灰;要更淡用 "text.disabled"
  77. },
  78. } as const;
  79. type MarqueeRect = { left: number; top: number; width: number; height: number };
  80. function rectsIntersect(a: DOMRect, b: DOMRect): boolean {
  81. return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
  82. }
  83. function clientMarqueeRect(startX: number, startY: number, endX: number, endY: number): DOMRect {
  84. const left = Math.min(startX, endX);
  85. const top = Math.min(startY, endY);
  86. const right = Math.max(startX, endX);
  87. const bottom = Math.max(startY, endY);
  88. return { left, top, right, bottom, width: right - left, height: bottom - top } as DOMRect;
  89. }
  90. interface PickerCardListProps {
  91. /** 由父層保存,從明細返回時仍回到同一頁 */
  92. page: number;
  93. pageSize: number;
  94. onListPageChange: (page: number) => void;
  95. onCardClick: (session: AllPickedStockTakeListReponse) => void;
  96. onReStockTakeClick: (session: AllPickedStockTakeListReponse) => void;
  97. searchFilters: PickerCardListFilters;
  98. appliedFilters: PickerCardListFilters;
  99. onSearchFiltersChange: (next: PickerCardListFilters) => void;
  100. onAppliedFiltersChange: (next: PickerCardListFilters) => void;
  101. }
  102. export type PickerCardListFilters = {
  103. sectionDescription: string;
  104. stockTakeSession: string;
  105. status: string;
  106. area: string;
  107. storeId: string;
  108. };
  109. const PickerCardList: React.FC<PickerCardListProps> = ({
  110. page,
  111. pageSize,
  112. onListPageChange,
  113. onCardClick,
  114. onReStockTakeClick,
  115. searchFilters,
  116. appliedFilters,
  117. onSearchFiltersChange,
  118. onAppliedFiltersChange,
  119. }) => {
  120. const { t } = useTranslation(["inventory", "common"]);
  121. const router = useRouter();
  122. dayjs.extend(duration);
  123. const [loading, setLoading] = useState(false);
  124. const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]);
  125. const [total, setTotal] = useState(0);
  126. const { data: session } = useSession() as { data: SessionWithTokens | null };
  127. const abilities = session?.abilities ?? session?.user?.abilities ?? [];
  128. const canManageStockTake = abilities.some((a) => a.trim() === AUTH.ADMIN);
  129. /** 建立盤點後若仍在 page 0,仍強制重新載入 */
  130. const [listRefreshNonce, setListRefreshNonce] = useState(0);
  131. const [globalRoundPlanStartDate, setGlobalRoundPlanStartDate] = useState<string | null>(null);
  132. const [creating, setCreating] = useState(false);
  133. const [openConfirmDialog, setOpenConfirmDialog] = useState(false);
  134. const [openCreateStockTakeSummaryConfirm, setOpenCreateStockTakeSummaryConfirm] = useState(false);
  135. const [missingSectionWarnDrawerOpen, setMissingSectionWarnDrawerOpen] = useState(false);
  136. const [missingSectionCount, setMissingSectionCount] = useState(0);
  137. const [missingSectionItems, setMissingSectionItems] = useState<MissingStockTakeSectionIssueItem[]>([]);
  138. const [missingSectionIssuesLimit, setMissingSectionIssuesLimit] = useState(50);
  139. const [missingSectionIssuesLoading, setMissingSectionIssuesLoading] = useState(false);
  140. const [createDialogSelectedSections, setCreateDialogSelectedSections] = useState<string[]>([]);
  141. const [createDialogRoundName, setCreateDialogRoundName] = useState("");
  142. const [createDialogPlanStart, setCreateDialogPlanStart] = useState<Dayjs | null>(() => dayjs());
  143. /** 建立盤點對話框:目前選中的樓層分頁(對應 sortedFloorKeys 索引) */
  144. const [createFloorTabIndex, setCreateFloorTabIndex] = useState(0);
  145. const [createDialogSearchQuery, setCreateDialogSearchQuery] = useState("");
  146. const [sectionsLoading, setSectionsLoading] = useState(false);
  147. const [stockTakeSectionRows, setStockTakeSectionRows] = useState<StockTakeSectionInfo[]>([]);
  148. const createStockTakeInFlightRef = useRef(false);
  149. const createSectionGridScrollRef = useRef<HTMLDivElement>(null);
  150. const createSectionCardRefs = useRef<Map<string, HTMLElement>>(new Map());
  151. const marqueeDragRef = useRef<{
  152. pointerId: number;
  153. startClientX: number;
  154. startClientY: number;
  155. currentClientX: number;
  156. currentClientY: number;
  157. baseSelection: Set<string>;
  158. isMarquee: boolean;
  159. pointerDownSection: string | null;
  160. sectionIds: string[];
  161. } | null>(null);
  162. const [marqueeRect, setMarqueeRect] = useState<MarqueeRect | null>(null);
  163. const [marqueePreviewIds, setMarqueePreviewIds] = useState<Set<string>>(() => new Set());
  164. const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState<string[]>([]);
  165. const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState<string[]>([]);
  166. const [storeIdOptions, setStoreIdOptions] = useState<string[]>(["2F", "4F"]);
  167. const statusOptions = ["pending", "stockTaking", "approving", "completed"];
  168. const handleSearch = () => {
  169. onAppliedFiltersChange({
  170. sectionDescription: searchFilters.sectionDescription || "All",
  171. stockTakeSession: searchFilters.stockTakeSession || "",
  172. status: searchFilters.status || "All",
  173. area: searchFilters.area || "",
  174. storeId: searchFilters.storeId || "All",
  175. });
  176. onListPageChange(0);
  177. };
  178. const handleResetSearch = () => {
  179. const resetFilters: PickerCardListFilters = {
  180. sectionDescription: "All",
  181. stockTakeSession: "",
  182. status: "All",
  183. area: "",
  184. storeId: "All",
  185. };
  186. onSearchFiltersChange(resetFilters);
  187. onAppliedFiltersChange(resetFilters);
  188. onListPageChange(0);
  189. };
  190. useEffect(() => {
  191. let cancelled = false;
  192. setLoading(true);
  193. getStockTakeRecordsPaged(page, pageSize, {
  194. sectionDescription: appliedFilters.sectionDescription,
  195. stockTakeSections: appliedFilters.stockTakeSession,
  196. status: appliedFilters.status,
  197. area: appliedFilters.area,
  198. storeId: appliedFilters.storeId,
  199. onlyLatestRound: true,
  200. })
  201. .then((res) => {
  202. if (cancelled) return;
  203. setStockTakeSessions(Array.isArray(res.records) ? res.records : []);
  204. setTotal(res.total || 0);
  205. })
  206. .catch((e) => {
  207. console.error(e);
  208. if (!cancelled) {
  209. setStockTakeSessions([]);
  210. setTotal(0);
  211. }
  212. })
  213. .finally(() => {
  214. if (!cancelled) setLoading(false);
  215. });
  216. return () => {
  217. cancelled = true;
  218. };
  219. }, [page, pageSize, appliedFilters, listRefreshNonce]);
  220. useEffect(() => {
  221. let cancelled = false;
  222. getLatestStockTakeRoundMeta()
  223. .then((meta) => {
  224. if (cancelled) return;
  225. if (meta?.planStartDate) {
  226. setGlobalRoundPlanStartDate(dayjs(meta.planStartDate).format(OUTPUT_DATE_FORMAT));
  227. } else {
  228. setGlobalRoundPlanStartDate(null);
  229. }
  230. })
  231. .catch((e) => {
  232. console.error("Failed to load latest stock take round meta:", e);
  233. if (!cancelled) setGlobalRoundPlanStartDate(null);
  234. });
  235. return () => {
  236. cancelled = true;
  237. };
  238. }, [listRefreshNonce]);
  239. //const startIdx = page * PER_PAGE;
  240. //const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE);
  241. useEffect(() => {
  242. if (openConfirmDialog) {
  243. setCreateDialogSelectedSections([]);
  244. setCreateDialogRoundName("");
  245. setCreateDialogPlanStart(dayjs());
  246. setCreateFloorTabIndex(0);
  247. setCreateDialogSearchQuery("");
  248. marqueeDragRef.current = null;
  249. setMarqueeRect(null);
  250. setMarqueePreviewIds(new Set());
  251. setOpenCreateStockTakeSummaryConfirm(false);
  252. }
  253. }, [openConfirmDialog]);
  254. const loadMissingStockTakeSectionIssues = useCallback(async () => {
  255. setMissingSectionIssuesLoading(true);
  256. try {
  257. const res = await fetchMissingStockTakeSectionIssues(50);
  258. setMissingSectionCount(res.count);
  259. setMissingSectionItems(Array.isArray(res.items) ? res.items : []);
  260. setMissingSectionIssuesLimit(res.limit ?? 50);
  261. } catch (e) {
  262. console.error("Failed to load missing stock take section issues:", e);
  263. setMissingSectionCount(0);
  264. setMissingSectionItems([]);
  265. } finally {
  266. setMissingSectionIssuesLoading(false);
  267. }
  268. }, []);
  269. useEffect(() => {
  270. if (!openConfirmDialog) return;
  271. void loadMissingStockTakeSectionIssues();
  272. }, [openConfirmDialog, loadMissingStockTakeSectionIssues]);
  273. const handleGoWarehouseSettings = useCallback(() => {
  274. setMissingSectionWarnDrawerOpen(false);
  275. setOpenConfirmDialog(false);
  276. router.push("/settings/warehouse");
  277. }, [router]);
  278. const createStockTakeSummarySections = useMemo(() => {
  279. return [...createDialogSelectedSections].sort((a, b) => a.localeCompare(b));
  280. }, [createDialogSelectedSections]);
  281. const handleOpenCreateStockTakeSummaryConfirm = useCallback(() => {
  282. if (createDialogSelectedSections.length === 0) return;
  283. if (createDialogPlanStart == null || !createDialogPlanStart.isValid()) return;
  284. setOpenCreateStockTakeSummaryConfirm(true);
  285. }, [createDialogSelectedSections.length, createDialogPlanStart]);
  286. /** 盤點區域 → 樓層:優先 API(warehouse 帶 storeId),否則用列表卡片上的 storeId */
  287. const sectionsByStore = useMemo(() => {
  288. const sectionToFloor = new Map<string, string>();
  289. stockTakeSectionRows.forEach((row) => {
  290. const sec = row.stockTakeSection?.trim();
  291. if (!sec) return;
  292. const sid = row.storeId?.trim();
  293. if (sid) sectionToFloor.set(sec, sid);
  294. });
  295. stockTakeSessions.forEach((session) => {
  296. const sec = session.stockTakeSession?.trim();
  297. if (!sec) return;
  298. const sid = session.storeId?.trim();
  299. if (sid && !sectionToFloor.has(sec)) sectionToFloor.set(sec, sid);
  300. });
  301. const allSecs = new Set<string>();
  302. stockTakeSectionRows.forEach((r) => {
  303. const s = r.stockTakeSection?.trim();
  304. if (s) allSecs.add(s);
  305. });
  306. stockTakeSectionOptions.forEach((s) => {
  307. const x = s.trim();
  308. if (x) allSecs.add(x);
  309. });
  310. const byFloor = new Map<string, Set<string>>();
  311. allSecs.forEach((sec) => {
  312. const floorKey = sectionToFloor.get(sec)?.trim() || FLOOR_GROUP_UNSET;
  313. if (!byFloor.has(floorKey)) byFloor.set(floorKey, new Set());
  314. byFloor.get(floorKey)!.add(sec);
  315. });
  316. const out = new Map<string, string[]>();
  317. byFloor.forEach((set, key) => {
  318. out.set(key, Array.from(set).sort((a, b) => a.localeCompare(b)));
  319. });
  320. return out;
  321. }, [stockTakeSectionRows, stockTakeSessions, stockTakeSectionOptions]);
  322. const sortedFloorKeys = useMemo(() => {
  323. const keys = Array.from(sectionsByStore.keys());
  324. keys.sort((a, b) => {
  325. if (a === FLOOR_GROUP_UNSET) return 1;
  326. if (b === FLOOR_GROUP_UNSET) return -1;
  327. return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
  328. });
  329. return keys;
  330. }, [sectionsByStore]);
  331. const allSectionsForCreateDialog = useMemo(() => {
  332. const s = new Set<string>();
  333. sectionsByStore.forEach((arr) => arr.forEach((x) => s.add(x)));
  334. return Array.from(s).sort((a, b) => a.localeCompare(b));
  335. }, [sectionsByStore]);
  336. useEffect(() => {
  337. if (createFloorTabIndex >= sortedFloorKeys.length && sortedFloorKeys.length > 0) {
  338. setCreateFloorTabIndex(0);
  339. }
  340. }, [sortedFloorKeys.length, createFloorTabIndex]);
  341. const floorGroupLabel = useCallback(
  342. (storeKey: string) => (storeKey === FLOOR_GROUP_UNSET ? t("Floor unassigned") : storeKey),
  343. [t],
  344. );
  345. const toggleFloorSelectAll = useCallback(
  346. (storeKey: string) => {
  347. const list = sectionsByStore.get(storeKey) ?? [];
  348. setCreateDialogSelectedSections((prev) => {
  349. const allIn = list.length > 0 && list.every((s) => prev.includes(s));
  350. if (allIn) {
  351. return prev.filter((s) => !list.includes(s));
  352. }
  353. const next = new Set(prev);
  354. list.forEach((s) => next.add(s));
  355. return Array.from(next).sort((a, b) => a.localeCompare(b));
  356. });
  357. },
  358. [sectionsByStore],
  359. );
  360. /** 與卡片標題括號內相同:描述 / 區域 / 樓層 */
  361. const getCreateDialogSectionMeta = useCallback(
  362. (section: string) => {
  363. const row = stockTakeSectionRows.find((r) => r.stockTakeSection?.trim() === section);
  364. const fromList = stockTakeSessions.find((s) => s.stockTakeSession?.trim() === section);
  365. const desc = row?.stockTakeSectionDescription?.trim() || fromList?.stockTakeSectionDescription?.trim() || "";
  366. const area = row?.warehouseArea?.trim() || fromList?.warehouseArea?.trim() || "";
  367. // const store = row?.storeId?.trim() || fromList?.storeId?.trim() || "";
  368. const parts = [desc, area].filter((v) => Boolean(v && v.trim()));
  369. return parts.length ? parts.join(" / ") : "";
  370. },
  371. [stockTakeSectionRows, stockTakeSessions],
  372. );
  373. const activeCreateFloorKey = useMemo(() => {
  374. if (sortedFloorKeys.length === 0) return null;
  375. return sortedFloorKeys[Math.min(createFloorTabIndex, sortedFloorKeys.length - 1)];
  376. }, [sortedFloorKeys, createFloorTabIndex]);
  377. const activeCreateFloorSections = useMemo(() => {
  378. if (activeCreateFloorKey == null) return [];
  379. return sectionsByStore.get(activeCreateFloorKey) ?? [];
  380. }, [activeCreateFloorKey, sectionsByStore]);
  381. const createDialogFilteredSections = useMemo(() => {
  382. const q = createDialogSearchQuery.trim().toLowerCase();
  383. if (!q) return activeCreateFloorSections;
  384. return activeCreateFloorSections.filter((section) => {
  385. const meta = getCreateDialogSectionMeta(section);
  386. return section.toLowerCase().includes(q) || meta.toLowerCase().includes(q);
  387. });
  388. }, [activeCreateFloorSections, createDialogSearchQuery, getCreateDialogSectionMeta]);
  389. const getSectionIdsInMarquee = useCallback(
  390. (startX: number, startY: number, endX: number, endY: number, sectionIds: string[]) => {
  391. const marquee = clientMarqueeRect(startX, startY, endX, endY);
  392. const hit = new Set<string>();
  393. sectionIds.forEach((section) => {
  394. const el = createSectionCardRefs.current.get(section);
  395. if (!el) return;
  396. if (rectsIntersect(el.getBoundingClientRect(), marquee)) {
  397. hit.add(section);
  398. }
  399. });
  400. return hit;
  401. },
  402. [],
  403. );
  404. const syncMarqueeDragUi = useCallback(() => {
  405. const drag = marqueeDragRef.current;
  406. const scrollEl = createSectionGridScrollRef.current;
  407. if (!drag || !scrollEl) return;
  408. const dist = Math.hypot(
  409. drag.currentClientX - drag.startClientX,
  410. drag.currentClientY - drag.startClientY,
  411. );
  412. if (!drag.isMarquee && dist < MARQUEE_DRAG_THRESHOLD_PX) {
  413. setMarqueeRect(null);
  414. setMarqueePreviewIds(new Set());
  415. return;
  416. }
  417. drag.isMarquee = true;
  418. const scrollRect = scrollEl.getBoundingClientRect();
  419. setMarqueeRect({
  420. left: Math.min(drag.startClientX, drag.currentClientX) - scrollRect.left + scrollEl.scrollLeft,
  421. top: Math.min(drag.startClientY, drag.currentClientY) - scrollRect.top + scrollEl.scrollTop,
  422. width: Math.abs(drag.currentClientX - drag.startClientX),
  423. height: Math.abs(drag.currentClientY - drag.startClientY),
  424. });
  425. setMarqueePreviewIds(
  426. getSectionIdsInMarquee(
  427. drag.startClientX,
  428. drag.startClientY,
  429. drag.currentClientX,
  430. drag.currentClientY,
  431. drag.sectionIds,
  432. ),
  433. );
  434. }, [getSectionIdsInMarquee]);
  435. const finishMarqueeDrag = useCallback(() => {
  436. const drag = marqueeDragRef.current;
  437. if (drag?.isMarquee) {
  438. const hit = getSectionIdsInMarquee(
  439. drag.startClientX,
  440. drag.startClientY,
  441. drag.currentClientX,
  442. drag.currentClientY,
  443. drag.sectionIds,
  444. );
  445. const merged = new Set(drag.baseSelection);
  446. hit.forEach((id) => merged.add(id));
  447. setCreateDialogSelectedSections(
  448. Array.from(merged).sort((a, b) => a.localeCompare(b)),
  449. );
  450. } else if (drag?.pointerDownSection) {
  451. const sec = drag.pointerDownSection;
  452. setCreateDialogSelectedSections((prev) => {
  453. if (prev.includes(sec)) return prev.filter((s) => s !== sec);
  454. return [...prev, sec].sort((a, b) => a.localeCompare(b));
  455. });
  456. }
  457. marqueeDragRef.current = null;
  458. setMarqueeRect(null);
  459. setMarqueePreviewIds(new Set());
  460. }, [getSectionIdsInMarquee]);
  461. const handleCreateSectionGridPointerDown = useCallback(
  462. (e: React.PointerEvent<HTMLDivElement>) => {
  463. if (e.button !== 0) return;
  464. const scrollEl = createSectionGridScrollRef.current;
  465. if (!scrollEl || createDialogFilteredSections.length === 0) return;
  466. const cardEl = (e.target as HTMLElement).closest("[data-section-card]");
  467. const pointerDownSection = cardEl?.getAttribute("data-section") ?? null;
  468. e.preventDefault();
  469. marqueeDragRef.current = {
  470. pointerId: e.pointerId,
  471. startClientX: e.clientX,
  472. startClientY: e.clientY,
  473. currentClientX: e.clientX,
  474. currentClientY: e.clientY,
  475. baseSelection: new Set(createDialogSelectedSections),
  476. isMarquee: false,
  477. pointerDownSection,
  478. sectionIds: createDialogFilteredSections,
  479. };
  480. const onPointerMove = (ev: PointerEvent) => {
  481. const drag = marqueeDragRef.current;
  482. if (!drag || ev.pointerId !== drag.pointerId) return;
  483. drag.currentClientX = ev.clientX;
  484. drag.currentClientY = ev.clientY;
  485. syncMarqueeDragUi();
  486. };
  487. const onPointerEnd = (ev: PointerEvent) => {
  488. const drag = marqueeDragRef.current;
  489. if (!drag || ev.pointerId !== drag.pointerId) return;
  490. drag.currentClientX = ev.clientX;
  491. drag.currentClientY = ev.clientY;
  492. syncMarqueeDragUi();
  493. finishMarqueeDrag();
  494. window.removeEventListener("pointermove", onPointerMove);
  495. window.removeEventListener("pointerup", onPointerEnd);
  496. window.removeEventListener("pointercancel", onPointerEnd);
  497. try {
  498. scrollEl.releasePointerCapture(ev.pointerId);
  499. } catch {
  500. /* ignore */
  501. }
  502. };
  503. try {
  504. scrollEl.setPointerCapture(e.pointerId);
  505. } catch {
  506. /* ignore */
  507. }
  508. window.addEventListener("pointermove", onPointerMove);
  509. window.addEventListener("pointerup", onPointerEnd);
  510. window.addEventListener("pointercancel", onPointerEnd);
  511. },
  512. [createDialogFilteredSections, createDialogSelectedSections, finishMarqueeDrag, syncMarqueeDragUi],
  513. );
  514. const handleCreateStockTake = useCallback(async () => {
  515. if (createStockTakeInFlightRef.current) return;
  516. if (createDialogSelectedSections.length === 0) return;
  517. createStockTakeInFlightRef.current = true;
  518. setOpenCreateStockTakeSummaryConfirm(false);
  519. setOpenConfirmDialog(false);
  520. setCreating(true);
  521. try {
  522. const planStart =
  523. createDialogPlanStart != null
  524. ? dayjsToDateTimeString(createDialogPlanStart.startOf("day"))
  525. : dayjsToDateTimeString(dayjs().startOf("day"));
  526. const result = await createStockTakeForSections(
  527. createDialogSelectedSections,
  528. createDialogRoundName,
  529. planStart,
  530. );
  531. const createdCount = Object.values(result).filter((msg) => msg.startsWith("Created:")).length;
  532. const skippedCount = Object.values(result).filter((msg) => msg.startsWith("Skipped:")).length;
  533. const errorCount = Object.values(result).filter((msg) => msg.startsWith("Error:")).length;
  534. let message = `${t("Created")}: ${createdCount}, ${t("Skipped")}: ${skippedCount}`;
  535. if (errorCount > 0) {
  536. message += `, ${t("Errors")}: ${errorCount}`;
  537. }
  538. console.log(message);
  539. onListPageChange(0);
  540. setListRefreshNonce((n) => n + 1);
  541. setCreateDialogRoundName("");
  542. setCreateDialogPlanStart(dayjs());
  543. } catch (e) {
  544. console.error(e);
  545. } finally {
  546. setCreating(false);
  547. createStockTakeInFlightRef.current = false;
  548. }
  549. }, [createDialogPlanStart, createDialogRoundName, createDialogSelectedSections, onListPageChange, t]);
  550. useEffect(() => {
  551. setSectionsLoading(true);
  552. fetchStockTakeSections()
  553. .then((sections) => {
  554. setStockTakeSectionRows(Array.isArray(sections) ? sections : []);
  555. const descSet = new Set<string>();
  556. const sectionSet = new Set<string>();
  557. const storeIdSet = new Set<string>(["2F", "4F"]);
  558. sections.forEach((s) => {
  559. const section = s.stockTakeSection?.trim();
  560. if (section) sectionSet.add(section);
  561. const desc = s.stockTakeSectionDescription?.trim();
  562. if (desc) descSet.add(desc);
  563. const storeId = s.storeId?.trim();
  564. if (storeId) storeIdSet.add(storeId);
  565. });
  566. setStockTakeSectionOptions(Array.from(sectionSet).sort((a, b) => a.localeCompare(b)));
  567. setSectionDescriptionOptions(Array.from(descSet).sort((a, b) => a.localeCompare(b)));
  568. setStoreIdOptions(Array.from(storeIdSet).sort((a, b) => a.localeCompare(b)));
  569. })
  570. .catch((e) => {
  571. console.error("Failed to load section descriptions for filter:", e);
  572. })
  573. .finally(() => {
  574. setSectionsLoading(false);
  575. });
  576. }, []);
  577. useEffect(() => {
  578. setStockTakeSectionOptions((prev) => {
  579. const sectionSet = new Set<string>(prev);
  580. stockTakeSessions.forEach((item) => {
  581. const section = item.stockTakeSession?.trim();
  582. if (section) sectionSet.add(section);
  583. });
  584. return Array.from(sectionSet).sort((a, b) => a.localeCompare(b));
  585. });
  586. }, [stockTakeSessions]);
  587. useEffect(() => {
  588. setStoreIdOptions((prev) => {
  589. const storeIdSet = new Set<string>([...prev, "2F", "4F"]);
  590. stockTakeSessions.forEach((item) => {
  591. const storeId = item.storeId?.trim();
  592. if (storeId) storeIdSet.add(storeId);
  593. });
  594. return Array.from(storeIdSet).sort((a, b) => a.localeCompare(b));
  595. });
  596. }, [stockTakeSessions]);
  597. const getStatusColor = (status: string) => {
  598. const statusLower = status.toLowerCase();
  599. if (statusLower === "completed") return "success";
  600. if (statusLower === "in_progress" || statusLower === "processing") return "primary";
  601. if (statusLower === "approving") return "info";
  602. if (statusLower === "stockTaking") return "primary";
  603. if (statusLower === "no_cycle") return "default";
  604. return "warning";
  605. };
  606. const TimeDisplay: React.FC<{ startTime: string | null; endTime: string | null }> = ({ startTime, endTime }) => {
  607. const [currentTime, setCurrentTime] = useState(dayjs());
  608. useEffect(() => {
  609. if (!endTime && startTime) {
  610. const interval = setInterval(() => {
  611. setCurrentTime(dayjs());
  612. }, 1000); // 每秒更新一次
  613. return () => clearInterval(interval);
  614. }
  615. }, [startTime, endTime]);
  616. if (endTime && startTime) {
  617. // 当有结束时间时,计算从开始到结束的持续时间
  618. const start = dayjs(startTime);
  619. const end = dayjs(endTime);
  620. const duration = dayjs.duration(end.diff(start));
  621. const hours = Math.floor(duration.asHours());
  622. const minutes = duration.minutes();
  623. const seconds = duration.seconds();
  624. return (
  625. <>
  626. {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}
  627. </>
  628. );
  629. } else if (startTime) {
  630. // 当没有结束时间时,显示实时计时器
  631. const start = dayjs(startTime);
  632. const duration = dayjs.duration(currentTime.diff(start));
  633. const hours = Math.floor(duration.asHours());
  634. const minutes = duration.minutes();
  635. const seconds = duration.seconds();
  636. return (
  637. <>
  638. {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}
  639. </>
  640. );
  641. } else {
  642. return <>-</>;
  643. }
  644. };
  645. const startTimeDisplay = (startTime: string | null) => {
  646. if (startTime) {
  647. const start = dayjs(startTime);
  648. return start.format("HH:mm");
  649. } else {
  650. return "-";
  651. }
  652. };
  653. const endTimeDisplay = (endTime: string | null) => {
  654. if (endTime) {
  655. const end = dayjs(endTime);
  656. return end.format("HH:mm");
  657. } else {
  658. return "-";
  659. }
  660. };
  661. const getCompletionRate = (session: AllPickedStockTakeListReponse): number => {
  662. if (session.totalInventoryLotNumber === 0) return 0;
  663. return Math.round((session.currentStockTakeItemNumber / session.totalInventoryLotNumber) * 100);
  664. };
  665. const planStartDate = globalRoundPlanStartDate;
  666. return (
  667. <Box>
  668. <Card elevation={0} sx={{ mb: 2 }}>
  669. <CardContent>
  670. <Typography variant="overline" sx={{ display: "block", mb: 1 }}>
  671. {t("Search Criteria")}
  672. </Typography>
  673. <Grid container spacing={2}>
  674. <Grid item xs={12} md={4}>
  675. <FormControl fullWidth>
  676. <InputLabel>{t("Stock Take Section")}</InputLabel>
  677. <Select
  678. size="small"
  679. value={searchFilters.sectionDescription}
  680. label={t("Stock Take Section")}
  681. onChange={(e) =>
  682. onSearchFiltersChange({
  683. ...searchFilters,
  684. sectionDescription: e.target.value,
  685. })
  686. }
  687. >
  688. <MenuItem value="All">{t("All")}</MenuItem>
  689. {sectionDescriptionOptions.map((desc) => (
  690. <MenuItem key={desc} value={desc}>
  691. {desc}
  692. </MenuItem>
  693. ))}
  694. </Select>
  695. </FormControl>
  696. </Grid>
  697. <Grid item xs={12} md={4}>
  698. <Autocomplete
  699. freeSolo
  700. options={stockTakeSectionOptions}
  701. value={searchFilters.stockTakeSession}
  702. onChange={(_, newValue) =>
  703. onSearchFiltersChange({
  704. ...searchFilters,
  705. stockTakeSession: newValue || "",
  706. })
  707. }
  708. onInputChange={(_, newValue) =>
  709. onSearchFiltersChange({
  710. ...searchFilters,
  711. stockTakeSession: newValue,
  712. })
  713. }
  714. renderInput={(params) => (
  715. <TextField
  716. {...params}
  717. fullWidth
  718. label={t("Stock Take Section (can use , to search multiple sections)")}
  719. />
  720. )}
  721. />
  722. </Grid>
  723. <Grid item xs={12} md={4}>
  724. <FormControl fullWidth>
  725. <InputLabel>{t("Status")}</InputLabel>
  726. <Select
  727. value={searchFilters.status}
  728. label={t("Status")}
  729. onChange={(e) =>
  730. onSearchFiltersChange({
  731. ...searchFilters,
  732. status: e.target.value,
  733. })
  734. }
  735. >
  736. <MenuItem value="All">{t("All")}</MenuItem>
  737. {statusOptions.map((status) => (
  738. <MenuItem key={status} value={status}>
  739. {t(status)}
  740. </MenuItem>
  741. ))}
  742. </Select>
  743. </FormControl>
  744. </Grid>
  745. <Grid item xs={12} md={6}>
  746. <TextField
  747. fullWidth
  748. label={t("Area")}
  749. value={searchFilters.area}
  750. onChange={(e) =>
  751. onSearchFiltersChange({
  752. ...searchFilters,
  753. area: e.target.value,
  754. })
  755. }
  756. />
  757. </Grid>
  758. <Grid item xs={12} md={6}>
  759. <FormControl fullWidth>
  760. <InputLabel>{t("Store ID")}</InputLabel>
  761. <Select
  762. value={searchFilters.storeId}
  763. label={t("Store ID")}
  764. onChange={(e) =>
  765. onSearchFiltersChange({
  766. ...searchFilters,
  767. storeId: e.target.value,
  768. })
  769. }
  770. >
  771. <MenuItem value="All">{t("All")}</MenuItem>
  772. {storeIdOptions.map((storeId) => (
  773. <MenuItem key={storeId} value={storeId}>
  774. {storeId}
  775. </MenuItem>
  776. ))}
  777. </Select>
  778. </FormControl>
  779. </Grid>
  780. </Grid>
  781. <CardActions sx={{ px: 0, pt: 2, gap: 1 }}>
  782. <Button variant="outlined" onClick={handleResetSearch}>
  783. {t("Reset")}
  784. </Button>
  785. <Button variant="contained" onClick={handleSearch}>
  786. {t("Search")}
  787. </Button>
  788. </CardActions>
  789. </CardContent>
  790. </Card>
  791. <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
  792. <Typography variant="body2" color="text.secondary">
  793. {t("Total Sections")}: {total}
  794. </Typography>
  795. <Typography variant="body2" color="text.secondary">
  796. {t("Start Stock Take Date")}: {planStartDate || "-"}
  797. </Typography>
  798. <Button
  799. variant="contained"
  800. color="primary"
  801. onClick={() => setOpenConfirmDialog(true)}
  802. disabled={creating || !canManageStockTake}
  803. >
  804. {creating ? <CircularProgress size={20} /> : t("Create Stock Take (Select Sections)")}
  805. </Button>
  806. </Box>
  807. {loading ? (
  808. <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
  809. <CircularProgress />
  810. </Box>
  811. ) : (
  812. <Grid container spacing={2}>
  813. {stockTakeSessions.map((session: AllPickedStockTakeListReponse) => {
  814. const statusColor = getStatusColor(session.status || "");
  815. const lastStockTakeDate = session.lastStockTakeDate
  816. ? dayjs(session.lastStockTakeDate).format(OUTPUT_DATE_FORMAT)
  817. : "-";
  818. const completionRate = getCompletionRate(session);
  819. const sectionMeta = [
  820. session.stockTakeSectionDescription,
  821. session.warehouseArea,
  822. session.storeId,
  823. ].filter((v): v is string => Boolean(v && v.trim())).join(" / ");
  824. return (
  825. <Grid key={session.id} item xs={12} sm={6} md={4}>
  826. <Card
  827. sx={{
  828. minHeight: 200,
  829. display: "flex",
  830. flexDirection: "column",
  831. border: "1px solid",
  832. borderColor: statusColor === "success" ? "success.main" : "primary.main",
  833. }}
  834. >
  835. <CardContent sx={{ pb: 1, flexGrow: 1 }}>
  836. <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
  837. <Typography variant="subtitle1" fontWeight={600}>
  838. {t("Section")}: {session.stockTakeSession}
  839. {sectionMeta ? ` (${sectionMeta})` : null}
  840. </Typography>
  841. </Stack>
  842. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
  843. {t("Last Stock Take Date")}: {lastStockTakeDate || "-"}
  844. </Typography>
  845. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Stock Taker")}: {session.stockTakerName}</Typography>
  846. <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
  847. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("start time")}: {startTimeDisplay(session.startTime) || "-"}</Typography>
  848. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("end time")}: {endTimeDisplay(session.endTime) || "-"}</Typography>
  849. </Stack>
  850. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
  851. {t("Control Time")}: <TimeDisplay startTime={session.startTime} endTime={session.endTime} />
  852. </Typography>
  853. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Total Item Kind Number")}: {session.totalItemNumber}</Typography>
  854. </CardContent>
  855. <CardActions sx={{ pt: 0.5 ,justifyContent: "space-between"}}>
  856. <Stack direction="row" spacing={1}>
  857. <Button
  858. variant="contained"
  859. size="small"
  860. onClick={() => onCardClick(session)}
  861. >
  862. {t("View Details")}
  863. </Button>
  864. <Button
  865. variant="contained"
  866. size="small"
  867. onClick={() => onReStockTakeClick(session)}
  868. disabled={!session.reStockTakeTrueFalse}
  869. >
  870. {t("View ReStockTake")}
  871. </Button>
  872. </Stack>
  873. <Chip size="small" label={t(session.status || "")} color={statusColor as any} />
  874. </CardActions>
  875. </Card>
  876. </Grid>
  877. );
  878. })}
  879. </Grid>
  880. )}
  881. {total > 0 && (
  882. <TablePagination
  883. component="div"
  884. count={total}
  885. page={page}
  886. rowsPerPage={pageSize}
  887. onPageChange={(e, newPage) => {
  888. onListPageChange(newPage);
  889. }}
  890. rowsPerPageOptions={[pageSize]} // 如果暂时不让用户改 pageSize,就写死
  891. />
  892. )}
  893. {/* Create Stock Take 確認 Dialog */}
  894. <Dialog
  895. open={openConfirmDialog}
  896. onClose={() => {
  897. setOpenConfirmDialog(false);
  898. setCreateDialogRoundName("");
  899. setCreateDialogPlanStart(dayjs());
  900. }}
  901. maxWidth={false}
  902. fullWidth
  903. PaperProps={{
  904. sx: {
  905. width: "100%",
  906. maxWidth: { xs: "100%", sm: 960, md: 1120 },
  907. maxHeight: "90vh",
  908. display: "flex",
  909. flexDirection: "column",
  910. },
  911. }}
  912. >
  913. <DialogTitle
  914. sx={{
  915. pb: 1,
  916. flexShrink: 0,
  917. display: "flex",
  918. alignItems: "center",
  919. justifyContent: "space-between",
  920. gap: 1,
  921. }}
  922. >
  923. <Typography component="span" variant="h6" sx={{ fontSize: "1.25rem", fontWeight: 500 }}>
  924. {t("Create Stock Take (Select Sections)")}
  925. </Typography>
  926. <Tooltip
  927. title={
  928. missingSectionCount > 0
  929. ? t("Warehouse missing stock take section tooltip has", { count: missingSectionCount })
  930. : t("Warehouse missing stock take section tooltip none")
  931. }
  932. >
  933. <Box component="span" sx={{ flexShrink: 0, display: "inline-flex" }}>
  934. <IconButton
  935. size="small"
  936. aria-label={t("Warehouse missing stock take section warn title")}
  937. onClick={() => setMissingSectionWarnDrawerOpen(true)}
  938. sx={{
  939. color: missingSectionCount > 0 ? "warning.main" : "text.secondary",
  940. }}
  941. >
  942. <Badge
  943. color="error"
  944. badgeContent={missingSectionCount > 99 ? "99+" : missingSectionCount}
  945. invisible={missingSectionCount === 0}
  946. >
  947. <NotificationsNoneOutlinedIcon />
  948. </Badge>
  949. </IconButton>
  950. </Box>
  951. </Tooltip>
  952. </DialogTitle>
  953. <DialogContent
  954. dividers
  955. sx={{
  956. p: 0,
  957. flex: 1,
  958. minHeight: 0,
  959. overflow: "hidden",
  960. display: "flex",
  961. flexDirection: "column",
  962. }}
  963. >
  964. <Box
  965. sx={{
  966. display: "flex",
  967. flex: 1,
  968. minHeight: 0,
  969. flexDirection: { xs: "column", md: "row" },
  970. height: "100%",
  971. }}
  972. >
  973. {/* 左側:盤點設定與樓層 */}
  974. <Box
  975. sx={{
  976. width: { xs: "100%", md: 280 },
  977. flexShrink: 0,
  978. minHeight: 0,
  979. maxHeight: { xs: "38vh", md: "100%" },
  980. overflowY: "auto",
  981. borderRight: { md: 1 },
  982. borderBottom: { xs: 1, md: 0 },
  983. borderColor: "divider",
  984. p: 2.5,
  985. display: "flex",
  986. flexDirection: "column",
  987. gap: 2,
  988. bgcolor: "grey.50",
  989. }}
  990. >
  991. <Box>
  992. <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
  993. {t("Stock take round name")}
  994. </Typography>
  995. <TextField
  996. fullWidth
  997. size="small"
  998. hiddenLabel
  999. // placeholder={t("Stock take round name placeholder")}
  1000. value={createDialogRoundName}
  1001. onChange={(e) => setCreateDialogRoundName(e.target.value)}
  1002. inputProps={{ maxLength: 255, "aria-label": t("Stock take round name") }}
  1003. sx={createDialogCompactInputSx}
  1004. />
  1005. </Box>
  1006. <Box>
  1007. <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
  1008. {t("Creation date")}
  1009. </Typography>
  1010. <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
  1011. <DatePicker
  1012. value={createDialogPlanStart}
  1013. onChange={(newValue) => setCreateDialogPlanStart(newValue)}
  1014. format={OUTPUT_DATE_FORMAT}
  1015. slotProps={{
  1016. textField: {
  1017. fullWidth: true,
  1018. size: "small",
  1019. hiddenLabel: true,
  1020. inputProps: { "aria-label": t("Creation date") },
  1021. sx: createDialogCompactInputSx,
  1022. },
  1023. }}
  1024. />
  1025. </LocalizationProvider>
  1026. </Box>
  1027. {sortedFloorKeys.length === 0 && !sectionsLoading ? (
  1028. <Typography variant="body2" color="text.secondary">
  1029. {t("No stock take sections from warehouse")}
  1030. </Typography>
  1031. ) : (
  1032. <Stack
  1033. spacing={1}
  1034. sx={{
  1035. flexShrink: 0,
  1036. ...(sortedFloorKeys.length > FLOOR_LIST_SCROLL_THRESHOLD
  1037. ? { maxHeight: 300, overflowY: "auto", pr: 0.5 }
  1038. : {}),
  1039. }}
  1040. >
  1041. {sortedFloorKeys.map((floorKey, idx) => {
  1042. const floorSections = sectionsByStore.get(floorKey) ?? [];
  1043. const selected = idx === createFloorTabIndex;
  1044. return (
  1045. <Button
  1046. key={floorKey}
  1047. fullWidth
  1048. variant="outlined"
  1049. onClick={() => {
  1050. setCreateFloorTabIndex(idx);
  1051. setCreateDialogSearchQuery("");
  1052. }}
  1053. sx={{
  1054. justifyContent: "space-between",
  1055. textTransform: "none",
  1056. py: 1.25,
  1057. px: 1.5,
  1058. borderRadius: 2,
  1059. borderWidth: selected ? 2 : 1,
  1060. borderColor: selected ? "primary.main" : "divider",
  1061. color: selected ? "primary.main" : "text.primary",
  1062. bgcolor: "background.paper",
  1063. "&:hover": {
  1064. borderColor: selected ? "primary.main" : "divider",
  1065. bgcolor: "background.paper",
  1066. },
  1067. }}
  1068. >
  1069. <Typography variant="body2" fontWeight={selected ? 700 : 500}>
  1070. {floorGroupLabel(floorKey)}
  1071. </Typography>
  1072. <Chip
  1073. size="small"
  1074. label={floorSections.length}
  1075. sx={{
  1076. height: 22,
  1077. minWidth: 28,
  1078. bgcolor: selected ? "primary.main" : "grey.200",
  1079. color: selected ? "primary.contrastText" : "text.secondary",
  1080. }}
  1081. />
  1082. </Button>
  1083. );
  1084. })}
  1085. </Stack>
  1086. )}
  1087. <Stack spacing={1}>
  1088. <Button
  1089. fullWidth
  1090. variant="outlined"
  1091. onClick={() => setCreateDialogSelectedSections([...allSectionsForCreateDialog])}
  1092. disabled={allSectionsForCreateDialog.length === 0}
  1093. sx={{ bgcolor: "background.paper" }}
  1094. >
  1095. {t("Select all sections all floors")}
  1096. </Button>
  1097. <Button
  1098. fullWidth
  1099. variant="outlined"
  1100. onClick={() => setCreateDialogSelectedSections([])}
  1101. sx={{ bgcolor: "background.paper" }}
  1102. >
  1103. {t("Clear selection all floors")}
  1104. </Button>
  1105. </Stack>
  1106. <Typography variant="body2" color="text.secondary">
  1107. {t("Total selected sections label")}{" "}
  1108. <Typography component="span" variant="body2" color="primary.main" fontWeight={700}>
  1109. {createDialogSelectedSections.length}
  1110. </Typography>{" "}
  1111. {t("sections unit")}
  1112. </Typography>
  1113. </Box>
  1114. {/* 右側:區域卡片網格 */}
  1115. <Box
  1116. sx={{
  1117. flex: 1,
  1118. minHeight: 0,
  1119. display: "flex",
  1120. flexDirection: "column",
  1121. minWidth: 0,
  1122. p: 2.5,
  1123. bgcolor: "background.paper",
  1124. overflow: "hidden",
  1125. }}
  1126. >
  1127. {activeCreateFloorKey != null ? (
  1128. <>
  1129. <Stack
  1130. direction="row"
  1131. alignItems="center"
  1132. justifyContent="space-between"
  1133. flexWrap="wrap"
  1134. gap={1}
  1135. sx={{ mb: 1 }}
  1136. >
  1137. <Typography variant="subtitle1" fontWeight={700}>
  1138. {t("Floor area selection header", {
  1139. floor: floorGroupLabel(activeCreateFloorKey),
  1140. count: activeCreateFloorSections.length,
  1141. })}
  1142. </Typography>
  1143. <Button
  1144. size="small"
  1145. variant="outlined"
  1146. onClick={() => toggleFloorSelectAll(activeCreateFloorKey)}
  1147. disabled={activeCreateFloorSections.length === 0}
  1148. >
  1149. {activeCreateFloorSections.length > 0 &&
  1150. activeCreateFloorSections.every((s) => createDialogSelectedSections.includes(s))
  1151. ? t("Deselect all on this floor", {
  1152. floor: floorGroupLabel(activeCreateFloorKey),
  1153. })
  1154. : t("Select all on this floor", {
  1155. floor: floorGroupLabel(activeCreateFloorKey),
  1156. })}
  1157. </Button>
  1158. </Stack>
  1159. <TextField
  1160. fullWidth
  1161. size="small"
  1162. hiddenLabel
  1163. placeholder={t("Search section code or name")}
  1164. value={createDialogSearchQuery}
  1165. onChange={(e) => setCreateDialogSearchQuery(e.target.value)}
  1166. inputProps={{ "aria-label": t("Search section code or name") }}
  1167. InputProps={{
  1168. startAdornment: (
  1169. <InputAdornment position="start">
  1170. <SearchIcon fontSize="small" color="action" />
  1171. </InputAdornment>
  1172. ),
  1173. }}
  1174. sx={createDialogCompactInputSx}
  1175. />
  1176. <Box
  1177. ref={createSectionGridScrollRef}
  1178. onPointerDown={handleCreateSectionGridPointerDown}
  1179. sx={{
  1180. position: "relative",
  1181. flex: 1,
  1182. minHeight: 0,
  1183. mt: 2,
  1184. overflowY: "auto",
  1185. pr: 0.5,
  1186. userSelect: marqueeRect ? "none" : "auto",
  1187. touchAction: "pan-y",
  1188. cursor: "default",
  1189. }}
  1190. >
  1191. {sectionsLoading ? (
  1192. <Box
  1193. sx={{
  1194. display: "flex",
  1195. alignItems: "center",
  1196. justifyContent: "center",
  1197. minHeight: 240,
  1198. }}
  1199. >
  1200. <CircularProgress />
  1201. </Box>
  1202. ) : createDialogFilteredSections.length === 0 ? (
  1203. <Typography variant="body2" color="text.secondary" sx={{ py: 4, textAlign: "center" }}>
  1204. {createDialogSearchQuery.trim()
  1205. ? t("No sections match search")
  1206. : t("No stock take sections from warehouse")}
  1207. </Typography>
  1208. ) : (
  1209. <>
  1210. {marqueeRect ? (
  1211. <Box
  1212. aria-hidden
  1213. sx={{
  1214. position: "absolute",
  1215. left: marqueeRect.left,
  1216. top: marqueeRect.top,
  1217. width: marqueeRect.width,
  1218. height: marqueeRect.height,
  1219. border: "1px solid",
  1220. borderColor: "primary.main",
  1221. bgcolor: "primary.main",
  1222. opacity: 0.28,
  1223. pointerEvents: "none",
  1224. zIndex: 3,
  1225. boxSizing: "border-box",
  1226. }}
  1227. />
  1228. ) : null}
  1229. <Box
  1230. sx={{
  1231. display: "grid",
  1232. gridTemplateColumns: {
  1233. xs: "repeat(2, 1fr)",
  1234. sm: "repeat(3, 1fr)",
  1235. md: "repeat(4, 1fr)",
  1236. },
  1237. gap: 1.5,
  1238. alignItems: "start",
  1239. //minHeight: "100%",
  1240. }}
  1241. >
  1242. {createDialogFilteredSections.map((section) => {
  1243. const meta = getCreateDialogSectionMeta(section);
  1244. const checked = createDialogSelectedSections.includes(section);
  1245. const previewSelected = marqueePreviewIds.has(section);
  1246. const visuallySelected = checked || previewSelected;
  1247. return (
  1248. <Card
  1249. key={section}
  1250. data-section-card
  1251. data-section={section}
  1252. variant="outlined"
  1253. ref={(el) => {
  1254. if (el) {
  1255. createSectionCardRefs.current.set(section, el);
  1256. } else {
  1257. createSectionCardRefs.current.delete(section);
  1258. }
  1259. }}
  1260. sx={{
  1261. cursor: "default",
  1262. borderWidth: visuallySelected ? 2 : 1,
  1263. borderColor: visuallySelected ? "primary.main" : "divider",
  1264. bgcolor: visuallySelected
  1265. ? previewSelected && !checked
  1266. ? "action.hover"
  1267. : "action.selected"
  1268. : "background.paper",
  1269. transition: marqueeRect ? "none" : "border-color 0.15s, background-color 0.15s",
  1270. "&:hover": {
  1271. borderColor: visuallySelected ? "primary.main" : "grey.400",
  1272. },
  1273. }}
  1274. >
  1275. <CardContent sx={{ p: 1.5, "&:last-child": { pb: 1.5 } }}>
  1276. <Typography variant="body2" fontWeight={700} noWrap title={section}>
  1277. {section}
  1278. </Typography>
  1279. {meta ? (
  1280. <Typography
  1281. variant="caption"
  1282. color="text.secondary"
  1283. sx={{
  1284. display: "-webkit-box",
  1285. WebkitLineClamp: 2,
  1286. WebkitBoxOrient: "vertical",
  1287. overflow: "hidden",
  1288. lineHeight: 1.35,
  1289. mt: 0.25,
  1290. }}
  1291. >
  1292. {meta}
  1293. </Typography>
  1294. ) : null}
  1295. </CardContent>
  1296. </Card>
  1297. );
  1298. })}
  1299. </Box>
  1300. </>
  1301. )}
  1302. </Box>
  1303. </>
  1304. ) : (
  1305. <Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", flex: 1 }}>
  1306. {sectionsLoading ? (
  1307. <CircularProgress />
  1308. ) : (
  1309. <Typography variant="body2" color="text.secondary">
  1310. {t("No stock take sections from warehouse")}
  1311. </Typography>
  1312. )}
  1313. </Box>
  1314. )}
  1315. </Box>
  1316. </Box>
  1317. </DialogContent>
  1318. <DialogActions sx={{ px: 3, py: 2, flexShrink: 0 }}>
  1319. <Button
  1320. onClick={() => {
  1321. setOpenConfirmDialog(false);
  1322. setCreateDialogRoundName("");
  1323. setCreateDialogPlanStart(dayjs());
  1324. }}
  1325. >
  1326. {t("Cancel")}
  1327. </Button>
  1328. <Button
  1329. variant="contained"
  1330. color="primary"
  1331. onClick={handleOpenCreateStockTakeSummaryConfirm}
  1332. disabled={
  1333. creating ||
  1334. createDialogSelectedSections.length === 0 ||
  1335. createDialogPlanStart == null ||
  1336. !createDialogPlanStart.isValid()
  1337. }
  1338. >
  1339. {t("Confirm")}
  1340. </Button>
  1341. </DialogActions>
  1342. </Dialog>
  1343. <Dialog
  1344. open={openCreateStockTakeSummaryConfirm}
  1345. onClose={() => {
  1346. if (!creating) setOpenCreateStockTakeSummaryConfirm(false);
  1347. }}
  1348. maxWidth="sm"
  1349. fullWidth
  1350. >
  1351. <DialogTitle>{t("Confirm create stock take")}</DialogTitle>
  1352. <DialogContent dividers>
  1353. <Stack spacing={1.5} sx={{ mb: 2 }}>
  1354. <Typography variant="body2">
  1355. <Typography component="span" color="text.secondary">
  1356. {t("Stock take round name")}:{" "}
  1357. </Typography>
  1358. {createDialogRoundName.trim() || t("Not filled")}
  1359. </Typography>
  1360. <Typography variant="body2">
  1361. <Typography component="span" color="text.secondary">
  1362. {t("Creation date")}:{" "}
  1363. </Typography>
  1364. {createDialogPlanStart?.isValid()
  1365. ? createDialogPlanStart.format(OUTPUT_DATE_FORMAT)
  1366. : "-"}
  1367. </Typography>
  1368. <Typography variant="body2" fontWeight={600}>
  1369. {t("Total selected sections label")}{" "}
  1370. <Typography component="span"fontWeight={600} >
  1371. {createStockTakeSummarySections.length}
  1372. </Typography>{" "}
  1373. {t("sections unit")}
  1374. </Typography>
  1375. </Stack>
  1376. <Box
  1377. sx={{
  1378. maxHeight: 280,
  1379. overflowY: "auto",
  1380. border: 1,
  1381. borderColor: "divider",
  1382. borderRadius: 1,
  1383. p: 1.5,
  1384. }}
  1385. >
  1386. <Stack spacing={1}>
  1387. {createStockTakeSummarySections.map((section) => {
  1388. const meta = getCreateDialogSectionMeta(section);
  1389. return (
  1390. <Box key={section}>
  1391. <Typography variant="body2" fontWeight={600}>
  1392. {section}
  1393. </Typography>
  1394. {meta ? (
  1395. <Typography variant="caption" color="text.secondary">
  1396. {meta}
  1397. </Typography>
  1398. ) : null}
  1399. </Box>
  1400. );
  1401. })}
  1402. </Stack>
  1403. </Box>
  1404. </DialogContent>
  1405. <DialogActions sx={{ px: 3, py: 2 }}>
  1406. <Button
  1407. onClick={() => setOpenCreateStockTakeSummaryConfirm(false)}
  1408. disabled={creating}
  1409. >
  1410. {t("Cancel")}
  1411. </Button>
  1412. <Button
  1413. variant="contained"
  1414. color="primary"
  1415. onClick={handleCreateStockTake}
  1416. disabled={creating || createDialogSelectedSections.length === 0}
  1417. >
  1418. {creating ? <CircularProgress size={20} /> : t("Confirm")}
  1419. </Button>
  1420. </DialogActions>
  1421. </Dialog>
  1422. <Drawer
  1423. anchor="right"
  1424. open={missingSectionWarnDrawerOpen}
  1425. onClose={() => setMissingSectionWarnDrawerOpen(false)}
  1426. ModalProps={{
  1427. sx: (theme) => ({
  1428. // Drawer 預設 z-index 1200,低於 Dialog (1300),會被建立盤點對話框遮住
  1429. zIndex: theme.zIndex.modal + 2,
  1430. }),
  1431. }}
  1432. PaperProps={{
  1433. sx: {
  1434. width: { xs: "100%", sm: 400 },
  1435. p: 0,
  1436. display: "flex",
  1437. flexDirection: "column",
  1438. maxHeight: "100vh",
  1439. },
  1440. }}
  1441. >
  1442. <Box sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}>
  1443. <Typography variant="h6" fontWeight={700}>
  1444. {t("Warehouse missing stock take section warn title")}
  1445. </Typography>
  1446. <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
  1447. {t("Warehouse missing stock take section drawer hint")}
  1448. </Typography>
  1449. {missingSectionCount > 0 ? (
  1450. <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}>
  1451. {t("Warehouse missing stock take section showing", {
  1452. shown: Math.min(missingSectionItems.length, missingSectionIssuesLimit),
  1453. count: missingSectionCount,
  1454. })}
  1455. </Typography>
  1456. ) : null}
  1457. </Box>
  1458. <Box sx={{ flex: 1, overflowY: "auto", px: 2, py: 1 }}>
  1459. {missingSectionIssuesLoading ? (
  1460. <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
  1461. <CircularProgress size={28} />
  1462. </Box>
  1463. ) : missingSectionItems.length === 0 ? (
  1464. <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
  1465. {t("Warehouse missing stock take section empty")}
  1466. </Typography>
  1467. ) : (
  1468. <Stack spacing={1.5} divider={<Divider flexItem />}>
  1469. {missingSectionItems.map((row) => (
  1470. <Box key={row.id}>
  1471. <Typography variant="body2" fontWeight={600}>
  1472. {row.code || `#${row.id}`}
  1473. </Typography>
  1474. <Typography variant="caption" color="text.secondary" display="block">
  1475. {[row.storeId, row.warehouse, row.area, row.slot].filter(Boolean).join(" / ") ||
  1476. "—"}
  1477. </Typography>
  1478. {row.order ? (
  1479. <Typography variant="caption" color="text.secondary" display="block">
  1480. {row.order}
  1481. </Typography>
  1482. ) : null}
  1483. </Box>
  1484. ))}
  1485. </Stack>
  1486. )}
  1487. </Box>
  1488. <Box
  1489. sx={{
  1490. p: 2,
  1491. borderTop: 1,
  1492. borderColor: "divider",
  1493. display: "flex",
  1494. gap: 1,
  1495. flexWrap: "wrap",
  1496. }}
  1497. >
  1498. <Button variant="outlined" onClick={() => setMissingSectionWarnDrawerOpen(false)}>
  1499. {t("Cancel")}
  1500. </Button>
  1501. <Button
  1502. variant="contained"
  1503. color="primary"
  1504. onClick={handleGoWarehouseSettings}
  1505. disabled={missingSectionCount === 0}
  1506. >
  1507. {t("Warehouse missing stock take section go settings")}
  1508. </Button>
  1509. </Box>
  1510. </Drawer>
  1511. </Box>
  1512. );
  1513. };
  1514. export default PickerCardList;