FPSMS-frontend
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 

443 řádky
17 KiB

  1. "use client";
  2. import SearchBox, { Criterion } from "../SearchBox";
  3. import { useCallback, useMemo, useState, useEffect, useRef } from "react";
  4. import { useTranslation } from "react-i18next";
  5. import SearchResults, { Column } from "../SearchResults/index";
  6. import { StockTransactionResponse, SearchStockTransactionRequest } from "@/app/api/stockTake/actions";
  7. import { decimalFormatter } from "@/app/utils/formatUtil";
  8. import { Stack, Box } from "@mui/material";
  9. import { searchStockTransactions } from "@/app/api/stockTake/actions";
  10. interface Props {
  11. dataList: StockTransactionResponse[];
  12. }
  13. type SearchQuery = {
  14. itemCode?: string;
  15. itemName?: string;
  16. type?: string;
  17. startDate?: string;
  18. endDate?: string;
  19. };
  20. // 扩展类型以包含计算字段
  21. interface ExtendedStockTransaction extends StockTransactionResponse {
  22. formattedDate: string;
  23. inQty: number;
  24. outQty: number;
  25. balanceQty: number;
  26. }
  27. const SearchPage: React.FC<Props> = ({ dataList: initialDataList }) => {
  28. const { t } = useTranslation("inventory");
  29. // 添加数据状态
  30. const [dataList, setDataList] = useState<StockTransactionResponse[]>(initialDataList);
  31. const [loading, setLoading] = useState(false);
  32. const [filterArgs, setFilterArgs] = useState<Record<string, any>>({});
  33. const isInitialMount = useRef(true);
  34. const [searchTrigger, setSearchTrigger] = useState(0);
  35. // 添加分页状态
  36. const [page, setPage] = useState(0);
  37. const [pageSize, setPageSize] = useState<number | string>(10);
  38. const [pagingController, setPagingController] = useState({ pageNum: 1, pageSize: 100 });
  39. const [hasSearchQuery, setHasSearchQuery] = useState(false);
  40. const [totalCount, setTotalCount] = useState(initialDataList.length);
  41. const processedData = useMemo(() => {
  42. // 按日期和 itemId 排序 - 优先使用 date 字段,如果没有则使用 transactionDate
  43. const sorted = [...dataList].sort((a, b) => {
  44. // 优先使用 date 字段,如果没有则使用 transactionDate 的日期部分
  45. const getDateValue = (item: StockTransactionResponse): number => {
  46. if (item.date) {
  47. return new Date(item.date).getTime();
  48. }
  49. if (item.transactionDate) {
  50. if (Array.isArray(item.transactionDate)) {
  51. const [year, month, day] = item.transactionDate;
  52. return new Date(year, month - 1, day).getTime();
  53. } else {
  54. return new Date(item.transactionDate).getTime();
  55. }
  56. }
  57. return 0;
  58. };
  59. const dateA = getDateValue(a);
  60. const dateB = getDateValue(b);
  61. if (dateA !== dateB) return dateA - dateB; // 从旧到新排序
  62. return a.itemId - b.itemId;
  63. });
  64. // 计算每个 item 的累计余额
  65. const balanceMap = new Map<number, number>(); // itemId -> balance
  66. const processed: ExtendedStockTransaction[] = [];
  67. sorted.forEach((item) => {
  68. const currentBalance = balanceMap.get(item.itemId) || 0;
  69. // 格式化日期 - 优先使用 date 字段
  70. let formattedDate = "";
  71. if (item.date) {
  72. // 如果 date 是字符串格式 "yyyy-MM-dd"
  73. const date = new Date(item.date);
  74. if (!isNaN(date.getTime())) {
  75. const year = date.getFullYear();
  76. const month = String(date.getMonth() + 1).padStart(2, "0");
  77. const day = String(date.getDate()).padStart(2, "0");
  78. formattedDate = `${year}-${month}-${day}`;
  79. }
  80. } else if (item.transactionDate) {
  81. // 回退到 transactionDate
  82. if (Array.isArray(item.transactionDate)) {
  83. const [year, month, day] = item.transactionDate;
  84. formattedDate = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
  85. } else if (typeof item.transactionDate === 'string') {
  86. const date = new Date(item.transactionDate);
  87. if (!isNaN(date.getTime())) {
  88. const year = date.getFullYear();
  89. const month = String(date.getMonth() + 1).padStart(2, "0");
  90. const day = String(date.getDate()).padStart(2, "0");
  91. formattedDate = `${year}-${month}-${day}`;
  92. }
  93. } else {
  94. const date = new Date(item.transactionDate);
  95. if (!isNaN(date.getTime())) {
  96. const year = date.getFullYear();
  97. const month = String(date.getMonth() + 1).padStart(2, "0");
  98. const day = String(date.getDate()).padStart(2, "0");
  99. formattedDate = `${year}-${month}-${day}`;
  100. }
  101. }
  102. }
  103. processed.push({
  104. ...item,
  105. formattedDate,
  106. inQty: item.transactionType === "IN" ? item.qty : 0,
  107. outQty: item.transactionType === "OUT" ? item.qty : 0,
  108. balanceQty: item.balanceQty ?? 0,
  109. });
  110. });
  111. return processed;
  112. }, [dataList]);
  113. // 修复:使用 processedData 初始化 filteredList
  114. const [filteredList, setFilteredList] = useState<ExtendedStockTransaction[]>(processedData);
  115. // 当 processedData 变化时更新 filteredList(不更新 pagingController,避免循环)
  116. useEffect(() => {
  117. setFilteredList(processedData);
  118. // 只在初始加载时设置 pageSize
  119. if (isInitialMount.current && processedData.length > 0) {
  120. setPageSize("all");
  121. setPagingController(prev => ({ ...prev, pageSize: processedData.length }));
  122. setPage(0);
  123. isInitialMount.current = false;
  124. }
  125. }, [processedData]);
  126. // API 调用函数(参考 PoSearch 的实现)
  127. // API 调用函数(参考 PoSearch 的实现)
  128. const newPageFetch = useCallback(
  129. async (
  130. pagingController: Record<string, number>,
  131. filterArgs: Record<string, any>,
  132. ) => {
  133. setLoading(true);
  134. try {
  135. const itemCode = filterArgs.itemCode?.trim() || null;
  136. const itemName = filterArgs.itemName?.trim() || null;
  137. if (!itemCode && !itemName) {
  138. console.warn("Search requires at least itemCode or itemName");
  139. setDataList([]);
  140. setTotalCount(0);
  141. return;
  142. }
  143. const params: SearchStockTransactionRequest = {
  144. itemCode: itemCode,
  145. itemName: itemName,
  146. type: (filterArgs.type?.trim() && filterArgs.type?.trim() !== "All")
  147. ? filterArgs.type.trim()
  148. : null, // type="All" 時傳 null,不套用 type 篩選
  149. startDate: filterArgs.startDate || null,
  150. endDate: filterArgs.endDate || null,
  151. pageNum: pagingController.pageNum - 1 || 0,
  152. pageSize: pagingController.pageSize || 100,
  153. };
  154. const res = await searchStockTransactions(params);
  155. if (res && typeof res === 'object' && Array.isArray(res.records)) {
  156. setDataList(res.records);
  157. setTotalCount(res.total ?? res.records.length);
  158. } else {
  159. console.error("Invalid response format:", res);
  160. setDataList([]);
  161. setTotalCount(0);
  162. }
  163. } catch (error) {
  164. console.error("Fetch error:", error);
  165. setDataList([]);
  166. setTotalCount(0);
  167. } finally {
  168. setLoading(false);
  169. }
  170. },
  171. [],
  172. );
  173. // 使用 useRef 来存储上一次的值,避免不必要的 API 调用
  174. const prevPagingControllerRef = useRef(pagingController);
  175. const prevFilterArgsRef = useRef(filterArgs);
  176. const hasSearchedRef = useRef(false);
  177. // 当 filterArgs 或 pagingController 变化时调用 API(只在真正变化时调用)
  178. useEffect(() => {
  179. // 检查是否有有效的搜索条件
  180. const hasValidSearch = filterArgs.itemCode || filterArgs.itemName;
  181. if (!hasValidSearch) {
  182. // 如果没有有效搜索条件,只更新 ref,不调用 API
  183. if (isInitialMount.current) {
  184. isInitialMount.current = false;
  185. }
  186. prevFilterArgsRef.current = filterArgs;
  187. return;
  188. }
  189. const pagingChanged =
  190. prevPagingControllerRef.current.pageNum !== pagingController.pageNum ||
  191. prevPagingControllerRef.current.pageSize !== pagingController.pageSize;
  192. const filterChanged = JSON.stringify(prevFilterArgsRef.current) !== JSON.stringify(filterArgs);
  193. // 如果是第一次有效搜索,或者条件/分页发生变化,或者有新的搜索触发,则调用 API
  194. if (!hasSearchedRef.current || pagingChanged || filterChanged || searchTrigger > 0) {
  195. newPageFetch(pagingController, filterArgs);
  196. prevPagingControllerRef.current = pagingController;
  197. prevFilterArgsRef.current = filterArgs;
  198. hasSearchedRef.current = true;
  199. isInitialMount.current = false;
  200. }
  201. }, [newPageFetch, pagingController, filterArgs, searchTrigger]);
  202. // 分页处理函数
  203. const handleChangePage = useCallback((event: unknown, newPage: number) => {
  204. setPage(newPage);
  205. setPagingController(prev => ({ ...prev, pageNum: newPage + 1 }));
  206. }, []);
  207. const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
  208. const newSize = parseInt(event.target.value, 10);
  209. if (newSize === -1) {
  210. setPageSize("all");
  211. setPagingController(prev => ({ ...prev, pageSize: 100, pageNum: 1 })); // 用 100 觸發後端回傳全部
  212. } else if (!isNaN(newSize)) {
  213. setPageSize(newSize);
  214. setPagingController(prev => ({ ...prev, pageSize: newSize, pageNum: 1 }));
  215. }
  216. setPage(0);
  217. }, []);
  218. const searchCriteria: Criterion<string>[] = useMemo(
  219. () => [
  220. {
  221. label: t("Item Code"),
  222. paramName: "itemCode",
  223. type: "text",
  224. },
  225. {
  226. label: t("Item Name"),
  227. paramName: "itemName",
  228. type: "text",
  229. },
  230. {
  231. label: t("Type"),
  232. paramName: "type",
  233. type: "select-labelled",
  234. options: [
  235. { value: "tke", label: t("tke") }, // 盤點
  236. { value: "ADJ", label: t("adj") },
  237. { value: "Nor", label: t("nor") },
  238. { value: "TRF", label: t("trf") },
  239. { value: "OPEN", label: t("open") }, // 開倉
  240. { value: "miss", label: t("miss") },
  241. { value: "bad", label: t("bad") },
  242. ],
  243. },
  244. {
  245. label: t("Start Date"),
  246. paramName: "startDate",
  247. type: "date",
  248. },
  249. {
  250. label: t("End Date"),
  251. paramName: "endDate",
  252. type: "date",
  253. },
  254. ],
  255. [t],
  256. );
  257. const columns = useMemo<Column<ExtendedStockTransaction>[]>(
  258. () => [
  259. {
  260. name: "formattedDate" as keyof ExtendedStockTransaction,
  261. label: t("Date"),
  262. align: "left",
  263. },
  264. {
  265. name: "itemCode" as keyof ExtendedStockTransaction,
  266. label: t("Item-lotNo"),
  267. align: "left",
  268. renderCell: (item) => (
  269. <Box sx={{
  270. maxWidth: 150,
  271. wordBreak: 'break-word',
  272. whiteSpace: 'normal',
  273. lineHeight: 1.5
  274. }}>
  275. <Stack spacing={0.5}>
  276. <Box>{item.itemCode || "-"} {item.itemName || "-"}</Box>
  277. <Box>{item.lotNo || "-"}</Box>
  278. </Stack>
  279. </Box>
  280. ),
  281. },
  282. {
  283. name: "inQty" as keyof ExtendedStockTransaction,
  284. label: t("In Qty"),
  285. align: "left",
  286. type: "decimal",
  287. renderCell: (item) => (
  288. <>{item.inQty > 0 ? decimalFormatter.format(item.inQty) : ""}</>
  289. ),
  290. },
  291. {
  292. name: "outQty" as keyof ExtendedStockTransaction,
  293. label: t("Out Qty"),
  294. align: "left",
  295. type: "decimal",
  296. renderCell: (item) => (
  297. <>{item.outQty > 0 ? decimalFormatter.format(item.outQty) : ""}</>
  298. ),
  299. },
  300. {
  301. name: "balanceQty" as keyof ExtendedStockTransaction,
  302. label: t("Balance Qty"),
  303. align: "left",
  304. type: "decimal",
  305. },
  306. {
  307. name: "type",
  308. label: t("Type"),
  309. align: "left",
  310. renderCell: (item) => {
  311. if (!item.type) return "-";
  312. return t(item.type.toLowerCase());
  313. },
  314. },
  315. {
  316. name: "status",
  317. label: t("Status"),
  318. align: "left",
  319. renderCell: (item) => {
  320. if (!item.status) return "-";
  321. return t(item.status.toLowerCase());
  322. },
  323. },
  324. ],
  325. [t],
  326. );
  327. const handleSearch = useCallback((query: Record<string, string>) => {
  328. // 检查是否有搜索条件
  329. const itemCode = query.itemCode?.trim();
  330. const itemName = query.itemName?.trim();
  331. const type = query.type?.trim();
  332. const startDate = query.startDate === "Invalid Date" ? "" : query.startDate;
  333. const endDate = query.endDate === "Invalid Date" ? "" : query.endDate;
  334. // 验证:至少需要 itemCode 或 itemName
  335. if (!itemCode && !itemName) {
  336. // 可以显示提示信息
  337. console.warn("Please enter at least Item Code or Item Name");
  338. return;
  339. }
  340. const hasQuery = !!(itemCode || itemName || type || startDate || endDate);
  341. setHasSearchQuery(hasQuery);
  342. // 更新 filterArgs,触发 useEffect 调用 API
  343. setFilterArgs({
  344. itemCode: itemCode || undefined,
  345. itemName: itemName || undefined,
  346. type: (type && type !== "All") ? type : undefined, // "All" 不放入 filterArgs
  347. startDate: startDate || undefined,
  348. endDate: endDate || undefined,
  349. });
  350. setSearchTrigger(prev => prev + 1);
  351. // 重置分页
  352. setPage(0);
  353. setPagingController(prev => ({ ...prev, pageNum: 1 }));
  354. }, []);
  355. const handleReset = useCallback(() => {
  356. setHasSearchQuery(false);
  357. // 重置 filterArgs,触发 useEffect 调用 API
  358. setFilterArgs({});
  359. setPage(0);
  360. setPagingController(prev => ({ ...prev, pageNum: 1 }));
  361. }, []);
  362. const paginatedItems = useMemo(() => {
  363. if (pageSize === "all") {
  364. return filteredList;
  365. }
  366. const size = typeof pageSize === 'number' ? pageSize : 10;
  367. const start = page * size;
  368. return filteredList.slice(start, start + size);
  369. }, [filteredList, pageSize, page]);
  370. // 计算传递给 SearchResults 的 pageSize(确保在选项中)
  371. const actualPageSizeForTable = useMemo(() => {
  372. if (pageSize === "all") {
  373. return totalCount > 0 ? totalCount : filteredList.length;
  374. }
  375. const size = typeof pageSize === 'number' ? pageSize : 10;
  376. if (![10, 25, 100].includes(size)) {
  377. return size;
  378. }
  379. return size;
  380. }, [pageSize, filteredList.length, totalCount]);
  381. return (
  382. <>
  383. <SearchBox
  384. criteria={searchCriteria}
  385. onSearch={handleSearch}
  386. onReset={handleReset}
  387. />
  388. {loading && <Box sx={{ p: 2 }}>{t("Loading...")}</Box>}
  389. <SearchResults<ExtendedStockTransaction>
  390. items={paginatedItems}
  391. columns={columns}
  392. pagingController={{ ...pagingController, pageSize: actualPageSizeForTable }}
  393. setPagingController={setPagingController}
  394. totalCount={totalCount}
  395. isAutoPaging={false}
  396. />
  397. </>
  398. );
  399. };
  400. export default SearchPage;