"use client"; import dayjs from "dayjs"; import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; import StockIssueSearchPanel, { StockIssueSearchField, } from "./StockIssueSearchPanel"; import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import SearchResults, { Column } from "@/components/SearchResults/index"; import { SessionWithTokens } from "@/config/authConfig"; import { batchSubmitExpiryItem, ExpiryItemFilter, ExpiryItemResult, fetchExpiryItemList, submitExpiryItem, } from "@/app/api/stockIssue/actions"; import { exportExpiryItemExcel } from "@/app/api/stockIssue/client"; import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent, Tab, Tabs, Tooltip, Typography, } from "@mui/material"; import FileDownload from "@mui/icons-material/FileDownload"; import { useSession } from "next-auth/react"; type SearchQuery = { itemCode: string; itemName: string; lotNo: string; }; type SearchParamNames = keyof SearchQuery; type ResultBucket = "expired" | "today" | "upcoming"; const DEFAULT_DAYS_AHEAD = 7; const MIN_DAYS_AHEAD = 1; const MAX_DAYS_AHEAD = 14; const DAYS_AHEAD_OPTIONS = Array.from( { length: MAX_DAYS_AHEAD - MIN_DAYS_AHEAD + 1 }, (_, i) => MIN_DAYS_AHEAD + i, ); function parseDaysAhead(raw: string | number | undefined): number { const n = typeof raw === "number" ? raw : Number.parseInt(String(raw ?? "").trim(), 10); if (!Number.isFinite(n) || n < MIN_DAYS_AHEAD) return DEFAULT_DAYS_AHEAD; return Math.min(Math.floor(n), MAX_DAYS_AHEAD); } function parseExpiryDayjs(rawValue: unknown): dayjs.Dayjs | null { const raw = String(rawValue ?? "").trim(); if (!raw) return null; let d: dayjs.Dayjs; if (raw.includes(",")) { const parts = raw.split(",").map((s) => parseInt(s.trim(), 10)); const [y, m, d_] = parts; if ( parts.length >= 3 && y != null && m != null && d_ != null && !Number.isNaN(y) && !Number.isNaN(m) && !Number.isNaN(d_) ) { d = dayjs(new Date(y, m - 1, d_)); } else { d = dayjs(""); } } else if (/^\d{4}-\d{2}-\d{2}/.test(raw)) { d = dayjs(raw.slice(0, 10)); } else { let normalized = raw; if (raw.length === 7) { normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7); } else if (raw.length === 6) { normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6); } d = dayjs(normalized, "YYYYMMDD", true); } return d.isValid() ? d : null; } function getExpiryBucket( item: ExpiryItemResult, daysAhead: number, ): ResultBucket | null { const d = parseExpiryDayjs(item.expiryDate); if (!d) return null; const today = dayjs().startOf("day"); if (d.isBefore(today, "day")) return "expired"; if (d.isSame(today, "day")) return "today"; if (daysAhead > 0 && !d.isAfter(today.add(daysAhead, "day"), "day")) { return "upcoming"; } return null; } function canHandleExpiryItem(item: ExpiryItemResult): boolean { if (typeof item.canHandle === "boolean") return item.canHandle; const d = parseExpiryDayjs(item.expiryDate); return d != null && !d.isAfter(dayjs(), "day"); } /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.3 | 2026-09-08 */ const ExpiryHandleTab: React.FC = () => { const BATCH_CHUNK_SIZE = 20; const { t } = useTranslation("stockIssue"); const { t: tCommon } = useTranslation("common"); const { data: session } = useSession() as { data: SessionWithTokens | null }; const currentUserId = session?.id ? parseInt(session.id) : undefined; const [expiryItems, setExpiryItems] = useState([]); const [lastFilters, setLastFilters] = useState({ daysAhead: DEFAULT_DAYS_AHEAD, }); const [hasSearched, setHasSearched] = useState(false); const [resultTab, setResultTab] = useState("expired"); const [submittingIds, setSubmittingIds] = useState>(new Set()); const [batchSubmitting, setBatchSubmitting] = useState(false); const [batchConfirmOpen, setBatchConfirmOpen] = useState(false); const [batchProgress, setBatchProgress] = useState<{ done: number; total: number; } | null>(null); const expirySubmitInFlightRef = useRef>(new Set()); const batchSubmitInFlightRef = useRef(false); const exportInFlightRef = useRef(false); const searchInFlightRef = useRef(false); const [exporting, setExporting] = useState<"filtered" | "all" | null>(null); const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); const [daysAheadDraft, setDaysAheadDraft] = useState(String(DEFAULT_DAYS_AHEAD)); const daysAhead = lastFilters.daysAhead ?? DEFAULT_DAYS_AHEAD; const itemsByBucket = useMemo(() => { const expired: ExpiryItemResult[] = []; const today: ExpiryItemResult[] = []; const upcoming: ExpiryItemResult[] = []; for (const item of expiryItems) { const bucket = getExpiryBucket(item, daysAhead); if (bucket === "expired") expired.push(item); else if (bucket === "today") today.push(item); else if (bucket === "upcoming") upcoming.push(item); } return { expired, today, upcoming, }; }, [expiryItems, daysAhead]); const tabItems = itemsByBucket[resultTab]; const handleableIds = useMemo( () => tabItems.filter(canHandleExpiryItem).map((item) => item.id), [tabItems], ); const searchFields: StockIssueSearchField[] = useMemo( () => [ { name: "itemCode", label: t("Item Code"), type: "text" }, { name: "itemName", label: t("Item"), type: "text" }, { name: "lotNo", label: t("Lot No."), type: "text" }, ], [t], ); const handleSubmitSingle = useCallback( async (id: number) => { if (!currentUserId) { alert(t("User ID is required")); return; } const item = expiryItems.find((i) => i.id === id); if (!item) { alert(t("Item not found")); return; } if (!canHandleExpiryItem(item)) { alert(t("Not yet due; cannot dispose until the expiry date")); return; } if (expirySubmitInFlightRef.current.has(id)) return; try { expirySubmitInFlightRef.current.add(id); setSubmittingIds((prev) => new Set(prev).add(id)); await submitExpiryItem(item.id, currentUserId); setExpiryItems((prev) => prev.filter((i) => i.id !== id)); } catch (e) { console.error("submitExpiryItem failed:", e); const errMsg = e instanceof Error ? e.message : t("Unknown error"); alert(`${t("Failed to submit expiry item")}: ${errMsg}`); } finally { expirySubmitInFlightRef.current.delete(id); setSubmittingIds((prev) => { const next = new Set(prev); next.delete(id); return next; }); } }, [currentUserId, t, expiryItems], ); const handleSubmitAll = useCallback(async () => { if (!currentUserId) return; if (batchSubmitInFlightRef.current) return; const allIds = tabItems.filter(canHandleExpiryItem).map((item) => item.id); if (allIds.length === 0) return; batchSubmitInFlightRef.current = true; setBatchSubmitting(true); setBatchProgress({ done: 0, total: allIds.length }); try { for (let i = 0; i < allIds.length; i += BATCH_CHUNK_SIZE) { const chunkIds = allIds.slice(i, i + BATCH_CHUNK_SIZE); await batchSubmitExpiryItem(chunkIds, currentUserId); setExpiryItems((prev) => prev.filter((item) => !chunkIds.includes(item.id))); setBatchProgress({ done: Math.min(i + chunkIds.length, allIds.length), total: allIds.length, }); } } catch (error) { console.error("Failed to submit expiry items:", error); alert( `${t("Failed to submit")}: ${error instanceof Error ? error.message : "Unknown error"}`, ); } finally { setBatchSubmitting(false); setBatchProgress(null); batchSubmitInFlightRef.current = false; } }, [currentUserId, tabItems, t]); const expiryColumns = useMemo[]>( () => [ { name: "itemCode", label: t("Item Code") }, { name: "itemDescription", label: t("Item") }, { name: "lotNo", label: t("Lot No.") }, { name: "storeLocation", label: t("Location") }, { name: "expiryDate", label: t("Expiry Date"), renderCell: (item) => { const d = parseExpiryDayjs(item.expiryDate); return d ? d.format(OUTPUT_DATE_FORMAT) : String(item.expiryDate ?? "").trim() || "—"; }, }, { name: "remainingQty", label: t("Remaining Qty") }, { name: "uomDesc", label: t("UoM"), renderCell: (item) => item.uomDesc?.trim() || "—", }, { name: "id", label: t("Action"), renderCell: (item) => { const canHandle = canHandleExpiryItem(item); const disposing = submittingIds.has(item.id); const button = ( ); if (canHandle) return button; return ( {button} ); }, }, ], [t, handleSubmitSingle, submittingIds, currentUserId], ); const handleSearch = useCallback( async (query: Record) => { if (searchInFlightRef.current) return; const parsedDays = parseDaysAhead(daysAheadDraft); setDaysAheadDraft(String(parsedDays)); setPaging((prev) => ({ ...prev, pageNum: 1 })); const filters: ExpiryItemFilter = { itemCode: query.itemCode?.trim() || undefined, itemName: query.itemName?.trim() || undefined, lotNo: query.lotNo?.trim() || undefined, daysAhead: parsedDays, }; searchInFlightRef.current = true; try { const result = await fetchExpiryItemList(filters); setLastFilters(filters); setHasSearched(true); setExpiryItems(result); } catch (error) { console.error("Failed to search expiry items:", error); alert(t("Failed to load expiry items")); } finally { searchInFlightRef.current = false; } }, [t, daysAheadDraft], ); const applyDaysAhead = useCallback( async (nextDays: number) => { const parsedDays = parseDaysAhead(nextDays); setDaysAheadDraft(String(parsedDays)); if (parsedDays === daysAhead) return; if (!hasSearched) { setLastFilters((prev) => ({ ...prev, daysAhead: parsedDays })); return; } if (searchInFlightRef.current) return; searchInFlightRef.current = true; try { const filters: ExpiryItemFilter = { ...lastFilters, daysAhead: parsedDays, }; const result = await fetchExpiryItemList(filters); setLastFilters(filters); setExpiryItems(result); setPaging((prev) => ({ ...prev, pageNum: 1 })); } catch (error) { console.error("Failed to search expiry items:", error); alert(t("Failed to load expiry items")); } finally { searchInFlightRef.current = false; } }, [daysAhead, hasSearched, lastFilters, t], ); const handleDaysAheadChange = useCallback( (event: SelectChangeEvent) => { void applyDaysAhead(parseDaysAhead(event.target.value)); }, [applyDaysAhead], ); const handleExportExcel = useCallback( async (mode: "filtered" | "all") => { if (!hasSearched) return; if (exportInFlightRef.current) return; exportInFlightRef.current = true; setExporting(mode); try { await exportExpiryItemExcel( mode === "all" ? { daysAhead, } : { ...lastFilters, bucket: resultTab, }, ); } catch (error) { console.error("Failed to export expiry items:", error); alert(t("Failed to export Excel")); } finally { setExporting(null); exportInFlightRef.current = false; } }, [hasSearched, lastFilters, resultTab, daysAhead, t], ); const handleResultTabChange = useCallback( (_: React.SyntheticEvent, value: string) => { setResultTab(value as ResultBucket); setPaging((prev) => ({ ...prev, pageNum: 1 })); }, [], ); return ( {resultTab === "upcoming" && ( {t("Days ahead")} )} items={tabItems} columns={expiryColumns} pagingController={paging} setPagingController={setPaging} totalCount={tabItems.length} /> { if (!batchSubmitting) setBatchConfirmOpen(false); }} fullWidth maxWidth="xs" > {t("Confirm batch dispose")} {t("Confirm batch dispose message", { count: handleableIds.length })} ); }; export default ExpiryHandleTab;