FPSMS-frontend
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

PoSearch.tsx 16 KiB

7ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
3ヶ月前
4ヶ月前
9ヶ月前
9ヶ月前
6ヶ月前
7ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
4ヶ月前
7ヶ月前
8ヶ月前
7ヶ月前
7ヶ月前
7ヶ月前
9ヶ月前
7ヶ月前
9ヶ月前
7ヶ月前
9ヶ月前
7ヶ月前
7ヶ月前
7ヶ月前
7ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
7ヶ月前
9ヶ月前
9ヶ月前
7ヶ月前
4ヶ月前
9ヶ月前
3ヶ月前
9ヶ月前
9ヶ月前
7ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
9ヶ月前
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  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;