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.
 
 

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