|
- "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<InventoryLotLineResult[]>([]);
- const [totalCount, setTotalCount] = useState(0);
- const [paging, setPaging] = useState({ pageNum: 1, pageSize: 20 });
- const [filterArgs, setFilterArgs] = useState<SearchQuery>({
- itemCode: "",
- itemName: "",
- itemType: "All",
- lotNo: "",
- });
- const [drafts, setDrafts] = useState<Record<number, RowDraft>>({});
- const [itemTypeOptions, setItemTypeOptions] = useState<string[]>([]);
- const [loading, setLoading] = useState(false);
- const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set());
- const rowSubmitInFlightRef = useRef<Set<number>>(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<SearchParamNames>[] = 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<number, RowDraft> = 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<SearchParamNames, string>) => {
- 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<RowDraft>) => {
- setDrafts((prev) => {
- const current = prev[lineId] ?? {
- status: "available",
- badQty: "",
- remarks: "",
- };
- return {
- ...prev,
- [lineId]: { ...current, ...patch },
- };
- });
- }, []);
-
- return (
- <Box>
- <StockIssueSearchPanel
- fields={searchFields}
- onSearch={handleSearch}
- disabled={loading}
- />
-
- <Card elevation={0} sx={{ mt: 2 }}>
- <CardContent sx={{ p: 0 }}>
- <Typography variant="overline" sx={{ px: 2, pt: 2, display: "block" }}>
- {t("Bad Item Handle")}
- </Typography>
- <TableContainer component={Paper} elevation={0}>
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell>{t("Code")}</TableCell>
- <TableCell>{t("Name")}</TableCell>
- <TableCell>{t("Lot No.")}</TableCell>
- <TableCell align="right">{t("Available Qty")}</TableCell>
- <TableCell>{t("Stock UoM")}</TableCell>
- <TableCell>{t("Expiry Date")}</TableCell>
- <TableCell>{t("Warehouse")}</TableCell>
- <TableCell sx={{ minWidth: 140 }}>{t("Status")}</TableCell>
- <TableCell sx={{ minWidth: 100 }}>{t("Defective Qty")}</TableCell>
- <TableCell sx={{ minWidth: 120 }}>{t("Remarks")}</TableCell>
- <TableCell align="center" sx={{ minWidth: 100 }}>
- {t("Action")}
- </TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {!hasSearchedRef.current ? (
- <TableRow>
- <TableCell colSpan={11} align="center">
- {t("Search to load lot lines")}
- </TableCell>
- </TableRow>
- ) : loading ? (
- <TableRow>
- <TableCell colSpan={11} align="center">
- {t("Loading")}
- </TableCell>
- </TableRow>
- ) : rows.length === 0 ? (
- <TableRow>
- <TableCell colSpan={11} align="center">
- {t("No record found")}
- </TableCell>
- </TableRow>
- ) : (
- 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 (
- <TableRow key={line.id} hover>
- <TableCell>{line.item?.code ?? "—"}</TableCell>
- <TableCell>{line.item?.name ?? "—"}</TableCell>
- <TableCell>{line.lotNo ?? "—"}</TableCell>
- <TableCell align="right">{maxQty}</TableCell>
- <TableCell>{line.uom ?? "—"}</TableCell>
- <TableCell>
- {arrayToDateString(line.expiryDate)}
- </TableCell>
- <TableCell>{line.warehouse?.code ?? "—"}</TableCell>
- <TableCell>
- <FormControl
- size="small"
- fullWidth
- disabled={isSubmitting}
- >
- <InputLabel id={`status-${line.id}`}>
- {t("Status")}
- </InputLabel>
- <Select
- labelId={`status-${line.id}`}
- label={t("Status")}
- value={normalizeStatus(draft.status)}
- onChange={(e) =>
- updateDraft(line.id, { status: e.target.value })
- }
- >
- {LOT_STATUSES.map((s) => (
- <MenuItem key={s} value={s}>
- {formatStatusLabel(s)}
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- </TableCell>
- <TableCell>
- <TextField
- size="small"
- fullWidth
- value={draft.badQty}
- disabled={!canSubmit || maxQty <= 0}
- onChange={(e) => {
- 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),
- ),
- });
- }
- }}
- />
- </TableCell>
- <TableCell>
- <TextField
- size="small"
- fullWidth
- value={draft.remarks}
- disabled={!canSubmit}
- onChange={(e) =>
- updateDraft(line.id, { remarks: e.target.value })
- }
- />
- </TableCell>
- <TableCell align="center">
- <Button
- variant="contained"
- color="error"
- size="small"
- disabled={!canSubmit}
- onClick={() => handleRowSubmit(line)}
- >
- {isSubmitting
- ? t("Processing...")
- : t("Submit")}
- </Button>
- </TableCell>
- </TableRow>
- );
- })
- )}
- </TableBody>
- </Table>
- </TableContainer>
- <TablePagination
- component="div"
- count={totalCount}
- page={paging.pageNum - 1}
- onPageChange={(_, page) =>
- 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")}
- />
- </CardContent>
- </Card>
- </Box>
- );
- };
-
- export default BadItemHandleForm;
|