FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

516 lines
17 KiB

  1. "use client";
  2. import { InventoryLotLineResult } from "@/app/api/inventory";
  3. import {
  4. fetchInventoryListFresh,
  5. fetchStockIssueBadItemLotLinesFresh,
  6. updateInventoryLotLineStatus,
  7. } from "@/app/api/inventory/actions";
  8. import { handleBadItem } from "@/app/api/stockIssue/actions";
  9. import { arrayToDateString } from "@/app/utils/formatUtil";
  10. import { msg, msgError } from "@/components/Swal/CustomAlerts";
  11. import { SessionWithTokens } from "@/config/authConfig";
  12. import {
  13. Box,
  14. Button,
  15. Card,
  16. CardContent,
  17. FormControl,
  18. InputLabel,
  19. MenuItem,
  20. Paper,
  21. Select,
  22. Table,
  23. TableBody,
  24. TableCell,
  25. TableContainer,
  26. TableHead,
  27. TablePagination,
  28. TableRow,
  29. TextField,
  30. Typography,
  31. } from "@mui/material";
  32. import { uniq } from "lodash";
  33. import { useSession } from "next-auth/react";
  34. import { useCallback, useEffect, useMemo, useRef, useState } from "react";
  35. import { useTranslation } from "react-i18next";
  36. import StockIssueSearchPanel, {
  37. StockIssueSearchField,
  38. } from "./StockIssueSearchPanel";
  39. const LOT_STATUSES = ["available", "unavailable"] as const;
  40. type SearchQuery = {
  41. itemCode: string;
  42. itemName: string;
  43. itemType: string;
  44. lotNo: string;
  45. };
  46. type SearchParamNames = keyof SearchQuery;
  47. type RowDraft = {
  48. status: string;
  49. badQty: string;
  50. remarks: string;
  51. };
  52. const normalizeStatus = (status: string | undefined | null): string => {
  53. const s = status?.toLowerCase() ?? "";
  54. return LOT_STATUSES.includes(s as (typeof LOT_STATUSES)[number])
  55. ? s
  56. : "unavailable";
  57. };
  58. const BadItemHandleForm: React.FC = () => {
  59. const { t } = useTranslation(["inventory", "common"]);
  60. const { data: session } = useSession() as { data: SessionWithTokens | null };
  61. const currentUserId = session?.id ? parseInt(session.id) : undefined;
  62. const [rows, setRows] = useState<InventoryLotLineResult[]>([]);
  63. const [totalCount, setTotalCount] = useState(0);
  64. const [paging, setPaging] = useState({ pageNum: 1, pageSize: 20 });
  65. const [filterArgs, setFilterArgs] = useState<SearchQuery>({
  66. itemCode: "",
  67. itemName: "",
  68. itemType: "All",
  69. lotNo: "",
  70. });
  71. const [drafts, setDrafts] = useState<Record<number, RowDraft>>({});
  72. const [itemTypeOptions, setItemTypeOptions] = useState<string[]>([]);
  73. const [loading, setLoading] = useState(false);
  74. const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set());
  75. const rowSubmitInFlightRef = useRef<Set<number>>(new Set());
  76. const hasSearchedRef = useRef(false);
  77. const formatItemTypeLabel = useCallback(
  78. (code: string) => {
  79. const key = code?.trim();
  80. if (!key) return code;
  81. const translated = t(key, { ns: "common", defaultValue: key });
  82. return translated !== key ? translated : t(key, { defaultValue: key });
  83. },
  84. [t],
  85. );
  86. const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo(
  87. () => [
  88. { name: "itemCode", label: t("Code"), type: "text" },
  89. { name: "itemName", label: t("Name"), type: "text" },
  90. { name: "lotNo", label: t("Lot No."), type: "text" },
  91. {
  92. name: "itemType",
  93. label: t("Type"),
  94. type: "select",
  95. options: itemTypeOptions,
  96. getOptionLabel: formatItemTypeLabel,
  97. },
  98. ],
  99. [t, itemTypeOptions, formatItemTypeLabel],
  100. );
  101. useEffect(() => {
  102. fetchInventoryListFresh()
  103. .then((list) => {
  104. if (!list?.length) return;
  105. setItemTypeOptions(
  106. uniq(
  107. list
  108. .map((row) => row.itemType?.trim())
  109. .filter((type): type is string => Boolean(type)),
  110. ).sort(),
  111. );
  112. })
  113. .catch((e) => console.error("Failed to load item types:", e));
  114. }, []);
  115. const buildSearchParams = useCallback(
  116. (query: SearchQuery, page: { pageNum: number; pageSize: number }) => ({
  117. itemCode: query.itemCode?.trim() || undefined,
  118. itemName: query.itemName?.trim() || undefined,
  119. lotNo: query.lotNo?.trim() || undefined,
  120. itemType:
  121. !query.itemType || query.itemType === "All"
  122. ? undefined
  123. : query.itemType,
  124. pageNum: page.pageNum - 1,
  125. pageSize: page.pageSize,
  126. }),
  127. [],
  128. );
  129. const applyDraftsForRecords = useCallback(
  130. (records: InventoryLotLineResult[], replaceAll: boolean) => {
  131. setDrafts((prev) => {
  132. const next: Record<number, RowDraft> = replaceAll ? {} : { ...prev };
  133. records.forEach((line) => {
  134. if (next[line.id]) return;
  135. const max = Math.max(0, Math.floor(line.availableQty ?? 0));
  136. next[line.id] = {
  137. status: normalizeStatus(line.status),
  138. badQty: max > 0 ? String(max) : "",
  139. remarks: "",
  140. };
  141. });
  142. return next;
  143. });
  144. },
  145. [],
  146. );
  147. const fetchRows = useCallback(
  148. async (
  149. query: SearchQuery,
  150. page: { pageNum: number; pageSize: number },
  151. options?: { replaceDrafts?: boolean },
  152. ) => {
  153. setLoading(true);
  154. try {
  155. const res = await fetchStockIssueBadItemLotLinesFresh(
  156. buildSearchParams(query, page),
  157. );
  158. const records = res?.records ?? [];
  159. setRows(records);
  160. setTotalCount(res?.total ?? 0);
  161. applyDraftsForRecords(records, options?.replaceDrafts ?? false);
  162. } catch (e) {
  163. console.error(e);
  164. setRows([]);
  165. setTotalCount(0);
  166. setDrafts({});
  167. } finally {
  168. setLoading(false);
  169. }
  170. },
  171. [buildSearchParams, applyDraftsForRecords],
  172. );
  173. const handleSearch = useCallback(
  174. async (query: Record<SearchParamNames, string>) => {
  175. const q = query as SearchQuery;
  176. const hasCriterion =
  177. Boolean(q.itemCode?.trim()) ||
  178. Boolean(q.itemName?.trim()) ||
  179. Boolean(q.lotNo?.trim());
  180. if (!hasCriterion) {
  181. msgError(t("Please set at least one search criterion"));
  182. return;
  183. }
  184. hasSearchedRef.current = true;
  185. setFilterArgs(q);
  186. const page = { pageNum: 1, pageSize: paging.pageSize };
  187. setPaging(page);
  188. await fetchRows(q, page, { replaceDrafts: true });
  189. },
  190. [fetchRows, paging.pageSize, t],
  191. );
  192. useEffect(() => {
  193. if (!hasSearchedRef.current) return;
  194. fetchRows(filterArgs, paging, { replaceDrafts: false });
  195. // eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when paging changes only
  196. }, [paging.pageNum, paging.pageSize]);
  197. const formatStatusLabel = useCallback(
  198. (status: string) => {
  199. const key = status?.toLowerCase();
  200. if (key === "available") return t("available");
  201. if (key === "unavailable") return t("unavailable");
  202. return status;
  203. },
  204. [t],
  205. );
  206. const handleRowSubmit = useCallback(
  207. async (line: InventoryLotLineResult) => {
  208. if (!currentUserId) return;
  209. if (rowSubmitInFlightRef.current.has(line.id)) return;
  210. const draft = drafts[line.id] ?? {
  211. status: normalizeStatus(line.status),
  212. badQty: "",
  213. remarks: "",
  214. };
  215. const savedStatus = normalizeStatus(line.status);
  216. const draftStatus = normalizeStatus(draft.status);
  217. const statusChanged = draftStatus !== savedStatus;
  218. const maxQty = Math.max(0, Math.floor(line.availableQty ?? 0));
  219. const parsed = parseInt((draft.badQty ?? "").replace(/\D/g, ""), 10);
  220. const hasBadQty = !Number.isNaN(parsed) && parsed >= 1;
  221. if (!statusChanged && !hasBadQty) {
  222. msgError(t("No changes to submit"));
  223. return;
  224. }
  225. if (hasBadQty && parsed > maxQty) {
  226. msgError(t("Quantity exceeds available quantity"));
  227. return;
  228. }
  229. rowSubmitInFlightRef.current.add(line.id);
  230. setSubmittingIds((prev) => new Set(prev).add(line.id));
  231. try {
  232. if (statusChanged) {
  233. const statusRes = await updateInventoryLotLineStatus({
  234. inventoryLotLineId: line.id,
  235. status: draftStatus,
  236. });
  237. if (statusRes?.code && statusRes.code !== "SUCCESS") {
  238. throw new Error(statusRes.message ?? t("Failed to submit"));
  239. }
  240. }
  241. if (hasBadQty) {
  242. const badRes = await handleBadItem({
  243. inventoryLotLineId: line.id,
  244. qty: parsed,
  245. remarks: draft.remarks?.trim() || undefined,
  246. handler: currentUserId,
  247. });
  248. if (badRes?.code && badRes.code !== "SUCCESS") {
  249. throw new Error(badRes.message || t("Failed to submit"));
  250. }
  251. }
  252. msg(t("Saved successfully"));
  253. setRows((prev) => {
  254. let next = prev.map((row) => {
  255. if (row.id !== line.id) return row;
  256. let updated = { ...row, status: draftStatus };
  257. if (hasBadQty) {
  258. updated = {
  259. ...updated,
  260. availableQty: Math.max(0, (updated.availableQty ?? 0) - parsed),
  261. };
  262. }
  263. return updated;
  264. });
  265. if (hasBadQty) {
  266. next = next.filter((row) => Math.floor(row.availableQty ?? 0) > 0);
  267. }
  268. return next;
  269. });
  270. setDrafts((prev) => {
  271. const next = { ...prev };
  272. if (hasBadQty && Math.max(0, maxQty - parsed) <= 0) {
  273. delete next[line.id];
  274. } else if (next[line.id]) {
  275. next[line.id] = {
  276. ...next[line.id],
  277. status: draftStatus,
  278. remarks: hasBadQty ? "" : next[line.id].remarks,
  279. badQty: hasBadQty
  280. ? String(Math.max(0, maxQty - parsed))
  281. : next[line.id].badQty,
  282. };
  283. }
  284. return next;
  285. });
  286. } catch (e: unknown) {
  287. msgError(e instanceof Error ? e.message : t("Failed to submit"));
  288. } finally {
  289. rowSubmitInFlightRef.current.delete(line.id);
  290. setSubmittingIds((prev) => {
  291. const next = new Set(prev);
  292. next.delete(line.id);
  293. return next;
  294. });
  295. }
  296. },
  297. [currentUserId, drafts, t],
  298. );
  299. const updateDraft = useCallback((lineId: number, patch: Partial<RowDraft>) => {
  300. setDrafts((prev) => {
  301. const current = prev[lineId] ?? {
  302. status: "available",
  303. badQty: "",
  304. remarks: "",
  305. };
  306. return {
  307. ...prev,
  308. [lineId]: { ...current, ...patch },
  309. };
  310. });
  311. }, []);
  312. return (
  313. <Box>
  314. <StockIssueSearchPanel
  315. fields={searchFields}
  316. onSearch={handleSearch}
  317. disabled={loading}
  318. />
  319. <Card elevation={0} sx={{ mt: 2 }}>
  320. <CardContent sx={{ p: 0 }}>
  321. <Typography variant="overline" sx={{ px: 2, pt: 2, display: "block" }}>
  322. {t("Bad Item Handle")}
  323. </Typography>
  324. <TableContainer component={Paper} elevation={0}>
  325. <Table size="small">
  326. <TableHead>
  327. <TableRow>
  328. <TableCell>{t("Code")}</TableCell>
  329. <TableCell>{t("Name")}</TableCell>
  330. <TableCell>{t("Lot No.")}</TableCell>
  331. <TableCell align="right">{t("Available Qty")}</TableCell>
  332. <TableCell>{t("Stock UoM")}</TableCell>
  333. <TableCell>{t("Expiry Date")}</TableCell>
  334. <TableCell>{t("Warehouse")}</TableCell>
  335. <TableCell sx={{ minWidth: 140 }}>{t("Status")}</TableCell>
  336. <TableCell sx={{ minWidth: 100 }}>{t("Defective Qty")}</TableCell>
  337. <TableCell sx={{ minWidth: 120 }}>{t("Remarks")}</TableCell>
  338. <TableCell align="center" sx={{ minWidth: 100 }}>
  339. {t("Action")}
  340. </TableCell>
  341. </TableRow>
  342. </TableHead>
  343. <TableBody>
  344. {!hasSearchedRef.current ? (
  345. <TableRow>
  346. <TableCell colSpan={11} align="center">
  347. {t("Search to load lot lines")}
  348. </TableCell>
  349. </TableRow>
  350. ) : loading ? (
  351. <TableRow>
  352. <TableCell colSpan={11} align="center">
  353. {t("Loading")}
  354. </TableCell>
  355. </TableRow>
  356. ) : rows.length === 0 ? (
  357. <TableRow>
  358. <TableCell colSpan={11} align="center">
  359. {t("No record found")}
  360. </TableCell>
  361. </TableRow>
  362. ) : (
  363. rows.map((line) => {
  364. const maxQty = Math.max(
  365. 0,
  366. Math.floor(line.availableQty ?? 0),
  367. );
  368. const draft = drafts[line.id] ?? {
  369. status: normalizeStatus(line.status),
  370. badQty: "",
  371. remarks: "",
  372. };
  373. const isSubmitting = submittingIds.has(line.id);
  374. const canSubmit =
  375. Boolean(currentUserId) && !isSubmitting;
  376. return (
  377. <TableRow key={line.id} hover>
  378. <TableCell>{line.item?.code ?? "—"}</TableCell>
  379. <TableCell>{line.item?.name ?? "—"}</TableCell>
  380. <TableCell>{line.lotNo ?? "—"}</TableCell>
  381. <TableCell align="right">{maxQty}</TableCell>
  382. <TableCell>{line.uom ?? "—"}</TableCell>
  383. <TableCell>
  384. {arrayToDateString(line.expiryDate)}
  385. </TableCell>
  386. <TableCell>{line.warehouse?.code ?? "—"}</TableCell>
  387. <TableCell>
  388. <FormControl
  389. size="small"
  390. fullWidth
  391. disabled={isSubmitting}
  392. >
  393. <InputLabel id={`status-${line.id}`}>
  394. {t("Status")}
  395. </InputLabel>
  396. <Select
  397. labelId={`status-${line.id}`}
  398. label={t("Status")}
  399. value={normalizeStatus(draft.status)}
  400. onChange={(e) =>
  401. updateDraft(line.id, { status: e.target.value })
  402. }
  403. >
  404. {LOT_STATUSES.map((s) => (
  405. <MenuItem key={s} value={s}>
  406. {formatStatusLabel(s)}
  407. </MenuItem>
  408. ))}
  409. </Select>
  410. </FormControl>
  411. </TableCell>
  412. <TableCell>
  413. <TextField
  414. size="small"
  415. fullWidth
  416. value={draft.badQty}
  417. disabled={!canSubmit || maxQty <= 0}
  418. onChange={(e) => {
  419. const raw = e.target.value.replace(/\D/g, "");
  420. if (raw === "") {
  421. updateDraft(line.id, { badQty: "" });
  422. return;
  423. }
  424. const n = parseInt(raw, 10);
  425. if (!Number.isNaN(n)) {
  426. updateDraft(line.id, {
  427. badQty: String(
  428. Math.min(Math.max(0, n), maxQty),
  429. ),
  430. });
  431. }
  432. }}
  433. />
  434. </TableCell>
  435. <TableCell>
  436. <TextField
  437. size="small"
  438. fullWidth
  439. value={draft.remarks}
  440. disabled={!canSubmit}
  441. onChange={(e) =>
  442. updateDraft(line.id, { remarks: e.target.value })
  443. }
  444. />
  445. </TableCell>
  446. <TableCell align="center">
  447. <Button
  448. variant="contained"
  449. color="error"
  450. size="small"
  451. disabled={!canSubmit}
  452. onClick={() => handleRowSubmit(line)}
  453. >
  454. {isSubmitting
  455. ? t("Processing...")
  456. : t("Submit")}
  457. </Button>
  458. </TableCell>
  459. </TableRow>
  460. );
  461. })
  462. )}
  463. </TableBody>
  464. </Table>
  465. </TableContainer>
  466. <TablePagination
  467. component="div"
  468. count={totalCount}
  469. page={paging.pageNum - 1}
  470. onPageChange={(_, page) =>
  471. setPaging((p) => ({ ...p, pageNum: page + 1 }))
  472. }
  473. rowsPerPage={paging.pageSize}
  474. onRowsPerPageChange={(e) =>
  475. setPaging({ pageNum: 1, pageSize: parseInt(e.target.value, 10) })
  476. }
  477. rowsPerPageOptions={[10, 20, 50, 100]}
  478. labelRowsPerPage={t("Rows per page")}
  479. />
  480. </CardContent>
  481. </Card>
  482. </Box>
  483. );
  484. };
  485. export default BadItemHandleForm;