FPSMS-frontend
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 

500 行
16 KiB

  1. "use client";
  2. import { PoResult } from "@/app/api/po";
  3. import React, { useCallback, useEffect, useMemo, useState } from "react";
  4. import { useTranslation } from "react-i18next";
  5. import { useRouter, useSearchParams } from "next/navigation";
  6. import SearchBox, { Criterion } from "../SearchBox";
  7. import SearchResults, { Column } from "../SearchResults";
  8. import { EditNote } from "@mui/icons-material";
  9. import { Backdrop, Button, CircularProgress, Grid, Tab, Tabs, TabsProps, Typography } from "@mui/material";
  10. import QrModal from "../PoDetail/QrModal";
  11. import { WarehouseResult } from "@/app/api/warehouse";
  12. import NotificationIcon from "@mui/icons-material/NotificationImportant";
  13. import { useSession } from "next-auth/react";
  14. import { defaultPagingController } from "../SearchResults/SearchResults";
  15. import { testing } from "@/app/api/po/actions";
  16. import dayjs from "dayjs";
  17. import { arrayToDateString, dayjsToDateString } from "@/app/utils/formatUtil";
  18. import arraySupport from "dayjs/plugin/arraySupport";
  19. import { Checkbox, Box } from "@mui/material";
  20. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  21. import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
  22. dayjs.extend(arraySupport);
  23. type Props = {
  24. po: PoResult[];
  25. warehouse: WarehouseResult[];
  26. totalCount: number;
  27. };
  28. type SearchQuery = Partial<Omit<PoResult, "id">>;
  29. type SearchParamNames = keyof SearchQuery;
  30. // cal offset (pageSize)
  31. // cal limit (pageSize)
  32. const PoSearch: React.FC<Props> = ({
  33. po,
  34. warehouse,
  35. totalCount: initTotalCount,
  36. }) => {
  37. const [selectedPoIds, setSelectedPoIds] = useState<number[]>([]);
  38. const [selectAll, setSelectAll] = useState(false);
  39. const [filteredPo, setFilteredPo] = useState<PoResult[]>(po);
  40. const [filterArgs, setFilterArgs] = useState<Record<string, any>>({estimatedArrivalDate : dayjsToDateString(dayjs(), "input")});
  41. const { t } = useTranslation(["purchaseOrder", "dashboard"]);
  42. const router = useRouter();
  43. const PO_DETAIL_SELECTION_KEY = "po-detail-selection";
  44. const [pagingController, setPagingController] = useState(
  45. defaultPagingController,
  46. );
  47. const [totalCount, setTotalCount] = useState(initTotalCount);
  48. const searchCriteria: Criterion<SearchParamNames>[] = useMemo(() => {
  49. const searchCriteria: Criterion<SearchParamNames>[] = [
  50. { label: t("Supplier"), paramName: "supplier", type: "text" },
  51. { label: t("PO No."), paramName: "code", type: "text" },
  52. {
  53. label: t("Escalated"),
  54. paramName: "escalated",
  55. type: "select",
  56. options: [t("Escalated"), t("NotEscalated")],
  57. },
  58. { label: t("Order Date"), label2: t("Order Date To"), paramName: "orderDate", type: "dateRange" },
  59. {
  60. label: t("Status"),
  61. paramName: "status",
  62. type: "select-labelled",
  63. options: [
  64. { label: t(`pending`), value: `pending` },
  65. { label: t(`receiving`), value: `receiving` },
  66. { label: t(`completed`), value: `completed` },
  67. ],
  68. },
  69. { label: t("ETA"),
  70. label2: t("ETA To"),
  71. paramName: "estimatedArrivalDate",
  72. type: "dateRange",
  73. preFilledValue: {
  74. from: dayjsToDateString(dayjs(), "input"),
  75. to: dayjsToDateString(dayjs(), "input"),
  76. },
  77. },
  78. ];
  79. return searchCriteria;
  80. }, [t]);
  81. const onDetailClick = useCallback(
  82. (po: PoResult) => {
  83. setSelectedPoIds([]);
  84. setSelectAll(false);
  85. const listForDetail = [
  86. { id: po.id, code: po.code, status: po.status, supplier: po.supplier ?? null },
  87. ];
  88. try {
  89. sessionStorage.setItem(
  90. PO_DETAIL_SELECTION_KEY,
  91. JSON.stringify(listForDetail),
  92. );
  93. } catch (e) {
  94. console.warn("sessionStorage setItem failed", e);
  95. }
  96. router.push(`/po/edit?id=${po.id}&start=true`);
  97. },
  98. [router],
  99. );
  100. const onDeleteClick = useCallback((po: PoResult) => {}, []);
  101. // handle single checkbox selection
  102. const handleSelectPo = useCallback((poId: number, checked: boolean) => {
  103. if (checked) {
  104. setSelectedPoIds(prev => [...prev, poId]);
  105. } else {
  106. setSelectedPoIds(prev => prev.filter(id => id !== poId));
  107. }
  108. }, []);
  109. // 处理全选
  110. const handleSelectAll = useCallback((checked: boolean) => {
  111. if (checked) {
  112. setSelectedPoIds(filteredPo.map(po => po.id));
  113. setSelectAll(true);
  114. } else {
  115. setSelectedPoIds([]);
  116. setSelectAll(false);
  117. }
  118. }, [filteredPo]);
  119. // navigate to PoDetail page
  120. const handleGoToPoDetail = useCallback(() => {
  121. if (selectedPoIds.length === 0) return;
  122. const selectedList = filteredPo.filter((p) => selectedPoIds.includes(p.id));
  123. const listForDetail = selectedList.map((p) => ({
  124. id: p.id,
  125. code: p.code,
  126. status: p.status,
  127. supplier: p.supplier ?? null,
  128. }));
  129. try {
  130. sessionStorage.setItem("po-detail-selection", JSON.stringify(listForDetail));
  131. } catch (e) {
  132. console.warn("sessionStorage setItem failed", e);
  133. }
  134. const selectedIdsParam = selectedPoIds.join(",");
  135. const firstPoId = selectedPoIds[0];
  136. router.push(`/po/edit?id=${firstPoId}&start=true&selectedIds=${selectedIdsParam}`);
  137. }, [selectedPoIds, filteredPo, router]);
  138. const itemColumn = useCallback((value: string | undefined) => {
  139. if (!value) {
  140. return <Grid>"N/A"</Grid>
  141. }
  142. const items = value.split(",")
  143. return items.map((item) => <Grid key={item}>{item}</Grid>)
  144. }, [])
  145. const columns = useMemo<Column<PoResult>[]>(
  146. () => [
  147. {
  148. name: "id" as keyof PoResult,
  149. label: "",
  150. renderCell: (params) => (
  151. <Checkbox
  152. checked={selectedPoIds.includes(params.id)}
  153. onChange={(e) => handleSelectPo(params.id, e.target.checked)}
  154. onClick={(e) => e.stopPropagation()}
  155. />
  156. ),
  157. width: 60,
  158. },
  159. {
  160. name: "id",
  161. label: t("Details"),
  162. onClick: onDetailClick,
  163. buttonIcon: <EditNote />,
  164. },
  165. {
  166. name: "code",
  167. label: `${t("PO No.")} ${t("&")}\n${t("Supplier")}`,
  168. renderCell: (params) => {
  169. return <>{params.code}<br/>{params.supplier}</>
  170. },
  171. },
  172. {
  173. name: "orderDate",
  174. label: `${t("Order Date")} ${t("&")}\n${t("ETA")}`,
  175. renderCell: (params) => {
  176. // return (
  177. // dayjs(params.estimatedArrivalDate)
  178. // .add(-1, "month")
  179. // .format(OUTPUT_DATE_FORMAT)
  180. // );
  181. return <>{arrayToDateString(params.orderDate)}<br/>{arrayToDateString(params.estimatedArrivalDate)}</>
  182. },
  183. },
  184. // {
  185. // name: "itemDetail",
  186. // label: t("Item Detail"),
  187. // renderCell: (params) => {
  188. // if (!params.itemDetail) {
  189. // return "N/A"
  190. // }
  191. // const items = params.itemDetail.split(",")
  192. // return items.map((item) => <Grid key={item}>{item}</Grid>)
  193. // },
  194. // },
  195. {
  196. name: "itemCode",
  197. label: t("Item Code"),
  198. renderCell: (params) => {
  199. return itemColumn(params.itemCode);
  200. },
  201. },
  202. {
  203. name: "itemName",
  204. label: t("Item Name"),
  205. renderCell: (params) => {
  206. return itemColumn(params.itemName);
  207. },
  208. },
  209. {
  210. name: "itemQty",
  211. label: t("Item Qty"),
  212. renderCell: (params) => {
  213. return itemColumn(params.itemQty);
  214. },
  215. },
  216. {
  217. name: "itemSumAcceptedQty",
  218. label: t("Item Accepted Qty"),
  219. renderCell: (params) => {
  220. return itemColumn(params.itemSumAcceptedQty);
  221. },
  222. },
  223. {
  224. name: "itemUom",
  225. label: t("Item Purchase UoM"),
  226. renderCell: (params) => {
  227. return itemColumn(params.itemUom);
  228. },
  229. },
  230. {
  231. name: "status",
  232. label: t("Status"),
  233. renderCell: (params) => {
  234. return t(`${params.status.toLowerCase()}`);
  235. },
  236. },
  237. {
  238. name: "escalated",
  239. label: t("Escalated"),
  240. renderCell: (params) => {
  241. // console.log(params.escalated);
  242. return params.escalated ? (
  243. <NotificationIcon color="warning" />
  244. ) : undefined;
  245. },
  246. },
  247. ],
  248. [selectedPoIds, handleSelectPo, onDetailClick, t], // only keep necessary dependencies
  249. );
  250. const onReset = useCallback(() => {
  251. setFilteredPo(po);
  252. }, [po]);
  253. const [isOpenScanner, setOpenScanner] = useState(false);
  254. const [autoSyncStatus, setAutoSyncStatus] = useState<string | null>(null);
  255. const [isM18LookupLoading, setIsM18LookupLoading] = useState(false);
  256. const autoSyncInProgressRef = React.useRef(false);
  257. const onOpenScanner = useCallback(() => {
  258. setOpenScanner(true);
  259. }, []);
  260. const onCloseScanner = useCallback(() => {
  261. setOpenScanner(false);
  262. }, []);
  263. const newPageFetch = useCallback(
  264. async (
  265. pagingController: Record<string, number>,
  266. filterArgs: Record<string, number>,
  267. ) => {
  268. console.log(pagingController);
  269. console.log(filterArgs);
  270. const params = {
  271. ...pagingController,
  272. ...filterArgs,
  273. };
  274. setAutoSyncStatus(null);
  275. const cleanedQuery: Record<string, string> = {};
  276. Object.entries(params).forEach(([k, v]) => {
  277. if (v === undefined || v === null) return;
  278. if (typeof v === "string" && (v as string).trim() === "") return;
  279. cleanedQuery[k] = String(v);
  280. });
  281. const baseListResp = await clientAuthFetch(
  282. `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`,
  283. { method: "GET" },
  284. );
  285. if (!baseListResp.ok) {
  286. throw new Error(`PO list fetch failed: ${baseListResp.status}`);
  287. }
  288. const res = await baseListResp.json();
  289. if (!res) return;
  290. if (res.records && res.records.length > 0) {
  291. setFilteredPo(res.records);
  292. setTotalCount(res.total);
  293. return;
  294. }
  295. const searchedCodeRaw = (filterArgs as any)?.code;
  296. const searchedCode =
  297. typeof searchedCodeRaw === "string" ? searchedCodeRaw.trim() : "";
  298. const shouldAutoSyncFromM18 =
  299. searchedCode.length > 14 &&
  300. (searchedCode.startsWith("PP") || searchedCode.startsWith("PF"));
  301. if (!shouldAutoSyncFromM18 || autoSyncInProgressRef.current) {
  302. setFilteredPo(res.records);
  303. setTotalCount(res.total);
  304. return;
  305. }
  306. try {
  307. autoSyncInProgressRef.current = true;
  308. setIsM18LookupLoading(true);
  309. setAutoSyncStatus("正在從M18找尋PO...");
  310. const syncResp = await clientAuthFetch(
  311. `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(
  312. searchedCode,
  313. )}`,
  314. { method: "GET" },
  315. );
  316. if (!syncResp.ok) {
  317. throw new Error(`M18 sync failed: ${syncResp.status}`);
  318. }
  319. let syncJson: any = null;
  320. try {
  321. syncJson = await syncResp.json();
  322. } catch {
  323. // Some endpoints may respond with plain text
  324. const txt = await syncResp.text();
  325. syncJson = { raw: txt };
  326. }
  327. const syncOk = Boolean(syncJson?.totalSuccess && syncJson.totalSuccess > 0);
  328. if (syncOk) {
  329. setAutoSyncStatus("成功找到PO");
  330. const listResp = await clientAuthFetch(
  331. `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(
  332. cleanedQuery,
  333. ).toString()}`,
  334. { method: "GET" },
  335. );
  336. if (listResp.ok) {
  337. const listJson = await listResp.json();
  338. setFilteredPo(listJson.records ?? []);
  339. setTotalCount(listJson.total ?? 0);
  340. setAutoSyncStatus("成功找到PO");
  341. return;
  342. }
  343. setAutoSyncStatus("找不到PO");
  344. } else {
  345. setAutoSyncStatus("找不到PO");
  346. }
  347. // Ensure UI updates even if sync didn't change results
  348. setFilteredPo(res.records);
  349. setTotalCount(res.total ?? 0);
  350. } catch (e) {
  351. console.error("Auto sync error:", e);
  352. setAutoSyncStatus("找不到PO");
  353. setFilteredPo(res.records);
  354. setTotalCount(res.total ?? 0);
  355. } finally {
  356. setIsM18LookupLoading(false);
  357. autoSyncInProgressRef.current = false;
  358. }
  359. },
  360. [],
  361. );
  362. useEffect(() => {
  363. console.log(filteredPo)
  364. }, [filteredPo])
  365. useEffect(() => {
  366. newPageFetch(pagingController, filterArgs);
  367. }, [newPageFetch, pagingController, filterArgs]);
  368. // when filteredPo changes, update select all state
  369. useEffect(() => {
  370. if (filteredPo.length > 0 && selectedPoIds.length === filteredPo.length) {
  371. setSelectAll(true);
  372. } else {
  373. setSelectAll(false);
  374. }
  375. }, [filteredPo, selectedPoIds]);
  376. return (
  377. <>
  378. <Grid container>
  379. <Grid item xs={8}>
  380. <Typography variant="h4" marginInlineEnd={2}>
  381. {t("Purchase Receipt")}
  382. </Typography>
  383. </Grid>
  384. <Grid item xs={4} display="flex" justifyContent="end" alignItems="end">
  385. <QrModal
  386. open={isOpenScanner}
  387. onClose={onCloseScanner}
  388. warehouse={warehouse}
  389. />
  390. <Button onClick={onOpenScanner}>{t("bind")}</Button>
  391. </Grid>
  392. </Grid>
  393. <>
  394. <SearchBox
  395. criteria={searchCriteria}
  396. disabled={isM18LookupLoading}
  397. onSearch={(query) => {
  398. if (isM18LookupLoading) return;
  399. console.log(query);
  400. const code = typeof query.code === "string" ? query.code.trim() : "";
  401. if (code) {
  402. // When PO code is provided, ignore other search criteria (especially date ranges).
  403. setFilterArgs({ code });
  404. } else {
  405. setFilterArgs({
  406. code: query.code,
  407. supplier: query.supplier,
  408. status: query.status === "All" ? "" : query.status,
  409. escalated:
  410. query.escalated === "All"
  411. ? undefined
  412. : query.escalated === t("Escalated"),
  413. estimatedArrivalDate: query.estimatedArrivalDate === "Invalid Date" ? "" : query.estimatedArrivalDate,
  414. estimatedArrivalDateTo: query.estimatedArrivalDateTo === "Invalid Date" ? "" : query.estimatedArrivalDateTo,
  415. orderDate: query.orderDate === "Invalid Date" ? "" : query.orderDate,
  416. orderDateTo: query.orderDateTo === "Invalid Date" ? "" : query.orderDateTo,
  417. });
  418. }
  419. setSelectedPoIds([]); // reset selected po ids
  420. setSelectAll(false); // reset select all
  421. }}
  422. onReset={onReset}
  423. />
  424. {autoSyncStatus ? (
  425. <Typography
  426. variant="body2"
  427. color={isM18LookupLoading ? "warning.main" : "text.secondary"}
  428. sx={{ mb: 1 }}
  429. >
  430. {autoSyncStatus}
  431. </Typography>
  432. ) : null}
  433. <SearchResults<PoResult>
  434. items={filteredPo}
  435. columns={columns}
  436. pagingController={pagingController}
  437. setPagingController={setPagingController}
  438. totalCount={totalCount}
  439. isAutoPaging={false}
  440. />
  441. {/* add select all and view selected button */}
  442. <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
  443. <Button
  444. variant="outlined"
  445. onClick={() => handleSelectAll(!selectAll)}
  446. startIcon={<Checkbox checked={selectAll} />}
  447. >
  448. {t("Select All")} ({selectedPoIds.length} / {filteredPo.length})
  449. </Button>
  450. <Button
  451. variant="contained"
  452. onClick={handleGoToPoDetail}
  453. disabled={selectedPoIds.length === 0}
  454. color="primary"
  455. >
  456. {t("View Selected")} ({selectedPoIds.length})
  457. </Button>
  458. </Box>
  459. <Backdrop
  460. open={isM18LookupLoading}
  461. sx={{ color: "#fff", zIndex: (theme) => theme.zIndex.modal + 1, flexDirection: "column", gap: 1 }}
  462. >
  463. <CircularProgress color="inherit" />
  464. <Typography variant="body1">正在從M18找尋PO...</Typography>
  465. </Backdrop>
  466. </>
  467. </>
  468. );
  469. };
  470. export default PoSearch;