FPSMS-frontend
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 

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