FPSMS-frontend
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 

537 líneas
17 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. FormControl,
  27. InputLabel,
  28. MenuItem,
  29. Select,
  30. SelectChangeEvent,
  31. Tab,
  32. Tabs,
  33. Tooltip,
  34. Typography,
  35. } from "@mui/material";
  36. import FileDownload from "@mui/icons-material/FileDownload";
  37. import { useSession } from "next-auth/react";
  38. type SearchQuery = {
  39. itemCode: string;
  40. itemName: string;
  41. lotNo: string;
  42. };
  43. type SearchParamNames = keyof SearchQuery;
  44. type ResultBucket = "expired" | "today" | "upcoming";
  45. const DEFAULT_DAYS_AHEAD = 7;
  46. const MIN_DAYS_AHEAD = 1;
  47. const MAX_DAYS_AHEAD = 14;
  48. const DAYS_AHEAD_OPTIONS = Array.from(
  49. { length: MAX_DAYS_AHEAD - MIN_DAYS_AHEAD + 1 },
  50. (_, i) => MIN_DAYS_AHEAD + i,
  51. );
  52. function parseDaysAhead(raw: string | number | undefined): number {
  53. const n = typeof raw === "number" ? raw : Number.parseInt(String(raw ?? "").trim(), 10);
  54. if (!Number.isFinite(n) || n < MIN_DAYS_AHEAD) return DEFAULT_DAYS_AHEAD;
  55. return Math.min(Math.floor(n), MAX_DAYS_AHEAD);
  56. }
  57. function parseExpiryDayjs(rawValue: unknown): dayjs.Dayjs | null {
  58. const raw = String(rawValue ?? "").trim();
  59. if (!raw) return null;
  60. let d: dayjs.Dayjs;
  61. if (raw.includes(",")) {
  62. const parts = raw.split(",").map((s) => parseInt(s.trim(), 10));
  63. const [y, m, d_] = parts;
  64. if (
  65. parts.length >= 3 &&
  66. y != null &&
  67. m != null &&
  68. d_ != null &&
  69. !Number.isNaN(y) &&
  70. !Number.isNaN(m) &&
  71. !Number.isNaN(d_)
  72. ) {
  73. d = dayjs(new Date(y, m - 1, d_));
  74. } else {
  75. d = dayjs("");
  76. }
  77. } else if (/^\d{4}-\d{2}-\d{2}/.test(raw)) {
  78. d = dayjs(raw.slice(0, 10));
  79. } else {
  80. let normalized = raw;
  81. if (raw.length === 7) {
  82. normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7);
  83. } else if (raw.length === 6) {
  84. normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6);
  85. }
  86. d = dayjs(normalized, "YYYYMMDD", true);
  87. }
  88. return d.isValid() ? d : null;
  89. }
  90. function getExpiryBucket(
  91. item: ExpiryItemResult,
  92. daysAhead: number,
  93. ): ResultBucket | null {
  94. const d = parseExpiryDayjs(item.expiryDate);
  95. if (!d) return null;
  96. const today = dayjs().startOf("day");
  97. if (d.isBefore(today, "day")) return "expired";
  98. if (d.isSame(today, "day")) return "today";
  99. if (daysAhead > 0 && !d.isAfter(today.add(daysAhead, "day"), "day")) {
  100. return "upcoming";
  101. }
  102. return null;
  103. }
  104. function canHandleExpiryItem(item: ExpiryItemResult): boolean {
  105. if (typeof item.canHandle === "boolean") return item.canHandle;
  106. const d = parseExpiryDayjs(item.expiryDate);
  107. return d != null && !d.isAfter(dayjs(), "day");
  108. }
  109. /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.3 | 2026-09-08 */
  110. const ExpiryHandleTab: React.FC = () => {
  111. const BATCH_CHUNK_SIZE = 20;
  112. const { t } = useTranslation("stockIssue");
  113. const { t: tCommon } = useTranslation("common");
  114. const { data: session } = useSession() as { data: SessionWithTokens | null };
  115. const currentUserId = session?.id ? parseInt(session.id) : undefined;
  116. const [expiryItems, setExpiryItems] = useState<ExpiryItemResult[]>([]);
  117. const [lastFilters, setLastFilters] = useState<ExpiryItemFilter>({
  118. daysAhead: DEFAULT_DAYS_AHEAD,
  119. });
  120. const [hasSearched, setHasSearched] = useState(false);
  121. const [resultTab, setResultTab] = useState<ResultBucket>("expired");
  122. const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set());
  123. const [batchSubmitting, setBatchSubmitting] = useState(false);
  124. const [batchConfirmOpen, setBatchConfirmOpen] = useState(false);
  125. const [batchProgress, setBatchProgress] = useState<{
  126. done: number;
  127. total: number;
  128. } | null>(null);
  129. const expirySubmitInFlightRef = useRef<Set<number>>(new Set());
  130. const batchSubmitInFlightRef = useRef(false);
  131. const exportInFlightRef = useRef(false);
  132. const searchInFlightRef = useRef(false);
  133. const [exporting, setExporting] = useState<"filtered" | "all" | null>(null);
  134. const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 });
  135. const [daysAheadDraft, setDaysAheadDraft] = useState(String(DEFAULT_DAYS_AHEAD));
  136. const daysAhead = lastFilters.daysAhead ?? DEFAULT_DAYS_AHEAD;
  137. const itemsByBucket = useMemo(() => {
  138. const expired: ExpiryItemResult[] = [];
  139. const today: ExpiryItemResult[] = [];
  140. const upcoming: ExpiryItemResult[] = [];
  141. for (const item of expiryItems) {
  142. const bucket = getExpiryBucket(item, daysAhead);
  143. if (bucket === "expired") expired.push(item);
  144. else if (bucket === "today") today.push(item);
  145. else if (bucket === "upcoming") upcoming.push(item);
  146. }
  147. return {
  148. expired,
  149. today,
  150. upcoming,
  151. };
  152. }, [expiryItems, daysAhead]);
  153. const tabItems = itemsByBucket[resultTab];
  154. const handleableIds = useMemo(
  155. () => tabItems.filter(canHandleExpiryItem).map((item) => item.id),
  156. [tabItems],
  157. );
  158. const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo(
  159. () => [
  160. { name: "itemCode", label: t("Item Code"), type: "text" },
  161. { name: "itemName", label: t("Item"), type: "text" },
  162. { name: "lotNo", label: t("Lot No."), type: "text" },
  163. ],
  164. [t],
  165. );
  166. const handleSubmitSingle = useCallback(
  167. async (id: number) => {
  168. if (!currentUserId) {
  169. alert(t("User ID is required"));
  170. return;
  171. }
  172. const item = expiryItems.find((i) => i.id === id);
  173. if (!item) {
  174. alert(t("Item not found"));
  175. return;
  176. }
  177. if (!canHandleExpiryItem(item)) {
  178. alert(t("Not yet due; cannot dispose until the expiry date"));
  179. return;
  180. }
  181. if (expirySubmitInFlightRef.current.has(id)) return;
  182. try {
  183. expirySubmitInFlightRef.current.add(id);
  184. setSubmittingIds((prev) => new Set(prev).add(id));
  185. await submitExpiryItem(item.id, currentUserId);
  186. setExpiryItems((prev) => prev.filter((i) => i.id !== id));
  187. } catch (e) {
  188. console.error("submitExpiryItem failed:", e);
  189. const errMsg = e instanceof Error ? e.message : t("Unknown error");
  190. alert(`${t("Failed to submit expiry item")}: ${errMsg}`);
  191. } finally {
  192. expirySubmitInFlightRef.current.delete(id);
  193. setSubmittingIds((prev) => {
  194. const next = new Set(prev);
  195. next.delete(id);
  196. return next;
  197. });
  198. }
  199. },
  200. [currentUserId, t, expiryItems],
  201. );
  202. const handleSubmitAll = useCallback(async () => {
  203. if (!currentUserId) return;
  204. if (batchSubmitInFlightRef.current) return;
  205. const allIds = tabItems.filter(canHandleExpiryItem).map((item) => item.id);
  206. if (allIds.length === 0) return;
  207. batchSubmitInFlightRef.current = true;
  208. setBatchSubmitting(true);
  209. setBatchProgress({ done: 0, total: allIds.length });
  210. try {
  211. for (let i = 0; i < allIds.length; i += BATCH_CHUNK_SIZE) {
  212. const chunkIds = allIds.slice(i, i + BATCH_CHUNK_SIZE);
  213. await batchSubmitExpiryItem(chunkIds, currentUserId);
  214. setExpiryItems((prev) => prev.filter((item) => !chunkIds.includes(item.id)));
  215. setBatchProgress({
  216. done: Math.min(i + chunkIds.length, allIds.length),
  217. total: allIds.length,
  218. });
  219. }
  220. } catch (error) {
  221. console.error("Failed to submit expiry items:", error);
  222. alert(
  223. `${t("Failed to submit")}: ${error instanceof Error ? error.message : "Unknown error"}`,
  224. );
  225. } finally {
  226. setBatchSubmitting(false);
  227. setBatchProgress(null);
  228. batchSubmitInFlightRef.current = false;
  229. }
  230. }, [currentUserId, tabItems, t]);
  231. const expiryColumns = useMemo<Column<ExpiryItemResult>[]>(
  232. () => [
  233. { name: "itemCode", label: t("Item Code") },
  234. { name: "itemDescription", label: t("Item") },
  235. { name: "lotNo", label: t("Lot No.") },
  236. { name: "storeLocation", label: t("Location") },
  237. {
  238. name: "expiryDate",
  239. label: t("Expiry Date"),
  240. renderCell: (item) => {
  241. const d = parseExpiryDayjs(item.expiryDate);
  242. return d ? d.format(OUTPUT_DATE_FORMAT) : String(item.expiryDate ?? "").trim() || "—";
  243. },
  244. },
  245. { name: "remainingQty", label: t("Remaining Qty") },
  246. {
  247. name: "uomDesc",
  248. label: t("UoM"),
  249. renderCell: (item) => item.uomDesc?.trim() || "—",
  250. },
  251. {
  252. name: "id",
  253. label: t("Action"),
  254. renderCell: (item) => {
  255. const canHandle = canHandleExpiryItem(item);
  256. const disposing = submittingIds.has(item.id);
  257. const button = (
  258. <Button
  259. size="small"
  260. variant="contained"
  261. color="primary"
  262. onClick={() => handleSubmitSingle(item.id)}
  263. disabled={disposing || !currentUserId || !canHandle}
  264. >
  265. {disposing ? t("Disposing...") : t("Disposed")}
  266. </Button>
  267. );
  268. if (canHandle) return button;
  269. return (
  270. <Tooltip title={t("Not yet due; cannot dispose until the expiry date")}>
  271. <span>{button}</span>
  272. </Tooltip>
  273. );
  274. },
  275. },
  276. ],
  277. [t, handleSubmitSingle, submittingIds, currentUserId],
  278. );
  279. const handleSearch = useCallback(
  280. async (query: Record<SearchParamNames, string>) => {
  281. if (searchInFlightRef.current) return;
  282. const parsedDays = parseDaysAhead(daysAheadDraft);
  283. setDaysAheadDraft(String(parsedDays));
  284. setPaging((prev) => ({ ...prev, pageNum: 1 }));
  285. const filters: ExpiryItemFilter = {
  286. itemCode: query.itemCode?.trim() || undefined,
  287. itemName: query.itemName?.trim() || undefined,
  288. lotNo: query.lotNo?.trim() || undefined,
  289. daysAhead: parsedDays,
  290. };
  291. searchInFlightRef.current = true;
  292. try {
  293. const result = await fetchExpiryItemList(filters);
  294. setLastFilters(filters);
  295. setHasSearched(true);
  296. setExpiryItems(result);
  297. } catch (error) {
  298. console.error("Failed to search expiry items:", error);
  299. alert(t("Failed to load expiry items"));
  300. } finally {
  301. searchInFlightRef.current = false;
  302. }
  303. },
  304. [t, daysAheadDraft],
  305. );
  306. const applyDaysAhead = useCallback(
  307. async (nextDays: number) => {
  308. const parsedDays = parseDaysAhead(nextDays);
  309. setDaysAheadDraft(String(parsedDays));
  310. if (parsedDays === daysAhead) return;
  311. if (!hasSearched) {
  312. setLastFilters((prev) => ({ ...prev, daysAhead: parsedDays }));
  313. return;
  314. }
  315. if (searchInFlightRef.current) return;
  316. searchInFlightRef.current = true;
  317. try {
  318. const filters: ExpiryItemFilter = {
  319. ...lastFilters,
  320. daysAhead: parsedDays,
  321. };
  322. const result = await fetchExpiryItemList(filters);
  323. setLastFilters(filters);
  324. setExpiryItems(result);
  325. setPaging((prev) => ({ ...prev, pageNum: 1 }));
  326. } catch (error) {
  327. console.error("Failed to search expiry items:", error);
  328. alert(t("Failed to load expiry items"));
  329. } finally {
  330. searchInFlightRef.current = false;
  331. }
  332. },
  333. [daysAhead, hasSearched, lastFilters, t],
  334. );
  335. const handleDaysAheadChange = useCallback(
  336. (event: SelectChangeEvent<string>) => {
  337. void applyDaysAhead(parseDaysAhead(event.target.value));
  338. },
  339. [applyDaysAhead],
  340. );
  341. const handleExportExcel = useCallback(
  342. async (mode: "filtered" | "all") => {
  343. if (!hasSearched) return;
  344. if (exportInFlightRef.current) return;
  345. exportInFlightRef.current = true;
  346. setExporting(mode);
  347. try {
  348. await exportExpiryItemExcel(
  349. mode === "all"
  350. ? {
  351. daysAhead,
  352. }
  353. : {
  354. ...lastFilters,
  355. bucket: resultTab,
  356. },
  357. );
  358. } catch (error) {
  359. console.error("Failed to export expiry items:", error);
  360. alert(t("Failed to export Excel"));
  361. } finally {
  362. setExporting(null);
  363. exportInFlightRef.current = false;
  364. }
  365. },
  366. [hasSearched, lastFilters, resultTab, daysAhead, t],
  367. );
  368. const handleResultTabChange = useCallback(
  369. (_: React.SyntheticEvent, value: string) => {
  370. setResultTab(value as ResultBucket);
  371. setPaging((prev) => ({ ...prev, pageNum: 1 }));
  372. },
  373. [],
  374. );
  375. return (
  376. <Box>
  377. <StockIssueSearchPanel fields={searchFields} onSearch={handleSearch} />
  378. <Box
  379. sx={{
  380. display: "flex",
  381. alignItems: "center",
  382. justifyContent: "flex-start",
  383. gap: 1.5,
  384. mb: 2,
  385. flexWrap: "wrap",
  386. }}
  387. >
  388. <Tabs
  389. value={resultTab}
  390. onChange={handleResultTabChange}
  391. sx={{
  392. minHeight: 48,
  393. "& .MuiTab-root": { minHeight: 48, minWidth: 0, px: 2 },
  394. }}
  395. >
  396. <Tab
  397. value="expired"
  398. label={`${t("Already expired")} (${itemsByBucket.expired.length})`}
  399. />
  400. <Tab
  401. value="today"
  402. label={`${t("Expires today")} (${itemsByBucket.today.length})`}
  403. />
  404. <Tab
  405. value="upcoming"
  406. label={`${t("Expires within X days")} (${itemsByBucket.upcoming.length})`}
  407. />
  408. </Tabs>
  409. <Button
  410. variant="outlined"
  411. startIcon={<FileDownload />}
  412. onClick={() => handleExportExcel("all")}
  413. disabled={!hasSearched || exporting != null}
  414. >
  415. {exporting === "all" ? t("Exporting...") : t("Export all in tab")}
  416. </Button>
  417. </Box>
  418. <Box
  419. sx={{
  420. display: "flex",
  421. alignItems: "center",
  422. gap: 1,
  423. mb: 1,
  424. flexWrap: "wrap",
  425. }}
  426. >
  427. {resultTab === "upcoming" && (
  428. <FormControl size="small" sx={{ minWidth: 120 }}>
  429. <InputLabel id="expiry-days-ahead-label">{t("Days ahead")}</InputLabel>
  430. <Select
  431. labelId="expiry-days-ahead-label"
  432. label={t("Days ahead")}
  433. value={String(parseDaysAhead(daysAheadDraft))}
  434. onChange={handleDaysAheadChange}
  435. >
  436. {DAYS_AHEAD_OPTIONS.map((days) => (
  437. <MenuItem key={days} value={String(days)}>
  438. {days}
  439. </MenuItem>
  440. ))}
  441. </Select>
  442. </FormControl>
  443. )}
  444. <Box sx={{ display: "flex", gap: 1, ml: "auto" }}>
  445. <Button
  446. variant="outlined"
  447. startIcon={<FileDownload />}
  448. onClick={() => handleExportExcel("filtered")}
  449. disabled={!hasSearched || exporting != null || tabItems.length === 0}
  450. >
  451. {exporting === "filtered" ? t("Exporting...") : t("Export Excel")}
  452. </Button>
  453. <Button
  454. variant="contained"
  455. color="primary"
  456. onClick={() => setBatchConfirmOpen(true)}
  457. disabled={
  458. batchSubmitting || !currentUserId || handleableIds.length === 0
  459. }
  460. >
  461. {batchSubmitting
  462. ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}`
  463. : t("Batch Disposed All")}
  464. </Button>
  465. </Box>
  466. </Box>
  467. <SearchResults<ExpiryItemResult>
  468. items={tabItems}
  469. columns={expiryColumns}
  470. pagingController={paging}
  471. setPagingController={setPaging}
  472. totalCount={tabItems.length}
  473. />
  474. <Dialog
  475. open={batchConfirmOpen}
  476. onClose={() => {
  477. if (!batchSubmitting) setBatchConfirmOpen(false);
  478. }}
  479. fullWidth
  480. maxWidth="xs"
  481. >
  482. <DialogTitle>{t("Confirm batch dispose")}</DialogTitle>
  483. <DialogContent>
  484. <Typography>
  485. {t("Confirm batch dispose message", { count: handleableIds.length })}
  486. </Typography>
  487. </DialogContent>
  488. <DialogActions>
  489. <Button
  490. onClick={() => setBatchConfirmOpen(false)}
  491. disabled={batchSubmitting}
  492. >
  493. {t("Cancel")}
  494. </Button>
  495. <Button
  496. variant="contained"
  497. color="primary"
  498. disabled={batchSubmitting || handleableIds.length === 0}
  499. onClick={async () => {
  500. setBatchConfirmOpen(false);
  501. await handleSubmitAll();
  502. }}
  503. >
  504. {tCommon("Confirm")}
  505. </Button>
  506. </DialogActions>
  507. </Dialog>
  508. </Box>
  509. );
  510. };
  511. export default ExpiryHandleTab;