|
- "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<ExpiryItemResult[]>([]);
- const [lastFilters, setLastFilters] = useState<ExpiryItemFilter>({
- daysAhead: DEFAULT_DAYS_AHEAD,
- });
- const [hasSearched, setHasSearched] = useState(false);
- const [resultTab, setResultTab] = useState<ResultBucket>("expired");
- const [submittingIds, setSubmittingIds] = useState<Set<number>>(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<Set<number>>(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<SearchParamNames>[] = 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<Column<ExpiryItemResult>[]>(
- () => [
- { 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 = (
- <Button
- size="small"
- variant="contained"
- color="primary"
- onClick={() => handleSubmitSingle(item.id)}
- disabled={disposing || !currentUserId || !canHandle}
- >
- {disposing ? t("Disposing...") : t("Disposed")}
- </Button>
- );
- if (canHandle) return button;
- return (
- <Tooltip title={t("Not yet due; cannot dispose until the expiry date")}>
- <span>{button}</span>
- </Tooltip>
- );
- },
- },
- ],
- [t, handleSubmitSingle, submittingIds, currentUserId],
- );
-
- const handleSearch = useCallback(
- async (query: Record<SearchParamNames, string>) => {
- 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<string>) => {
- 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 (
- <Box>
- <StockIssueSearchPanel fields={searchFields} onSearch={handleSearch} />
- <Box
- sx={{
- display: "flex",
- alignItems: "center",
- justifyContent: "flex-start",
- gap: 1.5,
- mb: 2,
- flexWrap: "wrap",
- }}
- >
- <Tabs
- value={resultTab}
- onChange={handleResultTabChange}
- sx={{
- minHeight: 48,
- "& .MuiTab-root": { minHeight: 48, minWidth: 0, px: 2 },
- }}
- >
- <Tab
- value="expired"
- label={`${t("Already expired")} (${itemsByBucket.expired.length})`}
- />
- <Tab
- value="today"
- label={`${t("Expires today")} (${itemsByBucket.today.length})`}
- />
- <Tab
- value="upcoming"
- label={`${t("Expires within X days")} (${itemsByBucket.upcoming.length})`}
- />
- </Tabs>
- <Button
- variant="outlined"
- startIcon={<FileDownload />}
- onClick={() => handleExportExcel("all")}
- disabled={!hasSearched || exporting != null}
- >
- {exporting === "all" ? t("Exporting...") : t("Export all in tab")}
- </Button>
- </Box>
- <Box
- sx={{
- display: "flex",
- alignItems: "center",
- gap: 1,
- mb: 1,
- flexWrap: "wrap",
- }}
- >
- {resultTab === "upcoming" && (
- <FormControl size="small" sx={{ minWidth: 120 }}>
- <InputLabel id="expiry-days-ahead-label">{t("Days ahead")}</InputLabel>
- <Select
- labelId="expiry-days-ahead-label"
- label={t("Days ahead")}
- value={String(parseDaysAhead(daysAheadDraft))}
- onChange={handleDaysAheadChange}
- >
- {DAYS_AHEAD_OPTIONS.map((days) => (
- <MenuItem key={days} value={String(days)}>
- {days}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- )}
- <Box sx={{ display: "flex", gap: 1, ml: "auto" }}>
- <Button
- variant="outlined"
- startIcon={<FileDownload />}
- onClick={() => handleExportExcel("filtered")}
- disabled={!hasSearched || exporting != null || tabItems.length === 0}
- >
- {exporting === "filtered" ? t("Exporting...") : t("Export Excel")}
- </Button>
- <Button
- variant="contained"
- color="primary"
- onClick={() => setBatchConfirmOpen(true)}
- disabled={
- batchSubmitting || !currentUserId || handleableIds.length === 0
- }
- >
- {batchSubmitting
- ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}`
- : t("Batch Disposed All")}
- </Button>
- </Box>
- </Box>
- <SearchResults<ExpiryItemResult>
- items={tabItems}
- columns={expiryColumns}
- pagingController={paging}
- setPagingController={setPaging}
- totalCount={tabItems.length}
- />
- <Dialog
- open={batchConfirmOpen}
- onClose={() => {
- if (!batchSubmitting) setBatchConfirmOpen(false);
- }}
- fullWidth
- maxWidth="xs"
- >
- <DialogTitle>{t("Confirm batch dispose")}</DialogTitle>
- <DialogContent>
- <Typography>
- {t("Confirm batch dispose message", { count: handleableIds.length })}
- </Typography>
- </DialogContent>
- <DialogActions>
- <Button
- onClick={() => setBatchConfirmOpen(false)}
- disabled={batchSubmitting}
- >
- {t("Cancel")}
- </Button>
- <Button
- variant="contained"
- color="primary"
- disabled={batchSubmitting || handleableIds.length === 0}
- onClick={async () => {
- setBatchConfirmOpen(false);
- await handleSubmitAll();
- }}
- >
- {tCommon("Confirm")}
- </Button>
- </DialogActions>
- </Dialog>
- </Box>
- );
- };
-
- export default ExpiryHandleTab;
|