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.
 
 

466 regels
14 KiB

  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;