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

SearchPage.tsx 14 KiB

5ヶ月前
1ヶ月前
5ヶ月前
2週間前
5ヶ月前
2週間前
5ヶ月前
1ヶ月前
2週間前
1ヶ月前
1ヶ月前
2週間前
5ヶ月前
5ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
5ヶ月前
5ヶ月前
2ヶ月前
3ヶ月前
2ヶ月前
5ヶ月前
3ヶ月前
2ヶ月前
1ヶ月前
2週間前
2週間前
1ヶ月前
5ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
5ヶ月前
5ヶ月前
5ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
5ヶ月前
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. "use client";
  2. import dayjs from "dayjs";
  3. import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
  4. import SearchBox, { Criterion } from "../SearchBox";
  5. import { useCallback, useMemo, useState } from "react";
  6. import { useTranslation } from "react-i18next";
  7. import SearchResults, { Column } from "../SearchResults/index";
  8. import { SessionWithTokens } from "@/config/authConfig";
  9. import {
  10. batchSubmitBadItem,
  11. batchSubmitExpiryItem,
  12. batchSubmitMissItem,
  13. ExpiryItemResult,
  14. fetchExpiryItemList,
  15. StockIssueLists,
  16. StockIssueResult,
  17. submitBadItem,
  18. submitExpiryItem,
  19. submitMissItem,
  20. } from "@/app/api/stockIssue/actions";
  21. import { Box, Button, Tab, Tabs } from "@mui/material";
  22. import { useSession } from "next-auth/react";
  23. import SubmitIssueForm from "./SubmitIssueForm";
  24. interface Props {
  25. dataList: StockIssueLists;
  26. }
  27. type SearchQuery = {
  28. lotNo: string;
  29. itemCode: string;
  30. itemName: string;
  31. expiryDate: string;
  32. };
  33. type SearchParamNames = keyof SearchQuery;
  34. const SearchPage: React.FC<Props> = ({ dataList }) => {
  35. const BATCH_CHUNK_SIZE = 20;
  36. const { t } = useTranslation("inventory");
  37. const [tab, setTab] = useState<"miss" | "bad" | "expiry">("miss");
  38. const [search, setSearch] = useState<SearchQuery>({
  39. lotNo: "",
  40. itemCode: "",
  41. itemName: "",
  42. expiryDate: "",
  43. });
  44. const { data: session } = useSession() as { data: SessionWithTokens | null };
  45. const currentUserId = session?.id ? parseInt(session.id) : undefined;
  46. const [formOpen, setFormOpen] = useState(false);
  47. const [selectedLotId, setSelectedLotId] = useState<number | null>(null);
  48. const [selectedItemId, setSelectedItemId] = useState<number>(0);
  49. const [selectedIssueType, setSelectedIssueType] = useState<"miss" | "bad">("miss");
  50. const [missItems, setMissItems] = useState<StockIssueResult[]>(
  51. dataList.missItems,
  52. );
  53. const [badItems, setBadItems] = useState<StockIssueResult[]>(
  54. dataList.badItems,
  55. );
  56. const [expiryItems, setExpiryItems] = useState<ExpiryItemResult[]>(
  57. dataList.expiryItems,
  58. );
  59. const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
  60. const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set());
  61. const [batchSubmitting, setBatchSubmitting] = useState(false);
  62. const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
  63. const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 });
  64. const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
  65. () => {
  66. if (tab === "expiry") {
  67. return [
  68. {
  69. label: t("Item Code"),
  70. paramName: "itemCode",
  71. type: "text",
  72. },
  73. {
  74. label: t("Item"),
  75. paramName: "itemName",
  76. type: "text",
  77. },
  78. {
  79. label: t("Expiry Date"),
  80. paramName: "expiryDate",
  81. type: "date",
  82. },
  83. ];
  84. }
  85. return [
  86. {
  87. label: t("Lot No."),
  88. paramName: "lotNo",
  89. type: "text",
  90. },
  91. ];
  92. },
  93. [t, tab],
  94. );
  95. const filterBySearch = useCallback(
  96. <T extends { lotNo: string | null }>(items: T[]): T[] => {
  97. if (!search.lotNo) return items;
  98. const keyword = search.lotNo.toLowerCase();
  99. return items.filter(
  100. (i) => i.lotNo && i.lotNo.toLowerCase().includes(keyword),
  101. );
  102. },
  103. [search.lotNo],
  104. );
  105. const handleSubmitSingle = useCallback(
  106. async (id: number) => {
  107. if (!currentUserId) {
  108. alert(t("User ID is required"));
  109. return;
  110. }
  111. // Find the item to get lotId
  112. let lotId: number | null = null;
  113. let itemId = 0;
  114. if (tab === "miss") {
  115. const item = missItems.find((i) => i.id === id);
  116. if (item) {
  117. lotId = item.lotId;
  118. itemId = item.itemId;
  119. }
  120. } else if (tab === "bad") {
  121. const item = badItems.find((i) => i.id === id);
  122. if (item) {
  123. lotId = item.lotId;
  124. itemId = item.itemId;
  125. }
  126. } else if (tab === "expiry") {
  127. const item = expiryItems.find((i) => i.id === id);
  128. if (!item) {
  129. alert(t("Item not found"));
  130. return;
  131. }
  132. try {
  133. // 如果想要 loading 效果,可以这里把 id 加进 submittingIds
  134. await submitExpiryItem(item.id, currentUserId);
  135. // 成功后,从列表移除这一行,或直接 reload
  136. // setExpiryItems(prev => prev.filter(i => i.id !== id));
  137. window.location.reload();
  138. } catch (e) {
  139. console.error("submitExpiryItem failed:", e);
  140. const errMsg = e instanceof Error ? e.message : t("Unknown error");
  141. alert(`${t("Failed to submit expiry item")}: ${errMsg}`);
  142. }
  143. return; // 记得 return,避免再走到下面的 lotId/itemId 分支
  144. }
  145. if (lotId && itemId) {
  146. setSelectedLotId(lotId);
  147. setSelectedItemId(itemId);
  148. setSelectedIssueType(tab === "miss" ? "miss" : "bad");
  149. setFormOpen(true);
  150. } else {
  151. alert(t("Item not found"));
  152. }
  153. },
  154. [tab, currentUserId, t, missItems, badItems, expiryItems]
  155. );
  156. const handleFormSuccess = useCallback(() => {
  157. // Refresh the lists
  158. if (tab === "miss") {
  159. // Reload miss items - you may need to add a refresh function
  160. window.location.reload(); // Or use a proper refresh mechanism
  161. } else if (tab === "bad") {
  162. // Reload bad items
  163. window.location.reload(); // Or use a proper refresh mechanism
  164. }
  165. }, [tab]);
  166. const handleSubmitSelected = useCallback(async () => {
  167. if (!currentUserId) return;
  168. // Get all IDs from the current tab's filtered items
  169. let allIds: number[] = [];
  170. if (tab === "miss") {
  171. const items = filterBySearch(missItems);
  172. allIds = items.map((item) => item.id);
  173. } else if (tab === "bad") {
  174. const items = filterBySearch(badItems);
  175. allIds = items.map((item) => item.id);
  176. } else {
  177. const items = filterBySearch(expiryItems);
  178. allIds = items.map((item) => item.id);
  179. }
  180. if (allIds.length === 0) return;
  181. setBatchSubmitting(true);
  182. setBatchProgress({ done: 0, total: allIds.length });
  183. try {
  184. for (let i = 0; i < allIds.length; i += BATCH_CHUNK_SIZE) {
  185. const chunkIds = allIds.slice(i, i + BATCH_CHUNK_SIZE);
  186. if (tab === "miss") {
  187. await batchSubmitMissItem(chunkIds, currentUserId);
  188. setMissItems((prev) => prev.filter((item) => !chunkIds.includes(item.id)));
  189. } else if (tab === "bad") {
  190. await batchSubmitBadItem(chunkIds, currentUserId);
  191. setBadItems((prev) => prev.filter((item) => !chunkIds.includes(item.id)));
  192. } else {
  193. await batchSubmitExpiryItem(chunkIds, currentUserId);
  194. setExpiryItems((prev) => prev.filter((item) => !chunkIds.includes(item.id)));
  195. }
  196. setBatchProgress({
  197. done: Math.min(i + chunkIds.length, allIds.length),
  198. total: allIds.length,
  199. });
  200. }
  201. setSelectedIds([]);
  202. } catch (error) {
  203. console.error("Failed to submit selected items:", error);
  204. const partialDone = batchProgress?.done ?? 0;
  205. alert(
  206. `${t("Failed to submit")}: ${error instanceof Error ? error.message : "Unknown error"} (${partialDone}/${allIds.length})`
  207. );
  208. } finally {
  209. setBatchSubmitting(false);
  210. setBatchProgress(null);
  211. }
  212. }, [tab, currentUserId, missItems, badItems, expiryItems, filterBySearch, batchProgress, t]);
  213. const missColumns = useMemo<Column<StockIssueResult>[]>(
  214. () => [
  215. { name: "itemCode", label: t("Item Code") },
  216. { name: "itemDescription", label: t("Item") },
  217. { name: "lotNo", label: t("Lot No.") },
  218. { name: "storeLocation", label: t("Location") },
  219. {
  220. name: "bookQty",
  221. label: t("Book Qty"),
  222. renderCell: (item) => (
  223. <>{item.bookQty?.toFixed(2) ?? "0"} {item.uomDesc ?? ""}</>
  224. ),
  225. },
  226. { name: "issueQty", label: t("Miss Qty") },
  227. { name: "uomDesc", label: t("UoM"), renderCell: (item) => (
  228. <>{item.uomDesc ?? ""}</>
  229. ) },
  230. {
  231. name: "id",
  232. label: t("Action"),
  233. renderCell: (item) => (
  234. <Button
  235. size="small"
  236. variant="contained"
  237. color="primary"
  238. onClick={() => handleSubmitSingle(item.id)}
  239. disabled={submittingIds.has(item.id) || !currentUserId}
  240. >
  241. {submittingIds.has(item.id) ? t("Processing...") : t("Looked")}
  242. </Button>
  243. ),
  244. },
  245. ],
  246. [t, handleSubmitSingle, submittingIds, currentUserId],
  247. );
  248. const badColumns = useMemo<Column<StockIssueResult>[]>(
  249. () => [
  250. { name: "itemCode", label: t("Item Code") },
  251. { name: "itemDescription", label: t("Item") },
  252. { name: "lotNo", label: t("Lot No.") },
  253. { name: "storeLocation", label: t("Location") },
  254. { name: "issueQty", label: t("Defective Qty") },
  255. { name: "uomDesc", label: t("UoM"), renderCell: (item) => (
  256. <>{item.uomDesc ?? ""}</>
  257. ) },
  258. {
  259. name: "id",
  260. label: t("Action"),
  261. renderCell: (item) => (
  262. <Button
  263. size="small"
  264. variant="contained"
  265. color="primary"
  266. onClick={() => handleSubmitSingle(item.id)}
  267. disabled={submittingIds.has(item.id) || !currentUserId}
  268. >
  269. {submittingIds.has(item.id) ? t("Disposing...") : t("Disposed")}
  270. </Button>
  271. ),
  272. },
  273. ],
  274. [t, handleSubmitSingle, submittingIds, currentUserId],
  275. );
  276. const expiryColumns = useMemo<Column<ExpiryItemResult>[]>(
  277. () => [
  278. { name: "itemCode", label: t("Item Code") },
  279. { name: "itemDescription", label: t("Item") },
  280. { name: "lotNo", label: t("Lot No.") },
  281. { name: "storeLocation", label: t("Location") },
  282. {
  283. name: "expiryDate",
  284. label: t("Expiry Date"),
  285. renderCell: (item) => {
  286. const raw = String(item.expiryDate ?? "").trim();
  287. if (!raw) return "—";
  288. let d;
  289. if (raw.includes(",")) {
  290. const parts = raw.split(",").map((s) => parseInt(s.trim(), 10));
  291. const [y, m, d_] = parts;
  292. if (parts.length >= 3 && y != null && m != null && d_ != null && !Number.isNaN(y) && !Number.isNaN(m) && !Number.isNaN(d_)) {
  293. d = dayjs(new Date(y, m - 1, d_));
  294. } else {
  295. d = dayjs("");
  296. }
  297. } else {
  298. let normalized = raw;
  299. if (raw.length === 7) {
  300. normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7);
  301. } else if (raw.length === 6) {
  302. normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6);
  303. }
  304. d = dayjs(normalized, "YYYYMMDD", true);
  305. }
  306. return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : raw;
  307. },
  308. },
  309. { name: "remainingQty", label: t("Remaining Qty") },
  310. {
  311. name: "id",
  312. label: t("Action"),
  313. renderCell: (item) => (
  314. <Button
  315. size="small"
  316. variant="contained"
  317. color="primary"
  318. onClick={() => handleSubmitSingle(item.id)}
  319. disabled={submittingIds.has(item.id) || !currentUserId}
  320. >
  321. {submittingIds.has(item.id) ? t("Disposing...") : t("Disposed")}
  322. </Button>
  323. ),
  324. },
  325. ],
  326. [t, handleSubmitSingle, submittingIds, currentUserId],
  327. );
  328. const handleSearch = useCallback(async (query: Record<SearchParamNames, string>) => {
  329. setSearch(query);
  330. setPaging((prev) => ({ ...prev, pageNum: 1 }));
  331. if (tab !== "expiry") {
  332. return;
  333. }
  334. try {
  335. const result = await fetchExpiryItemList({
  336. itemCode: query.itemCode?.trim() || undefined,
  337. itemName: query.itemName?.trim() || undefined,
  338. expiryDate: query.expiryDate || undefined,
  339. });
  340. setExpiryItems(result);
  341. setSelectedIds([]);
  342. } catch (error) {
  343. console.error("Failed to search expiry items:", error);
  344. alert(t("Failed to load expiry items"));
  345. }
  346. }, [tab, t]);
  347. const handleTabChange = useCallback(
  348. (_: React.SyntheticEvent, value: string) => {
  349. setTab(value as "miss" | "bad" | "expiry");
  350. setSelectedIds([]);
  351. setPaging((prev) => ({ ...prev, pageNum: 1 })); // 新增:切 Tab 时回到第 1 页
  352. },
  353. [],
  354. );
  355. const renderCurrentTab = () => {
  356. if (tab === "miss") {
  357. const items = filterBySearch(missItems);
  358. return (
  359. <SearchResults<StockIssueResult>
  360. items={items}
  361. columns={missColumns}
  362. pagingController={paging}
  363. checkboxIds={selectedIds}
  364. setPagingController={setPaging}
  365. setCheckboxIds={setSelectedIds}
  366. />
  367. );
  368. }
  369. if (tab === "bad") {
  370. const items = filterBySearch(badItems);
  371. return (
  372. <SearchResults<StockIssueResult>
  373. items={items}
  374. columns={badColumns}
  375. pagingController={paging}
  376. setPagingController={setPaging}
  377. checkboxIds={selectedIds}
  378. setCheckboxIds={setSelectedIds}
  379. />
  380. );
  381. }
  382. const items = filterBySearch(expiryItems);
  383. return (
  384. <SearchResults<ExpiryItemResult>
  385. items={items}
  386. columns={expiryColumns}
  387. pagingController={paging}
  388. setPagingController={setPaging}
  389. checkboxIds={selectedIds}
  390. setCheckboxIds={setSelectedIds}
  391. />
  392. );
  393. };
  394. return (
  395. <Box>
  396. <Tabs value={tab} onChange={handleTabChange} sx={{ mb: 2 }}>
  397. <Tab value="miss" label={t("Miss Item")} />
  398. <Tab value="bad" label={t("Bad Item")} />
  399. <Tab value="expiry" label={t("Expiry Item")} />
  400. </Tabs>
  401. <SearchBox<SearchParamNames>
  402. criteria={searchCriteria}
  403. onSearch={handleSearch}
  404. />
  405. {tab === "expiry" && (
  406. <Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
  407. <Button
  408. variant="contained"
  409. color="primary"
  410. onClick={handleSubmitSelected}
  411. disabled={batchSubmitting || !currentUserId}
  412. >
  413. {batchSubmitting
  414. ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}`
  415. : t("Batch Disposed All")}
  416. </Button>
  417. </Box>
  418. )}
  419. {renderCurrentTab()}
  420. <SubmitIssueForm
  421. open={formOpen}
  422. onClose={() => setFormOpen(false)}
  423. lotId={selectedLotId}
  424. itemId={selectedItemId}
  425. issueType={selectedIssueType}
  426. currentUserId={currentUserId || 0}
  427. onSuccess={handleFormSuccess}
  428. />
  429. </Box>
  430. );
  431. };
  432. export default SearchPage;