FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

678 lines
20 KiB

  1. "use client";
  2. import { DoResult } from "@/app/api/do";
  3. import { DoSearchAll, DoSearchLiteResponse, fetchDoSearch, fetchAllDoSearch, fetchDoSearchList, releaseDo ,startBatchReleaseAsync, getBatchReleaseProgress} from "@/app/api/do/actions";
  4. import { useRouter } from "next/navigation";
  5. import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react";
  6. import { useTranslation } from "react-i18next";
  7. import { Criterion } from "../SearchBox";
  8. import { isEmpty, sortBy, uniqBy, upperFirst } from "lodash";
  9. import { arrayToDateString, arrayToDayjs } from "@/app/utils/formatUtil";
  10. import SearchBox from "../SearchBox/SearchBox";
  11. import { EditNote } from "@mui/icons-material";
  12. import InputDataGrid from "../InputDataGrid";
  13. import { CreateConsoDoInput } from "@/app/api/do/actions";
  14. import { TableRow } from "../InputDataGrid/InputDataGrid";
  15. import {
  16. FooterPropsOverrides,
  17. GridColDef,
  18. GridRowModel,
  19. GridToolbarContainer,
  20. useGridApiRef,
  21. } from "@mui/x-data-grid";
  22. import {
  23. FormProvider,
  24. SubmitErrorHandler,
  25. SubmitHandler,
  26. useForm,
  27. } from "react-hook-form";
  28. import { Box, Button, Grid, Stack, Typography, TablePagination} from "@mui/material";
  29. import StyledDataGrid from "../StyledDataGrid";
  30. import { GridRowSelectionModel } from "@mui/x-data-grid";
  31. import Swal from "sweetalert2";
  32. import { useSession } from "next-auth/react";
  33. import { SessionWithTokens } from "@/config/authConfig";
  34. type Props = {
  35. filterArgs?: Record<string, any>;
  36. searchQuery?: Record<string, any>;
  37. onDeliveryOrderSearch: () => void;
  38. };
  39. type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" , string>;
  40. type SearchParamNames = keyof SearchBoxInputs;
  41. // put all this into a new component
  42. // ConsoDoForm
  43. type EntryError =
  44. | {
  45. [field in keyof DoResult]?: string;
  46. }
  47. | undefined;
  48. type DoRow = TableRow<Partial<DoResult>, EntryError>;
  49. const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSearch}) => {
  50. const apiRef = useGridApiRef();
  51. const formProps = useForm<CreateConsoDoInput>({
  52. defaultValues: {},
  53. });
  54. const errors = formProps.formState.errors;
  55. const { t } = useTranslation("do");
  56. const router = useRouter();
  57. const { data: session } = useSession() as { data: SessionWithTokens | null };
  58. const currentUserId = session?.id ? parseInt(session.id) : undefined;
  59. console.log("🔍 DoSearch - session:", session);
  60. console.log("🔍 DoSearch - currentUserId:", currentUserId);
  61. const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null);
  62. const [rowSelectionModel, setRowSelectionModel] =
  63. useState<GridRowSelectionModel>([]);
  64. const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]);
  65. const [totalCount, setTotalCount] = useState(0);
  66. const [pagingController, setPagingController] = useState({
  67. pageNum: 1,
  68. pageSize: 10,
  69. });
  70. const [currentSearchParams, setCurrentSearchParams] = useState<SearchBoxInputs>({
  71. code: "",
  72. status: "",
  73. estimatedArrivalDate: "",
  74. orderDate: "",
  75. supplierName: "",
  76. shopName: "",
  77. deliveryOrderLines: "",
  78. codeTo: "",
  79. statusTo: "",
  80. estimatedArrivalDateTo: "",
  81. orderDateTo: "",
  82. supplierNameTo: "",
  83. shopNameTo: "",
  84. deliveryOrderLinesTo: ""
  85. });
  86. const [hasSearched, setHasSearched] = useState(false);
  87. const [hasResults, setHasResults] = useState(false);
  88. // 当搜索条件变化时,重置到第一页
  89. useEffect(() => {
  90. setPagingController(p => ({
  91. ...p,
  92. pageNum: 1,
  93. }));
  94. }, [currentSearchParams.code, currentSearchParams.shopName, currentSearchParams.status, currentSearchParams.estimatedArrivalDate]);
  95. const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
  96. () => [
  97. { label: t("Code"), paramName: "code", type: "text" },
  98. { label: t("Shop Name"), paramName: "shopName", type: "text" },
  99. {
  100. label: t("Estimated Arrival"),
  101. paramName: "estimatedArrivalDate",
  102. type: "date",
  103. },
  104. {
  105. label: t("Status"),
  106. paramName: "status",
  107. type: "autocomplete",
  108. options:[
  109. {label: t('Pending'), value: 'pending'},
  110. {label: t('Receiving'), value: 'receiving'},
  111. {label: t('Completed'), value: 'completed'}
  112. ]
  113. }
  114. ],
  115. [t],
  116. );
  117. const onReset = useCallback(async () => {
  118. try {
  119. setSearchAllDos([]);
  120. setTotalCount(0);
  121. setHasSearched(false);
  122. setHasResults(false);
  123. setPagingController({ pageNum: 1, pageSize: 10 });
  124. }
  125. catch (error) {
  126. console.error("Error: ", error);
  127. setSearchAllDos([]);
  128. setTotalCount(0);
  129. }
  130. }, []);
  131. const onDetailClick = useCallback(
  132. (doResult: DoResult) => {
  133. if (typeof window !== 'undefined') {
  134. sessionStorage.setItem('doSearchParams', JSON.stringify(currentSearchParams));
  135. }
  136. router.push(`/do/edit?id=${doResult.id}`);
  137. },
  138. [router, currentSearchParams],
  139. );
  140. const validationTest = useCallback(
  141. (
  142. newRow: GridRowModel<DoRow>,
  143. ): EntryError => {
  144. const error: EntryError = {};
  145. console.log(newRow);
  146. return Object.keys(error).length > 0 ? error : undefined;
  147. },
  148. [],
  149. );
  150. const columns = useMemo<GridColDef[]>(
  151. () => [
  152. {
  153. field: "id",
  154. headerName: t("Details"),
  155. width: 100,
  156. renderCell: (params) => (
  157. <Button
  158. variant="outlined"
  159. size="small"
  160. startIcon={<EditNote />}
  161. onClick={() => onDetailClick(params.row)}
  162. >
  163. {t("Details")}
  164. </Button>
  165. ),
  166. },
  167. {
  168. field: "code",
  169. headerName: t("code"),
  170. flex: 1.5,
  171. },
  172. {
  173. field: "shopName",
  174. headerName: t("Shop Name"),
  175. flex: 1,
  176. },
  177. {
  178. field: "supplierName",
  179. headerName: t("Supplier Name"),
  180. flex: 1,
  181. },
  182. {
  183. field: "orderDate",
  184. headerName: t("Order Date"),
  185. flex: 1,
  186. renderCell: (params) => {
  187. return params.row.orderDate
  188. ? arrayToDateString(params.row.orderDate)
  189. : "N/A";
  190. },
  191. },
  192. {
  193. field: "estimatedArrivalDate",
  194. headerName: t("Estimated Arrival"),
  195. flex: 1,
  196. renderCell: (params) => {
  197. return params.row.estimatedArrivalDate
  198. ? arrayToDateString(params.row.estimatedArrivalDate)
  199. : "N/A";
  200. },
  201. },
  202. {
  203. field: "status",
  204. headerName: t("Status"),
  205. flex: 1,
  206. renderCell: (params) => {
  207. return t(upperFirst(params.row.status));
  208. },
  209. },
  210. ],
  211. [t, arrayToDateString, onDetailClick],
  212. );
  213. const onSubmit = useCallback<SubmitHandler<CreateConsoDoInput>>(
  214. async (data, event) => {
  215. const hasErrors = false;
  216. console.log(errors);
  217. },
  218. [errors],
  219. );
  220. const onSubmitError = useCallback<SubmitErrorHandler<CreateConsoDoInput>>(
  221. (errors) => {},
  222. [],
  223. );
  224. //SEARCH FUNCTION
  225. const handleSearch = useCallback(async (query: SearchBoxInputs) => {
  226. try {
  227. setCurrentSearchParams(query);
  228. let estArrStartDate = query.estimatedArrivalDate;
  229. const time = "T00:00:00";
  230. if(estArrStartDate != ""){
  231. estArrStartDate = query.estimatedArrivalDate + time;
  232. }
  233. let status = "";
  234. if(query.status == "All"){
  235. status = "";
  236. }
  237. else{
  238. status = query.status;
  239. }
  240. // 调用新的 API,传入分页参数
  241. const response = await fetchDoSearch(
  242. query.code || "",
  243. query.shopName || "",
  244. status,
  245. "", // orderStartDate - 不再使用
  246. "", // orderEndDate - 不再使用
  247. estArrStartDate,
  248. "", // estArrEndDate - 不再使用
  249. pagingController.pageNum, // 传入当前页码
  250. pagingController.pageSize // 传入每页大小
  251. );
  252. setSearchAllDos(response.records);
  253. setTotalCount(response.total); // 设置总记录数
  254. setHasSearched(true);
  255. setHasResults(response.records.length > 0);
  256. } catch (error) {
  257. console.error("Error: ", error);
  258. setSearchAllDos([]);
  259. setTotalCount(0);
  260. setHasSearched(true);
  261. setHasResults(false);
  262. }
  263. }, [pagingController]);
  264. useEffect(() => {
  265. if (typeof window !== 'undefined') {
  266. const savedSearchParams = sessionStorage.getItem('doSearchParams');
  267. if (savedSearchParams) {
  268. try {
  269. const params = JSON.parse(savedSearchParams);
  270. setCurrentSearchParams(params);
  271. // 自动使用保存的搜索条件重新搜索,获取最新数据
  272. const timer = setTimeout(async () => {
  273. await handleSearch(params);
  274. // 搜索完成后,清除 sessionStorage
  275. if (typeof window !== 'undefined') {
  276. sessionStorage.removeItem('doSearchParams');
  277. sessionStorage.removeItem('doSearchResults');
  278. sessionStorage.removeItem('doSearchHasSearched');
  279. }
  280. }, 100);
  281. return () => clearTimeout(timer);
  282. } catch (e) {
  283. console.error('Error restoring search state:', e);
  284. // 如果出错,也清除 sessionStorage
  285. if (typeof window !== 'undefined') {
  286. sessionStorage.removeItem('doSearchParams');
  287. sessionStorage.removeItem('doSearchResults');
  288. sessionStorage.removeItem('doSearchHasSearched');
  289. }
  290. }
  291. }
  292. }
  293. }, [handleSearch]);
  294. const debouncedSearch = useCallback((query: SearchBoxInputs) => {
  295. if (searchTimeout) {
  296. clearTimeout(searchTimeout);
  297. }
  298. const timeout = setTimeout(() => {
  299. handleSearch(query);
  300. }, 300);
  301. setSearchTimeout(timeout);
  302. }, [handleSearch, searchTimeout]);
  303. // 分页变化时重新搜索
  304. const handlePageChange = useCallback((event: unknown, newPage: number) => {
  305. const newPagingController = {
  306. ...pagingController,
  307. pageNum: newPage + 1,
  308. };
  309. setPagingController(newPagingController);
  310. // 如果已经搜索过,重新搜索
  311. if (hasSearched && currentSearchParams) {
  312. // 使用新的分页参数重新搜索
  313. const searchWithNewPage = async () => {
  314. try {
  315. let estArrStartDate = currentSearchParams.estimatedArrivalDate;
  316. const time = "T00:00:00";
  317. if(estArrStartDate != ""){
  318. estArrStartDate = currentSearchParams.estimatedArrivalDate + time;
  319. }
  320. let status = "";
  321. if(currentSearchParams.status == "All"){
  322. status = "";
  323. }
  324. else{
  325. status = currentSearchParams.status;
  326. }
  327. const response = await fetchDoSearch(
  328. currentSearchParams.code || "",
  329. currentSearchParams.shopName || "",
  330. status,
  331. "",
  332. "",
  333. estArrStartDate,
  334. "",
  335. newPagingController.pageNum,
  336. newPagingController.pageSize
  337. );
  338. setSearchAllDos(response.records);
  339. setTotalCount(response.total);
  340. } catch (error) {
  341. console.error("Error: ", error);
  342. }
  343. };
  344. searchWithNewPage();
  345. }
  346. }, [pagingController, hasSearched, currentSearchParams]);
  347. const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
  348. const newPageSize = parseInt(event.target.value, 10);
  349. const newPagingController = {
  350. pageNum: 1, // 改变每页大小时重置到第一页
  351. pageSize: newPageSize,
  352. };
  353. setPagingController(newPagingController);
  354. // 如果已经搜索过,重新搜索
  355. if (hasSearched && currentSearchParams) {
  356. const searchWithNewPageSize = async () => {
  357. try {
  358. let estArrStartDate = currentSearchParams.estimatedArrivalDate;
  359. const time = "T00:00:00";
  360. if(estArrStartDate != ""){
  361. estArrStartDate = currentSearchParams.estimatedArrivalDate + time;
  362. }
  363. let status = "";
  364. if(currentSearchParams.status == "All"){
  365. status = "";
  366. }
  367. else{
  368. status = currentSearchParams.status;
  369. }
  370. const response = await fetchDoSearch(
  371. currentSearchParams.code || "",
  372. currentSearchParams.shopName || "",
  373. status,
  374. "",
  375. "",
  376. estArrStartDate,
  377. "",
  378. 1, // 重置到第一页
  379. newPageSize
  380. );
  381. setSearchAllDos(response.records);
  382. setTotalCount(response.total);
  383. } catch (error) {
  384. console.error("Error: ", error);
  385. }
  386. };
  387. searchWithNewPageSize();
  388. }
  389. }, [hasSearched, currentSearchParams]);
  390. const handleBatchRelease = useCallback(async () => {
  391. try {
  392. // 根据当前搜索条件获取所有匹配的记录(不分页)
  393. let estArrStartDate = currentSearchParams.estimatedArrivalDate;
  394. const time = "T00:00:00";
  395. if(estArrStartDate != ""){
  396. estArrStartDate = currentSearchParams.estimatedArrivalDate + time;
  397. }
  398. let status = "";
  399. if(currentSearchParams.status == "All"){
  400. status = "";
  401. }
  402. else{
  403. status = currentSearchParams.status;
  404. }
  405. // 显示加载提示
  406. const loadingSwal = Swal.fire({
  407. title: t("Loading"),
  408. text: t("Fetching all matching records..."),
  409. allowOutsideClick: false,
  410. allowEscapeKey: false,
  411. showConfirmButton: false,
  412. didOpen: () => {
  413. Swal.showLoading();
  414. }
  415. });
  416. // 获取所有匹配的记录
  417. const allMatchingDos = await fetchAllDoSearch(
  418. currentSearchParams.code || "",
  419. currentSearchParams.shopName || "",
  420. status,
  421. estArrStartDate
  422. );
  423. Swal.close();
  424. if (allMatchingDos.length === 0) {
  425. await Swal.fire({
  426. icon: "warning",
  427. title: t("No Records"),
  428. text: t("No matching records found for batch release."),
  429. confirmButtonText: t("OK")
  430. });
  431. return;
  432. }
  433. // 显示确认对话框
  434. const result = await Swal.fire({
  435. icon: "question",
  436. title: t("Batch Release"),
  437. html: `
  438. <div>
  439. <p>${t("Selected Shop(s): ")}${allMatchingDos.length}</p>
  440. <p style="font-size: 0.9em; color: #666; margin-top: 8px;">
  441. ${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""}
  442. ${currentSearchParams.shopName ? `${t("Shop Name")}: ${currentSearchParams.shopName} ` : ""}
  443. ${currentSearchParams.estimatedArrivalDate ? `${t("Estimated Arrival")}: ${currentSearchParams.estimatedArrivalDate} ` : ""}
  444. ${status ? `${t("Status")}: ${status} ` : ""}
  445. </p>
  446. </div>
  447. `,
  448. showCancelButton: true,
  449. confirmButtonText: t("Confirm"),
  450. cancelButtonText: t("Cancel"),
  451. confirmButtonColor: "#8dba00",
  452. cancelButtonColor: "#F04438"
  453. });
  454. if (result.isConfirmed) {
  455. const idsToRelease = allMatchingDos.map(d => d.id);
  456. try {
  457. const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 });
  458. const jobId = startRes?.entity?.jobId;
  459. if (!jobId) {
  460. await Swal.fire({ icon: "error", title: t("Error"), text: t("Failed to start batch release") });
  461. return;
  462. }
  463. const progressSwal = Swal.fire({
  464. title: t("Releasing"),
  465. text: "0% (0 / 0)",
  466. allowOutsideClick: false,
  467. allowEscapeKey: false,
  468. showConfirmButton: false,
  469. didOpen: () => {
  470. Swal.showLoading();
  471. }
  472. });
  473. const timer = setInterval(async () => {
  474. try {
  475. const p = await getBatchReleaseProgress(jobId);
  476. const e = p?.entity || {};
  477. const total = e.total ?? 0;
  478. const finished = e.finished ?? 0;
  479. const percentage = total > 0 ? Math.round((finished / total) * 100) : 0;
  480. const textContent = document.querySelector('.swal2-html-container');
  481. if (textContent) {
  482. textContent.textContent = `${percentage}% (${finished} / ${total})`;
  483. }
  484. if (p.code === "FINISHED" || e.running === false) {
  485. clearInterval(timer);
  486. await new Promise(resolve => setTimeout(resolve, 500));
  487. Swal.close();
  488. await Swal.fire({
  489. icon: "success",
  490. title: t("Completed"),
  491. text: t("Batch release completed successfully."),
  492. confirmButtonText: t("Confirm"),
  493. confirmButtonColor: "#8dba00"
  494. });
  495. if (currentSearchParams && Object.keys(currentSearchParams).length > 0) {
  496. await handleSearch(currentSearchParams);
  497. }
  498. }
  499. } catch (err) {
  500. console.error("progress poll error:", err);
  501. }
  502. }, 800);
  503. } catch (error) {
  504. console.error("Batch release error:", error);
  505. await Swal.fire({
  506. icon: "error",
  507. title: t("Error"),
  508. text: t("An error occurred during batch release"),
  509. confirmButtonText: t("OK")
  510. });
  511. }
  512. }
  513. } catch (error) {
  514. console.error("Error fetching all matching records:", error);
  515. await Swal.fire({
  516. icon: "error",
  517. title: t("Error"),
  518. text: t("Failed to fetch matching records"),
  519. confirmButtonText: t("OK")
  520. });
  521. }
  522. }, [t, currentUserId, currentSearchParams, handleSearch]);
  523. return (
  524. <>
  525. <FormProvider {...formProps}>
  526. <Stack
  527. spacing={2}
  528. component="form"
  529. onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)}
  530. >
  531. <Grid container>
  532. <Grid item xs={8}>
  533. <Typography variant="h4" marginInlineEnd={2}>
  534. {t("Delivery Order")}
  535. </Typography>
  536. </Grid>
  537. <Grid
  538. item
  539. xs={4}
  540. display="flex"
  541. justifyContent="end"
  542. alignItems="end"
  543. >
  544. <Stack spacing={2} direction="row">
  545. {hasSearched && hasResults && (
  546. <Button
  547. name="batch_release"
  548. variant="contained"
  549. onClick={handleBatchRelease}
  550. >
  551. {t("Batch Release")}
  552. </Button>
  553. )}
  554. </Stack>
  555. </Grid>
  556. </Grid>
  557. <SearchBox
  558. criteria={searchCriteria}
  559. onSearch={handleSearch}
  560. onReset={onReset}
  561. />
  562. <StyledDataGrid
  563. rows={searchAllDos}
  564. columns={columns}
  565. checkboxSelection
  566. rowSelectionModel={rowSelectionModel}
  567. onRowSelectionModelChange={(newRowSelectionModel) => {
  568. setRowSelectionModel(newRowSelectionModel);
  569. formProps.setValue("ids", newRowSelectionModel);
  570. }}
  571. slots={{
  572. footer: FooterToolbar,
  573. noRowsOverlay: NoRowsOverlay,
  574. }}
  575. />
  576. <TablePagination
  577. component="div"
  578. count={totalCount}
  579. page={(pagingController.pageNum - 1)}
  580. rowsPerPage={pagingController.pageSize}
  581. onPageChange={handlePageChange}
  582. onRowsPerPageChange={handlePageSizeChange}
  583. rowsPerPageOptions={[10, 25, 50]}
  584. />
  585. </Stack>
  586. </FormProvider>
  587. </>
  588. );
  589. };
  590. const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => {
  591. return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>;
  592. };
  593. const NoRowsOverlay: React.FC = () => {
  594. const { t } = useTranslation("home");
  595. return (
  596. <Box
  597. display="flex"
  598. justifyContent="center"
  599. alignItems="center"
  600. height="100%"
  601. >
  602. <Typography variant="caption">{t("Add some entries!")}</Typography>
  603. </Box>
  604. );
  605. };
  606. export default DoSearch;