"use client"; import { InventoryLotLineResult } from "@/app/api/inventory"; import { fetchInventoryListFresh, fetchStockIssueBadItemLotLinesFresh, updateInventoryLotLineStatus, } from "@/app/api/inventory/actions"; import { handleBadItem } from "@/app/api/stockIssue/actions"; import { arrayToDateString } from "@/app/utils/formatUtil"; import { msg, msgError } from "@/components/Swal/CustomAlerts"; import { SessionWithTokens } from "@/config/authConfig"; import { Box, Button, Card, CardContent, FormControl, InputLabel, MenuItem, Paper, Select, Table, TableBody, TableCell, TableContainer, TableHead, TablePagination, TableRow, TextField, Typography, } from "@mui/material"; import { uniq } from "lodash"; import { useSession } from "next-auth/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import StockIssueSearchPanel, { StockIssueSearchField, } from "./StockIssueSearchPanel"; const LOT_STATUSES = ["available", "unavailable"] as const; type SearchQuery = { itemCode: string; itemName: string; itemType: string; lotNo: string; }; type SearchParamNames = keyof SearchQuery; type RowDraft = { status: string; badQty: string; remarks: string; }; const normalizeStatus = (status: string | undefined | null): string => { const s = status?.toLowerCase() ?? ""; return LOT_STATUSES.includes(s as (typeof LOT_STATUSES)[number]) ? s : "unavailable"; }; const BadItemHandleForm: React.FC = () => { const { t } = useTranslation(["inventory", "common"]); const { data: session } = useSession() as { data: SessionWithTokens | null }; const currentUserId = session?.id ? parseInt(session.id) : undefined; const [rows, setRows] = useState([]); const [totalCount, setTotalCount] = useState(0); const [paging, setPaging] = useState({ pageNum: 1, pageSize: 20 }); const [filterArgs, setFilterArgs] = useState({ itemCode: "", itemName: "", itemType: "All", lotNo: "", }); const [drafts, setDrafts] = useState>({}); const [itemTypeOptions, setItemTypeOptions] = useState([]); const [loading, setLoading] = useState(false); const [submittingIds, setSubmittingIds] = useState>(new Set()); const rowSubmitInFlightRef = useRef>(new Set()); const hasSearchedRef = useRef(false); const formatItemTypeLabel = useCallback( (code: string) => { const key = code?.trim(); if (!key) return code; const translated = t(key, { ns: "common", defaultValue: key }); return translated !== key ? translated : t(key, { defaultValue: key }); }, [t], ); const searchFields: StockIssueSearchField[] = useMemo( () => [ { name: "itemCode", label: t("Code"), type: "text" }, { name: "itemName", label: t("Name"), type: "text" }, { name: "lotNo", label: t("Lot No."), type: "text" }, { name: "itemType", label: t("Type"), type: "select", options: itemTypeOptions, getOptionLabel: formatItemTypeLabel, }, ], [t, itemTypeOptions, formatItemTypeLabel], ); useEffect(() => { fetchInventoryListFresh() .then((list) => { if (!list?.length) return; setItemTypeOptions( uniq( list .map((row) => row.itemType?.trim()) .filter((type): type is string => Boolean(type)), ).sort(), ); }) .catch((e) => console.error("Failed to load item types:", e)); }, []); const buildSearchParams = useCallback( (query: SearchQuery, page: { pageNum: number; pageSize: number }) => ({ itemCode: query.itemCode?.trim() || undefined, itemName: query.itemName?.trim() || undefined, lotNo: query.lotNo?.trim() || undefined, itemType: !query.itemType || query.itemType === "All" ? undefined : query.itemType, pageNum: page.pageNum - 1, pageSize: page.pageSize, }), [], ); const applyDraftsForRecords = useCallback( (records: InventoryLotLineResult[], replaceAll: boolean) => { setDrafts((prev) => { const next: Record = replaceAll ? {} : { ...prev }; records.forEach((line) => { if (next[line.id]) return; const max = Math.max(0, Math.floor(line.availableQty ?? 0)); next[line.id] = { status: normalizeStatus(line.status), badQty: max > 0 ? String(max) : "", remarks: "", }; }); return next; }); }, [], ); const fetchRows = useCallback( async ( query: SearchQuery, page: { pageNum: number; pageSize: number }, options?: { replaceDrafts?: boolean }, ) => { setLoading(true); try { const res = await fetchStockIssueBadItemLotLinesFresh( buildSearchParams(query, page), ); const records = res?.records ?? []; setRows(records); setTotalCount(res?.total ?? 0); applyDraftsForRecords(records, options?.replaceDrafts ?? false); } catch (e) { console.error(e); setRows([]); setTotalCount(0); setDrafts({}); } finally { setLoading(false); } }, [buildSearchParams, applyDraftsForRecords], ); const handleSearch = useCallback( async (query: Record) => { const q = query as SearchQuery; const hasCriterion = Boolean(q.itemCode?.trim()) || Boolean(q.itemName?.trim()) || Boolean(q.lotNo?.trim()); if (!hasCriterion) { msgError(t("Please set at least one search criterion")); return; } hasSearchedRef.current = true; setFilterArgs(q); const page = { pageNum: 1, pageSize: paging.pageSize }; setPaging(page); await fetchRows(q, page, { replaceDrafts: true }); }, [fetchRows, paging.pageSize, t], ); useEffect(() => { if (!hasSearchedRef.current) return; fetchRows(filterArgs, paging, { replaceDrafts: false }); // eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when paging changes only }, [paging.pageNum, paging.pageSize]); const formatStatusLabel = useCallback( (status: string) => { const key = status?.toLowerCase(); if (key === "available") return t("available"); if (key === "unavailable") return t("unavailable"); return status; }, [t], ); const handleRowSubmit = useCallback( async (line: InventoryLotLineResult) => { if (!currentUserId) return; if (rowSubmitInFlightRef.current.has(line.id)) return; const draft = drafts[line.id] ?? { status: normalizeStatus(line.status), badQty: "", remarks: "", }; const savedStatus = normalizeStatus(line.status); const draftStatus = normalizeStatus(draft.status); const statusChanged = draftStatus !== savedStatus; const maxQty = Math.max(0, Math.floor(line.availableQty ?? 0)); const parsed = parseInt((draft.badQty ?? "").replace(/\D/g, ""), 10); const hasBadQty = !Number.isNaN(parsed) && parsed >= 1; if (!statusChanged && !hasBadQty) { msgError(t("No changes to submit")); return; } if (hasBadQty && parsed > maxQty) { msgError(t("Quantity exceeds available quantity")); return; } rowSubmitInFlightRef.current.add(line.id); setSubmittingIds((prev) => new Set(prev).add(line.id)); try { if (statusChanged) { const statusRes = await updateInventoryLotLineStatus({ inventoryLotLineId: line.id, status: draftStatus, }); if (statusRes?.code && statusRes.code !== "SUCCESS") { throw new Error(statusRes.message ?? t("Failed to submit")); } } if (hasBadQty) { const badRes = await handleBadItem({ inventoryLotLineId: line.id, qty: parsed, remarks: draft.remarks?.trim() || undefined, handler: currentUserId, }); if (badRes?.code && badRes.code !== "SUCCESS") { throw new Error(badRes.message || t("Failed to submit")); } } msg(t("Saved successfully")); setRows((prev) => { let next = prev.map((row) => { if (row.id !== line.id) return row; let updated = { ...row, status: draftStatus }; if (hasBadQty) { updated = { ...updated, availableQty: Math.max(0, (updated.availableQty ?? 0) - parsed), }; } return updated; }); if (hasBadQty) { next = next.filter((row) => Math.floor(row.availableQty ?? 0) > 0); } return next; }); setDrafts((prev) => { const next = { ...prev }; if (hasBadQty && Math.max(0, maxQty - parsed) <= 0) { delete next[line.id]; } else if (next[line.id]) { next[line.id] = { ...next[line.id], status: draftStatus, remarks: hasBadQty ? "" : next[line.id].remarks, badQty: hasBadQty ? String(Math.max(0, maxQty - parsed)) : next[line.id].badQty, }; } return next; }); } catch (e: unknown) { msgError(e instanceof Error ? e.message : t("Failed to submit")); } finally { rowSubmitInFlightRef.current.delete(line.id); setSubmittingIds((prev) => { const next = new Set(prev); next.delete(line.id); return next; }); } }, [currentUserId, drafts, t], ); const updateDraft = useCallback((lineId: number, patch: Partial) => { setDrafts((prev) => { const current = prev[lineId] ?? { status: "available", badQty: "", remarks: "", }; return { ...prev, [lineId]: { ...current, ...patch }, }; }); }, []); return ( {t("Bad Item Handle")} {t("Code")} {t("Name")} {t("Lot No.")} {t("Available Qty")} {t("Stock UoM")} {t("Expiry Date")} {t("Warehouse")} {t("Status")} {t("Defective Qty")} {t("Remarks")} {t("Action")} {!hasSearchedRef.current ? ( {t("Search to load lot lines")} ) : loading ? ( {t("Loading")} ) : rows.length === 0 ? ( {t("No record found")} ) : ( rows.map((line) => { const maxQty = Math.max( 0, Math.floor(line.availableQty ?? 0), ); const draft = drafts[line.id] ?? { status: normalizeStatus(line.status), badQty: "", remarks: "", }; const isSubmitting = submittingIds.has(line.id); const canSubmit = Boolean(currentUserId) && !isSubmitting; return ( {line.item?.code ?? "—"} {line.item?.name ?? "—"} {line.lotNo ?? "—"} {maxQty} {line.uom ?? "—"} {arrayToDateString(line.expiryDate)} {line.warehouse?.code ?? "—"} {t("Status")} { const raw = e.target.value.replace(/\D/g, ""); if (raw === "") { updateDraft(line.id, { badQty: "" }); return; } const n = parseInt(raw, 10); if (!Number.isNaN(n)) { updateDraft(line.id, { badQty: String( Math.min(Math.max(0, n), maxQty), ), }); } }} /> updateDraft(line.id, { remarks: e.target.value }) } /> ); }) )}
setPaging((p) => ({ ...p, pageNum: page + 1 })) } rowsPerPage={paging.pageSize} onRowsPerPageChange={(e) => setPaging({ pageNum: 1, pageSize: parseInt(e.target.value, 10) }) } rowsPerPageOptions={[10, 20, 50, 100]} labelRowsPerPage={t("Rows per page")} />
); }; export default BadItemHandleForm;