FPSMS-frontend
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

PickerCardList.tsx 59 KiB

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