FPSMS-frontend
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 

397 рядки
13 KiB

  1. "use client";
  2. import dayjs from "dayjs";
  3. import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
  4. import StockIssueSearchPanel, {
  5. StockIssueSearchField,
  6. } from "./StockIssueSearchPanel";
  7. import { useCallback, useMemo, useRef, useState } from "react";
  8. import { useTranslation } from "react-i18next";
  9. import SearchResults, { Column } from "@/components/SearchResults/index";
  10. import { SessionWithTokens } from "@/config/authConfig";
  11. import {
  12. batchSubmitExpiryItem,
  13. ExpiryItemFilter,
  14. ExpiryItemResult,
  15. fetchExpiryItemList,
  16. submitExpiryItem,
  17. } from "@/app/api/stockIssue/actions";
  18. import { exportExpiryItemExcel } from "@/app/api/stockIssue/client";
  19. import {
  20. Box,
  21. Button,
  22. Dialog,
  23. DialogActions,
  24. DialogContent,
  25. DialogTitle,
  26. Tab,
  27. Tabs,
  28. Tooltip,
  29. Typography,
  30. } from "@mui/material";
  31. import FileDownload from "@mui/icons-material/FileDownload";
  32. import { useSession } from "next-auth/react";
  33. type SearchQuery = {
  34. itemCode: string;
  35. itemName: string;
  36. lotNo: string;
  37. };
  38. type SearchParamNames = keyof SearchQuery;
  39. type ResultBucket = "expired" | "today" | "upcoming";
  40. function parseExpiryDayjs(rawValue: unknown): dayjs.Dayjs | null {
  41. const raw = String(rawValue ?? "").trim();
  42. if (!raw) return null;
  43. let d: dayjs.Dayjs;
  44. if (raw.includes(",")) {
  45. const parts = raw.split(",").map((s) => parseInt(s.trim(), 10));
  46. const [y, m, d_] = parts;
  47. if (
  48. parts.length >= 3 &&
  49. y != null &&
  50. m != null &&
  51. d_ != null &&
  52. !Number.isNaN(y) &&
  53. !Number.isNaN(m) &&
  54. !Number.isNaN(d_)
  55. ) {
  56. d = dayjs(new Date(y, m - 1, d_));
  57. } else {
  58. d = dayjs("");
  59. }
  60. } else if (/^\d{4}-\d{2}-\d{2}/.test(raw)) {
  61. d = dayjs(raw.slice(0, 10));
  62. } else {
  63. let normalized = raw;
  64. if (raw.length === 7) {
  65. normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7);
  66. } else if (raw.length === 6) {
  67. normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6);
  68. }
  69. d = dayjs(normalized, "YYYYMMDD", true);
  70. }
  71. return d.isValid() ? d : null;
  72. }
  73. function getExpiryBucket(item: ExpiryItemResult): ResultBucket | null {
  74. const d = parseExpiryDayjs(item.expiryDate);
  75. if (!d) return null;
  76. const today = dayjs().startOf("day");
  77. if (d.isBefore(today, "day")) return "expired";
  78. if (d.isSame(today, "day")) return "today";
  79. if (!d.isAfter(today.add(7, "day"), "day")) return "upcoming";
  80. return null;
  81. }
  82. function canHandleExpiryItem(item: ExpiryItemResult): boolean {
  83. if (typeof item.canHandle === "boolean") return item.canHandle;
  84. const d = parseExpiryDayjs(item.expiryDate);
  85. return d != null && !d.isAfter(dayjs(), "day");
  86. }
  87. /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.0 | 2026-09-07 */
  88. const ExpiryHandleTab: React.FC = () => {
  89. const BATCH_CHUNK_SIZE = 20;
  90. const { t } = useTranslation("stockIssue");
  91. const { t: tCommon } = useTranslation("common");
  92. const { data: session } = useSession() as { data: SessionWithTokens | null };
  93. const currentUserId = session?.id ? parseInt(session.id) : undefined;
  94. const [expiryItems, setExpiryItems] = useState<ExpiryItemResult[]>([]);
  95. const [lastFilters, setLastFilters] = useState<ExpiryItemFilter>({});
  96. const [hasSearched, setHasSearched] = useState(false);
  97. const [resultTab, setResultTab] = useState<ResultBucket>("expired");
  98. const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set());
  99. const [batchSubmitting, setBatchSubmitting] = useState(false);
  100. const [batchConfirmOpen, setBatchConfirmOpen] = useState(false);
  101. const [batchProgress, setBatchProgress] = useState<{
  102. done: number;
  103. total: number;
  104. } | null>(null);
  105. const expirySubmitInFlightRef = useRef<Set<number>>(new Set());
  106. const batchSubmitInFlightRef = useRef(false);
  107. const exportInFlightRef = useRef(false);
  108. const [exporting, setExporting] = useState(false);
  109. const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 });
  110. const itemsByBucket = useMemo(() => {
  111. const expired: ExpiryItemResult[] = [];
  112. const today: ExpiryItemResult[] = [];
  113. const upcoming: ExpiryItemResult[] = [];
  114. for (const item of expiryItems) {
  115. const bucket = getExpiryBucket(item);
  116. if (bucket === "expired") expired.push(item);
  117. else if (bucket === "today") today.push(item);
  118. else if (bucket === "upcoming") upcoming.push(item);
  119. }
  120. return { expired, today, upcoming };
  121. }, [expiryItems]);
  122. const tabItems = itemsByBucket[resultTab];
  123. const handleableIds = useMemo(
  124. () => tabItems.filter(canHandleExpiryItem).map((item) => item.id),
  125. [tabItems],
  126. );
  127. const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo(
  128. () => [
  129. { name: "itemCode", label: t("Item Code"), type: "text" },
  130. { name: "itemName", label: t("Item"), type: "text" },
  131. { name: "lotNo", label: t("Lot No."), type: "text" },
  132. ],
  133. [t],
  134. );
  135. const handleSubmitSingle = useCallback(
  136. async (id: number) => {
  137. if (!currentUserId) {
  138. alert(t("User ID is required"));
  139. return;
  140. }
  141. const item = expiryItems.find((i) => i.id === id);
  142. if (!item) {
  143. alert(t("Item not found"));
  144. return;
  145. }
  146. if (!canHandleExpiryItem(item)) {
  147. alert(t("Not yet due; cannot dispose until the expiry date"));
  148. return;
  149. }
  150. if (expirySubmitInFlightRef.current.has(id)) return;
  151. try {
  152. expirySubmitInFlightRef.current.add(id);
  153. setSubmittingIds((prev) => new Set(prev).add(id));
  154. await submitExpiryItem(item.id, currentUserId);
  155. setExpiryItems((prev) => prev.filter((i) => i.id !== id));
  156. } catch (e) {
  157. console.error("submitExpiryItem failed:", e);
  158. const errMsg = e instanceof Error ? e.message : t("Unknown error");
  159. alert(`${t("Failed to submit expiry item")}: ${errMsg}`);
  160. } finally {
  161. expirySubmitInFlightRef.current.delete(id);
  162. setSubmittingIds((prev) => {
  163. const next = new Set(prev);
  164. next.delete(id);
  165. return next;
  166. });
  167. }
  168. },
  169. [currentUserId, t, expiryItems],
  170. );
  171. const handleSubmitAll = useCallback(async () => {
  172. if (!currentUserId) return;
  173. if (batchSubmitInFlightRef.current) return;
  174. const allIds = tabItems.filter(canHandleExpiryItem).map((item) => item.id);
  175. if (allIds.length === 0) return;
  176. batchSubmitInFlightRef.current = true;
  177. setBatchSubmitting(true);
  178. setBatchProgress({ done: 0, total: allIds.length });
  179. try {
  180. for (let i = 0; i < allIds.length; i += BATCH_CHUNK_SIZE) {
  181. const chunkIds = allIds.slice(i, i + BATCH_CHUNK_SIZE);
  182. await batchSubmitExpiryItem(chunkIds, currentUserId);
  183. setExpiryItems((prev) => prev.filter((item) => !chunkIds.includes(item.id)));
  184. setBatchProgress({
  185. done: Math.min(i + chunkIds.length, allIds.length),
  186. total: allIds.length,
  187. });
  188. }
  189. } catch (error) {
  190. console.error("Failed to submit expiry items:", error);
  191. alert(
  192. `${t("Failed to submit")}: ${error instanceof Error ? error.message : "Unknown error"}`,
  193. );
  194. } finally {
  195. setBatchSubmitting(false);
  196. setBatchProgress(null);
  197. batchSubmitInFlightRef.current = false;
  198. }
  199. }, [currentUserId, tabItems, t]);
  200. const expiryColumns = useMemo<Column<ExpiryItemResult>[]>(
  201. () => [
  202. { name: "itemCode", label: t("Item Code") },
  203. { name: "itemDescription", label: t("Item") },
  204. { name: "lotNo", label: t("Lot No.") },
  205. { name: "storeLocation", label: t("Location") },
  206. {
  207. name: "expiryDate",
  208. label: t("Expiry Date"),
  209. renderCell: (item) => {
  210. const d = parseExpiryDayjs(item.expiryDate);
  211. return d ? d.format(OUTPUT_DATE_FORMAT) : String(item.expiryDate ?? "").trim() || "—";
  212. },
  213. },
  214. { name: "remainingQty", label: t("Remaining Qty") },
  215. {
  216. name: "uomDesc",
  217. label: t("UoM"),
  218. renderCell: (item) => item.uomDesc?.trim() || "—",
  219. },
  220. {
  221. name: "id",
  222. label: t("Action"),
  223. renderCell: (item) => {
  224. const canHandle = canHandleExpiryItem(item);
  225. const disposing = submittingIds.has(item.id);
  226. const button = (
  227. <Button
  228. size="small"
  229. variant="contained"
  230. color="primary"
  231. onClick={() => handleSubmitSingle(item.id)}
  232. disabled={disposing || !currentUserId || !canHandle}
  233. >
  234. {disposing ? t("Disposing...") : t("Disposed")}
  235. </Button>
  236. );
  237. if (canHandle) return button;
  238. return (
  239. <Tooltip title={t("Not yet due; cannot dispose until the expiry date")}>
  240. <span>{button}</span>
  241. </Tooltip>
  242. );
  243. },
  244. },
  245. ],
  246. [t, handleSubmitSingle, submittingIds, currentUserId],
  247. );
  248. const handleSearch = useCallback(
  249. async (query: Record<SearchParamNames, string>) => {
  250. setPaging((prev) => ({ ...prev, pageNum: 1 }));
  251. const filters: ExpiryItemFilter = {
  252. itemCode: query.itemCode?.trim() || undefined,
  253. itemName: query.itemName?.trim() || undefined,
  254. lotNo: query.lotNo?.trim() || undefined,
  255. };
  256. try {
  257. const result = await fetchExpiryItemList(filters);
  258. setLastFilters(filters);
  259. setHasSearched(true);
  260. setExpiryItems(result);
  261. } catch (error) {
  262. console.error("Failed to search expiry items:", error);
  263. alert(t("Failed to load expiry items"));
  264. }
  265. },
  266. [t],
  267. );
  268. const handleExportExcel = useCallback(async () => {
  269. if (!hasSearched) return;
  270. if (exportInFlightRef.current) return;
  271. exportInFlightRef.current = true;
  272. setExporting(true);
  273. try {
  274. await exportExpiryItemExcel({
  275. ...lastFilters,
  276. bucket: resultTab,
  277. });
  278. } catch (error) {
  279. console.error("Failed to export expiry items:", error);
  280. alert(t("Failed to export Excel"));
  281. } finally {
  282. setExporting(false);
  283. exportInFlightRef.current = false;
  284. }
  285. }, [hasSearched, lastFilters, resultTab, t]);
  286. const handleResultTabChange = useCallback(
  287. (_: React.SyntheticEvent, value: string) => {
  288. setResultTab(value as ResultBucket);
  289. setPaging((prev) => ({ ...prev, pageNum: 1 }));
  290. },
  291. [],
  292. );
  293. return (
  294. <Box>
  295. <StockIssueSearchPanel fields={searchFields} onSearch={handleSearch} />
  296. <Tabs value={resultTab} onChange={handleResultTabChange} sx={{ mb: 2 }}>
  297. <Tab
  298. value="expired"
  299. label={`${t("Already expired")} (${itemsByBucket.expired.length})`}
  300. />
  301. <Tab
  302. value="today"
  303. label={`${t("Expires today")} (${itemsByBucket.today.length})`}
  304. />
  305. <Tab
  306. value="upcoming"
  307. label={`${t("Expires within 7 days")} (${itemsByBucket.upcoming.length})`}
  308. />
  309. </Tabs>
  310. <Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mb: 1 }}>
  311. <Button
  312. variant="outlined"
  313. startIcon={<FileDownload />}
  314. onClick={handleExportExcel}
  315. disabled={!hasSearched || exporting || tabItems.length === 0}
  316. >
  317. {exporting ? t("Exporting...") : t("Export Excel")}
  318. </Button>
  319. <Button
  320. variant="contained"
  321. color="primary"
  322. onClick={() => setBatchConfirmOpen(true)}
  323. disabled={
  324. batchSubmitting || !currentUserId || handleableIds.length === 0
  325. }
  326. >
  327. {batchSubmitting
  328. ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}`
  329. : t("Batch Disposed All")}
  330. </Button>
  331. </Box>
  332. <SearchResults<ExpiryItemResult>
  333. items={tabItems}
  334. columns={expiryColumns}
  335. pagingController={paging}
  336. setPagingController={setPaging}
  337. totalCount={tabItems.length}
  338. />
  339. <Dialog
  340. open={batchConfirmOpen}
  341. onClose={() => {
  342. if (!batchSubmitting) setBatchConfirmOpen(false);
  343. }}
  344. fullWidth
  345. maxWidth="xs"
  346. >
  347. <DialogTitle>{t("Confirm batch dispose")}</DialogTitle>
  348. <DialogContent>
  349. <Typography>
  350. {t("Confirm batch dispose message", { count: handleableIds.length })}
  351. </Typography>
  352. </DialogContent>
  353. <DialogActions>
  354. <Button
  355. onClick={() => setBatchConfirmOpen(false)}
  356. disabled={batchSubmitting}
  357. >
  358. {t("Cancel")}
  359. </Button>
  360. <Button
  361. variant="contained"
  362. color="primary"
  363. disabled={batchSubmitting || handleableIds.length === 0}
  364. onClick={async () => {
  365. setBatchConfirmOpen(false);
  366. await handleSubmitAll();
  367. }}
  368. >
  369. {tCommon("Confirm")}
  370. </Button>
  371. </DialogActions>
  372. </Dialog>
  373. </Box>
  374. );
  375. };
  376. export default ExpiryHandleTab;