FPSMS-frontend
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 

512 lignes
17 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. const today = dayjsToDateString(dayjs(), "input");
  252. setSelectedPoIds([]);
  253. setSelectAll(false);
  254. setPagingController(defaultPagingController);
  255. setFilterArgs({
  256. estimatedArrivalDate: today,
  257. estimatedArrivalDateTo: today,
  258. });
  259. }, []);
  260. const [autoSyncStatus, setAutoSyncStatus] = useState<string | null>(null);
  261. const [isM18LookupLoading, setIsM18LookupLoading] = useState(false);
  262. const autoSyncInProgressRef = React.useRef(false);
  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. try {
  282. const baseListResp = await clientAuthFetch(
  283. `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`,
  284. { method: "GET" },
  285. );
  286. if (!baseListResp.ok) {
  287. throw new Error(`PO list fetch failed: ${baseListResp.status}`);
  288. }
  289. const res = await baseListResp.json();
  290. if (!res) return;
  291. const records: PoResult[] = res.records ?? [];
  292. const searchedCodeRaw = (filterArgs as any)?.code;
  293. const searchedCode =
  294. typeof searchedCodeRaw === "string" ? searchedCodeRaw.trim() : "";
  295. const isM18PoCode =
  296. searchedCode.length > 14 &&
  297. (searchedCode.startsWith("PP") || searchedCode.startsWith("PF"));
  298. const hasLocalPending = records.some(
  299. (row) => String(row.status ?? "").toLowerCase() === "pending",
  300. );
  301. const shouldLookupMissing = records.length === 0 && isM18PoCode;
  302. const shouldRefreshPending = hasLocalPending && isM18PoCode;
  303. if (
  304. (!shouldLookupMissing && !shouldRefreshPending) ||
  305. autoSyncInProgressRef.current
  306. ) {
  307. setFilteredPo(records);
  308. setTotalCount(res.total);
  309. return;
  310. }
  311. if (shouldRefreshPending) {
  312. setFilteredPo(records);
  313. setTotalCount(res.total);
  314. }
  315. try {
  316. autoSyncInProgressRef.current = true;
  317. setIsM18LookupLoading(shouldLookupMissing);
  318. setAutoSyncStatus(
  319. shouldLookupMissing
  320. ? "正在從M18找尋PO..."
  321. : "正在檢查M18是否有更新...",
  322. );
  323. const syncResp = await clientAuthFetch(
  324. `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(
  325. searchedCode,
  326. )}&ifNewer=true`,
  327. { method: "GET" },
  328. );
  329. if (!syncResp.ok) {
  330. throw new Error(`M18 sync failed: ${syncResp.status}`);
  331. }
  332. let syncJson: any = null;
  333. try {
  334. syncJson = await syncResp.json();
  335. } catch {
  336. // Some endpoints may respond with plain text
  337. const txt = await syncResp.text();
  338. syncJson = { raw: txt };
  339. }
  340. const syncOk = Boolean(syncJson?.totalSuccess && syncJson.totalSuccess > 0);
  341. const skippedIfNewer = String(syncJson?.query ?? "").includes(
  342. "skipped (ifNewer)",
  343. );
  344. if (syncOk) {
  345. setAutoSyncStatus(
  346. shouldLookupMissing ? "成功找到PO" : "已從M18更新PO",
  347. );
  348. const listResp = await clientAuthFetch(
  349. `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(
  350. cleanedQuery,
  351. ).toString()}`,
  352. { method: "GET" },
  353. );
  354. if (listResp.ok) {
  355. const listJson = await listResp.json();
  356. setFilteredPo(listJson.records ?? []);
  357. setTotalCount(listJson.total ?? 0);
  358. setAutoSyncStatus(
  359. shouldLookupMissing ? "成功找到PO" : "已從M18更新PO",
  360. );
  361. return;
  362. }
  363. setAutoSyncStatus(shouldLookupMissing ? "找不到PO" : null);
  364. } else if (skippedIfNewer || shouldRefreshPending) {
  365. setAutoSyncStatus(null);
  366. } else {
  367. setAutoSyncStatus("找不到PO");
  368. }
  369. setFilteredPo(records);
  370. setTotalCount(res.total ?? 0);
  371. } catch (e) {
  372. console.error("Auto sync error:", e);
  373. setAutoSyncStatus(shouldLookupMissing ? "找不到PO" : null);
  374. setFilteredPo(records);
  375. setTotalCount(res.total ?? 0);
  376. } finally {
  377. setIsM18LookupLoading(false);
  378. autoSyncInProgressRef.current = false;
  379. }
  380. } catch (e) {
  381. console.error("PO list fetch error:", e);
  382. }
  383. },
  384. [],
  385. );
  386. useEffect(() => {
  387. //console.log(filteredPo)
  388. }, [filteredPo])
  389. useEffect(() => {
  390. newPageFetch(pagingController, filterArgs);
  391. }, [newPageFetch, pagingController, filterArgs]);
  392. // when filteredPo changes, update select all state
  393. useEffect(() => {
  394. if (filteredPo.length > 0 && selectedPoIds.length === filteredPo.length) {
  395. setSelectAll(true);
  396. } else {
  397. setSelectAll(false);
  398. }
  399. }, [filteredPo, selectedPoIds]);
  400. return (
  401. <>
  402. <Typography variant="h4" marginInlineEnd={2} sx={{ mb: 2 }}>
  403. {t("Purchase Receipt")}
  404. </Typography>
  405. <>
  406. <SearchBox
  407. criteria={searchCriteria}
  408. disabled={isM18LookupLoading}
  409. onSearch={(query) => {
  410. if (isM18LookupLoading) return;
  411. //console.log(query);
  412. const code = typeof query.code === "string" ? query.code.trim() : "";
  413. if (code) {
  414. // When PO code is provided, ignore other search criteria (especially date ranges).
  415. setFilterArgs({ code });
  416. } else {
  417. setFilterArgs({
  418. code: query.code,
  419. supplier: query.supplier,
  420. status: query.status === "All" ? "" : query.status,
  421. escalated:
  422. query.escalated === "All"
  423. ? undefined
  424. : query.escalated === "true",
  425. estimatedArrivalDate: query.estimatedArrivalDate === "Invalid Date" ? "" : query.estimatedArrivalDate,
  426. estimatedArrivalDateTo: query.estimatedArrivalDateTo === "Invalid Date" ? "" : query.estimatedArrivalDateTo,
  427. orderDate: query.orderDate === "Invalid Date" ? "" : query.orderDate,
  428. orderDateTo: query.orderDateTo === "Invalid Date" ? "" : query.orderDateTo,
  429. });
  430. }
  431. setSelectedPoIds([]); // reset selected po ids
  432. setSelectAll(false); // reset select all
  433. }}
  434. onReset={onReset}
  435. />
  436. {autoSyncStatus ? (
  437. <Typography
  438. variant="body2"
  439. color={isM18LookupLoading ? "warning.main" : "text.secondary"}
  440. sx={{ mb: 1 }}
  441. >
  442. {autoSyncStatus}
  443. </Typography>
  444. ) : null}
  445. <SearchResults<PoResult>
  446. items={filteredPo}
  447. columns={columns}
  448. pagingController={pagingController}
  449. setPagingController={setPagingController}
  450. totalCount={totalCount}
  451. isAutoPaging={false}
  452. />
  453. {/* add select all and view selected button */}
  454. <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
  455. <Button
  456. variant="outlined"
  457. onClick={() => handleSelectAll(!selectAll)}
  458. startIcon={<Checkbox checked={selectAll} />}
  459. >
  460. {t("Select All")} ({selectedPoIds.length} / {filteredPo.length})
  461. </Button>
  462. <Button
  463. variant="contained"
  464. onClick={handleGoToPoDetail}
  465. disabled={selectedPoIds.length === 0}
  466. color="primary"
  467. >
  468. {t("View Selected")} ({selectedPoIds.length})
  469. </Button>
  470. </Box>
  471. <Backdrop
  472. open={isM18LookupLoading}
  473. sx={{ color: "#fff", zIndex: (theme) => theme.zIndex.modal + 1, flexDirection: "column", gap: 1 }}
  474. >
  475. <CircularProgress color="inherit" />
  476. <Typography variant="body1">
  477. {autoSyncStatus || "正在從M18找尋PO..."}
  478. </Typography>
  479. </Backdrop>
  480. </>
  481. </>
  482. );
  483. };
  484. export default PoSearch;