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ů.
 
 

480 řádky
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. {
  50. label: t("Escalated"),
  51. paramName: "escalated",
  52. type: "select-labelled",
  53. options: [
  54. { label: t("Escalated"), value: "true" },
  55. { label: t("NotEscalated"), value: "false" },
  56. ],
  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, index) => <Grid key={`${index}-${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 [autoSyncStatus, setAutoSyncStatus] = useState<string | null>(null);
  254. const [isM18LookupLoading, setIsM18LookupLoading] = useState(false);
  255. const autoSyncInProgressRef = React.useRef(false);
  256. const newPageFetch = useCallback(
  257. async (
  258. pagingController: Record<string, number>,
  259. filterArgs: Record<string, number>,
  260. ) => {
  261. // console.log(pagingController);
  262. //console.log(filterArgs);
  263. const params = {
  264. ...pagingController,
  265. ...filterArgs,
  266. };
  267. setAutoSyncStatus(null);
  268. const cleanedQuery: Record<string, string> = {};
  269. Object.entries(params).forEach(([k, v]) => {
  270. if (v === undefined || v === null) return;
  271. if (typeof v === "string" && (v as string).trim() === "") return;
  272. cleanedQuery[k] = String(v);
  273. });
  274. const baseListResp = await clientAuthFetch(
  275. `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`,
  276. { method: "GET" },
  277. );
  278. if (!baseListResp.ok) {
  279. throw new Error(`PO list fetch failed: ${baseListResp.status}`);
  280. }
  281. const res = await baseListResp.json();
  282. if (!res) return;
  283. if (res.records && res.records.length > 0) {
  284. setFilteredPo(res.records);
  285. setTotalCount(res.total);
  286. return;
  287. }
  288. const searchedCodeRaw = (filterArgs as any)?.code;
  289. const searchedCode =
  290. typeof searchedCodeRaw === "string" ? searchedCodeRaw.trim() : "";
  291. const shouldAutoSyncFromM18 =
  292. searchedCode.length > 14 &&
  293. (searchedCode.startsWith("PP") || searchedCode.startsWith("PF"));
  294. if (!shouldAutoSyncFromM18 || autoSyncInProgressRef.current) {
  295. setFilteredPo(res.records);
  296. setTotalCount(res.total);
  297. return;
  298. }
  299. try {
  300. autoSyncInProgressRef.current = true;
  301. setIsM18LookupLoading(true);
  302. setAutoSyncStatus("正在從M18找尋PO...");
  303. const syncResp = await clientAuthFetch(
  304. `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(
  305. searchedCode,
  306. )}`,
  307. { method: "GET" },
  308. );
  309. if (!syncResp.ok) {
  310. throw new Error(`M18 sync failed: ${syncResp.status}`);
  311. }
  312. let syncJson: any = null;
  313. try {
  314. syncJson = await syncResp.json();
  315. } catch {
  316. // Some endpoints may respond with plain text
  317. const txt = await syncResp.text();
  318. syncJson = { raw: txt };
  319. }
  320. const syncOk = Boolean(syncJson?.totalSuccess && syncJson.totalSuccess > 0);
  321. if (syncOk) {
  322. setAutoSyncStatus("成功找到PO");
  323. const listResp = await clientAuthFetch(
  324. `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(
  325. cleanedQuery,
  326. ).toString()}`,
  327. { method: "GET" },
  328. );
  329. if (listResp.ok) {
  330. const listJson = await listResp.json();
  331. setFilteredPo(listJson.records ?? []);
  332. setTotalCount(listJson.total ?? 0);
  333. setAutoSyncStatus("成功找到PO");
  334. return;
  335. }
  336. setAutoSyncStatus("找不到PO");
  337. } else {
  338. setAutoSyncStatus("找不到PO");
  339. }
  340. // Ensure UI updates even if sync didn't change results
  341. setFilteredPo(res.records);
  342. setTotalCount(res.total ?? 0);
  343. } catch (e) {
  344. console.error("Auto sync error:", e);
  345. setAutoSyncStatus("找不到PO");
  346. setFilteredPo(res.records);
  347. setTotalCount(res.total ?? 0);
  348. } finally {
  349. setIsM18LookupLoading(false);
  350. autoSyncInProgressRef.current = false;
  351. }
  352. },
  353. [],
  354. );
  355. useEffect(() => {
  356. //console.log(filteredPo)
  357. }, [filteredPo])
  358. useEffect(() => {
  359. newPageFetch(pagingController, filterArgs);
  360. }, [newPageFetch, pagingController, filterArgs]);
  361. // when filteredPo changes, update select all state
  362. useEffect(() => {
  363. if (filteredPo.length > 0 && selectedPoIds.length === filteredPo.length) {
  364. setSelectAll(true);
  365. } else {
  366. setSelectAll(false);
  367. }
  368. }, [filteredPo, selectedPoIds]);
  369. return (
  370. <>
  371. <Typography variant="h4" marginInlineEnd={2} sx={{ mb: 2 }}>
  372. {t("Purchase Receipt")}
  373. </Typography>
  374. <>
  375. <SearchBox
  376. criteria={searchCriteria}
  377. disabled={isM18LookupLoading}
  378. onSearch={(query) => {
  379. if (isM18LookupLoading) return;
  380. //console.log(query);
  381. const code = typeof query.code === "string" ? query.code.trim() : "";
  382. if (code) {
  383. // When PO code is provided, ignore other search criteria (especially date ranges).
  384. setFilterArgs({ code });
  385. } else {
  386. setFilterArgs({
  387. code: query.code,
  388. supplier: query.supplier,
  389. status: query.status === "All" ? "" : query.status,
  390. escalated:
  391. query.escalated === "All"
  392. ? undefined
  393. : query.escalated === "true",
  394. estimatedArrivalDate: query.estimatedArrivalDate === "Invalid Date" ? "" : query.estimatedArrivalDate,
  395. estimatedArrivalDateTo: query.estimatedArrivalDateTo === "Invalid Date" ? "" : query.estimatedArrivalDateTo,
  396. orderDate: query.orderDate === "Invalid Date" ? "" : query.orderDate,
  397. orderDateTo: query.orderDateTo === "Invalid Date" ? "" : query.orderDateTo,
  398. });
  399. }
  400. setSelectedPoIds([]); // reset selected po ids
  401. setSelectAll(false); // reset select all
  402. }}
  403. onReset={onReset}
  404. />
  405. {autoSyncStatus ? (
  406. <Typography
  407. variant="body2"
  408. color={isM18LookupLoading ? "warning.main" : "text.secondary"}
  409. sx={{ mb: 1 }}
  410. >
  411. {autoSyncStatus}
  412. </Typography>
  413. ) : null}
  414. <SearchResults<PoResult>
  415. items={filteredPo}
  416. columns={columns}
  417. pagingController={pagingController}
  418. setPagingController={setPagingController}
  419. totalCount={totalCount}
  420. isAutoPaging={false}
  421. />
  422. {/* add select all and view selected button */}
  423. <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
  424. <Button
  425. variant="outlined"
  426. onClick={() => handleSelectAll(!selectAll)}
  427. startIcon={<Checkbox checked={selectAll} />}
  428. >
  429. {t("Select All")} ({selectedPoIds.length} / {filteredPo.length})
  430. </Button>
  431. <Button
  432. variant="contained"
  433. onClick={handleGoToPoDetail}
  434. disabled={selectedPoIds.length === 0}
  435. color="primary"
  436. >
  437. {t("View Selected")} ({selectedPoIds.length})
  438. </Button>
  439. </Box>
  440. <Backdrop
  441. open={isM18LookupLoading}
  442. sx={{ color: "#fff", zIndex: (theme) => theme.zIndex.modal + 1, flexDirection: "column", gap: 1 }}
  443. >
  444. <CircularProgress color="inherit" />
  445. <Typography variant="body1">正在從M18找尋PO...</Typography>
  446. </Backdrop>
  447. </>
  448. </>
  449. );
  450. };
  451. export default PoSearch;