FPSMS-frontend
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

1156 строки
45 KiB

  1. import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory";
  2. import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
  3. import SaveIcon from "@mui/icons-material/Save";
  4. import EditIcon from "@mui/icons-material/Edit";
  5. import RestartAltIcon from "@mui/icons-material/RestartAlt";
  6. import { useTranslation } from "react-i18next";
  7. import { Column } from "../SearchResults";
  8. import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults";
  9. import { arrayToDateString } from "@/app/utils/formatUtil";
  10. import { Box, Card, Checkbox, FormControlLabel, Grid, IconButton, Modal, TextField, Typography, Button, Chip } from "@mui/material";
  11. import useUploadContext from "../UploadProvider/useUploadContext";
  12. import { downloadFile } from "@/app/utils/commonUtil";
  13. import { fetchQrCodeByLotLineId, LotLineToQrcode } from "@/app/api/pdf/actions";
  14. import QrCodeIcon from "@mui/icons-material/QrCode";
  15. import PrintIcon from "@mui/icons-material/Print";
  16. import SwapHoriz from "@mui/icons-material/SwapHoriz";
  17. import CloseIcon from "@mui/icons-material/Close";
  18. import { Autocomplete } from "@mui/material";
  19. import { WarehouseResult } from "@/app/api/warehouse";
  20. import { fetchWarehouseListClient } from "@/app/api/warehouse/client";
  21. import { createStockTransfer } from "@/app/api/inventory/actions";
  22. import { msg, msgError } from "@/components/Swal/CustomAlerts";
  23. import { PrinterCombo } from "@/app/api/settings/printer";
  24. import { printLabelForInventoryLotLine } from "@/app/api/pdf/actions";
  25. import TuneIcon from "@mui/icons-material/Tune";
  26. import AddIcon from "@mui/icons-material/Add";
  27. import { Table, TableBody, TableCell, TableHead, TableRow } from "@mui/material";
  28. import DeleteIcon from "@mui/icons-material/Delete";
  29. import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
  30. import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
  31. import { DatePicker } from "@mui/x-date-pickers/DatePicker";
  32. import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
  33. import dayjs from "dayjs";
  34. import CheckIcon from "@mui/icons-material/Check";
  35. import { submitStockAdjustment, StockAdjustmentLineRequest, fetchLatestAdjustmentRemarks } from "@/app/api/stockAdjustment/actions";
  36. import { useSession } from "next-auth/react";
  37. import { AUTH, hasAbility } from "@/authorities";
  38. type AdjustmentEntry = InventoryLotLineResult & {
  39. adjustedQty: number;
  40. originalQty?: number;
  41. productlotNo?: string;
  42. dnNo?: string;
  43. isNew?: boolean;
  44. isOpeningInventory?: boolean;
  45. remarks?: string;
  46. uomId?: number;
  47. };
  48. interface Props {
  49. inventoryLotLines: InventoryLotLineResult[] | null;
  50. setPagingController: defaultSetPagingController;
  51. pagingController: typeof defaultPagingController;
  52. totalCount: number;
  53. inventory: InventoryResult | null;
  54. filterLotNo?: string;
  55. onStockTransferSuccess?: () => void | Promise<void>;
  56. printerCombo?: PrinterCombo[];
  57. onStockAdjustmentSuccess?: () => void | Promise<void>;
  58. }
  59. /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.9 | 2026-09-21 */
  60. const InventoryLotLineTable: React.FC<Props> = ({
  61. inventoryLotLines, pagingController, setPagingController, totalCount, inventory,
  62. filterLotNo,
  63. onStockTransferSuccess, printerCombo = [],
  64. onStockAdjustmentSuccess,
  65. }) => {
  66. const { t } = useTranslation(["inventory"]);
  67. const { data: session } = useSession();
  68. const abilities = session?.abilities ?? session?.user?.abilities ?? [];
  69. const canStockAdjust = hasAbility(abilities, AUTH.INVENTORY_ADJUST);
  70. const PRINT_PRINTER_ID_KEY = 'inventoryLotLinePrintPrinterId';
  71. const { setIsUploading } = useUploadContext();
  72. const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false);
  73. const [selectedLotLine, setSelectedLotLine] = useState<InventoryLotLineResult | null>(null);
  74. const [startLocation, setStartLocation] = useState<string>("");
  75. const [targetLocation, setTargetLocation] = useState<number | null>(null); // Store warehouse ID instead of code
  76. const [targetLocationInput, setTargetLocationInput] = useState<string>("");
  77. const [qtyToBeTransferred, setQtyToBeTransferred] = useState<string>("");
  78. const [warehouses, setWarehouses] = useState<WarehouseResult[]>([]);
  79. const [printModalOpen, setPrintModalOpen] = useState(false);
  80. const [lotLineForPrint, setLotLineForPrint] = useState<InventoryLotLineResult | null>(null);
  81. const [printPrinter, setPrintPrinter] = useState<PrinterCombo | null>(null);
  82. const [printQty, setPrintQty] = useState(1);
  83. const [stockAdjustmentModalOpen, setStockAdjustmentModalOpen] = useState(false);
  84. const [pendingRemovalLineId, setPendingRemovalLineId] = useState<number | null>(null);
  85. const [removalReasons, setRemovalReasons] = useState<Record<number, string>>({});
  86. const [addEntryModalOpen, setAddEntryModalOpen] = useState(false);
  87. const [addEntryForm, setAddEntryForm] = useState({
  88. lotNo: '',
  89. qty: 0,
  90. expiryDate: '',
  91. locationId: null as number | null,
  92. locationInput: '',
  93. productlotNo: '',
  94. dnNo: '',
  95. isOpeningInventory: false,
  96. remarks: '',
  97. });
  98. const originalAdjustmentLinesRef = useRef<AdjustmentEntry[]>([]);
  99. const adjustSaveInFlightRef = useRef(false);
  100. const loadedRemarksByLotRef = useRef<Map<string, string>>(new Map());
  101. const remarksFetchGenRef = useRef(0);
  102. const [adjustmentEntries, setAdjustmentEntries] = useState<AdjustmentEntry[]>([]);
  103. const [isAdjustSaving, setIsAdjustSaving] = useState(false);
  104. useEffect(() => {
  105. if (stockTransferModalOpen) {
  106. fetchWarehouseListClient()
  107. .then(setWarehouses)
  108. .catch(console.error);
  109. }
  110. }, [stockTransferModalOpen]);
  111. useEffect(() => {
  112. if (addEntryModalOpen) {
  113. fetchWarehouseListClient()
  114. .then(setWarehouses)
  115. .catch(console.error);
  116. }
  117. }, [addEntryModalOpen]);
  118. const availableLotLines = useMemo(() => {
  119. const base = (inventoryLotLines ?? []).filter((line) => line.status?.toLowerCase() === "available");
  120. const f = filterLotNo?.trim?.() ? filterLotNo.trim() : '';
  121. if (!f) return base;
  122. return base.filter((line) => line.lotNo === f);
  123. }, [inventoryLotLines, filterLotNo]);
  124. const originalQty = selectedLotLine?.availableQty || 0;
  125. const validatedTransferQty = useMemo(() => {
  126. const raw = (qtyToBeTransferred ?? '').replace(/\D/g, '');
  127. if (raw === '') return 0;
  128. const parsed = parseInt(raw, 10);
  129. if (Number.isNaN(parsed)) return 0;
  130. if (originalQty < 1) return 0;
  131. const minClamped = Math.max(1, parsed);
  132. return Math.min(minClamped, originalQty);
  133. }, [qtyToBeTransferred, originalQty]);
  134. const remainingQty = originalQty - validatedTransferQty;
  135. const prevAdjustmentModalOpenRef = useRef(false);
  136. useEffect(() => {
  137. const wasOpen = prevAdjustmentModalOpenRef.current;
  138. prevAdjustmentModalOpenRef.current = stockAdjustmentModalOpen;
  139. if (stockAdjustmentModalOpen && inventory) {
  140. // Only init when we transition to open (modal just opened)
  141. if (!wasOpen) {
  142. const initial = (availableLotLines ?? []).map((line) => ({
  143. ...line,
  144. adjustedQty: line.availableQty ?? 0,
  145. originalQty: line.availableQty ?? 0,
  146. remarks: '',
  147. uomId: inventory.stockUomId ?? inventory.uomId,
  148. }));
  149. setAdjustmentEntries(initial);
  150. originalAdjustmentLinesRef.current = initial;
  151. loadedRemarksByLotRef.current = new Map();
  152. const fetchGen = ++remarksFetchGenRef.current;
  153. const itemId = inventory.itemId;
  154. fetchLatestAdjustmentRemarks(itemId)
  155. .then((rows) => {
  156. if (fetchGen !== remarksFetchGenRef.current) return;
  157. const byLot = new Map<string, string>();
  158. for (const row of rows ?? []) {
  159. const lot = row.lotNo?.trim();
  160. const remarks = row.remarks?.trim();
  161. if (!lot || !remarks || byLot.has(lot)) continue;
  162. byLot.set(lot, remarks);
  163. }
  164. loadedRemarksByLotRef.current = byLot;
  165. const apply = (line: AdjustmentEntry): AdjustmentEntry => {
  166. const lot = line.lotNo?.trim();
  167. const remarks = (lot && byLot.get(lot)) || line.remarks || '';
  168. return { ...line, remarks };
  169. };
  170. setAdjustmentEntries((prev) => prev.map(apply));
  171. originalAdjustmentLinesRef.current = originalAdjustmentLinesRef.current.map(apply);
  172. })
  173. .catch(console.error);
  174. }
  175. setPendingRemovalLineId(null);
  176. setRemovalReasons({});
  177. } else if (!stockAdjustmentModalOpen) {
  178. remarksFetchGenRef.current += 1;
  179. }
  180. }, [stockAdjustmentModalOpen, inventory, availableLotLines]);
  181. const handleAdjustmentReset = useCallback(() => {
  182. setPendingRemovalLineId(null);
  183. setRemovalReasons({});
  184. setAdjustmentEntries(
  185. (availableLotLines ?? []).map((line) => {
  186. const lot = line.lotNo?.trim();
  187. return {
  188. ...line,
  189. adjustedQty: line.availableQty ?? 0,
  190. originalQty: line.availableQty ?? 0,
  191. remarks: (lot && loadedRemarksByLotRef.current.get(lot)) || '',
  192. };
  193. })
  194. );
  195. }, [availableLotLines]);
  196. const handleAdjustmentQtyChange = useCallback((lineId: number, value: number) => {
  197. setAdjustmentEntries((prev) =>
  198. prev.map((line) =>
  199. line.id === lineId ? { ...line, adjustedQty: Math.max(0, value) } : line
  200. )
  201. );
  202. }, []);
  203. const handleAdjustmentRemarksChange = useCallback((lineId: number, value: string) => {
  204. setAdjustmentEntries((prev) =>
  205. prev.map((line) =>
  206. line.id === lineId ? { ...line, remarks: value } : line
  207. )
  208. );
  209. }, []);
  210. const handleRemoveAdjustmentLine = useCallback((lineId: number) => {
  211. setAdjustmentEntries((prev) => prev.filter((line) => line.id !== lineId));
  212. }, []);
  213. const handleRemoveClick = useCallback((lineId: number) => {
  214. setPendingRemovalLineId((prev) => (prev === lineId ? null : lineId));
  215. }, []);
  216. const handleRemovalReasonChange = useCallback((lineId: number, value: string) => {
  217. setRemovalReasons((prev) => ({ ...prev, [lineId]: value }));
  218. }, []);
  219. const handleConfirmRemoval = useCallback((lineId: number) => {
  220. setAdjustmentEntries((prev) => prev.filter((line) => line.id !== lineId));
  221. setPendingRemovalLineId(null);
  222. }, []);
  223. const handleCancelRemoval = useCallback(() => {
  224. setPendingRemovalLineId(null);
  225. }, []);
  226. const hasAdjustmentChange = useMemo(() => {
  227. const original = originalAdjustmentLinesRef.current;
  228. const current = adjustmentEntries;
  229. if (original.length !== current.length) return true;
  230. const origById = new Map(original.map((line) => [line.id, { adjustedQty: line.adjustedQty ?? 0, remarks: line.remarks ?? '' }]));
  231. for (const line of current) {
  232. const o = origById.get(line.id);
  233. if (!o) return true;
  234. if (o.adjustedQty !== (line.adjustedQty ?? 0) || (o.remarks ?? '') !== (line.remarks ?? '')) return true;
  235. }
  236. return false;
  237. }, [adjustmentEntries]);
  238. const toApiLine = useCallback((line: AdjustmentEntry, itemCode: string): StockAdjustmentLineRequest => {
  239. const [y, m, d] = Array.isArray(line.expiryDate) ? line.expiryDate : [];
  240. const expiryDate = y != null && m != null && d != null
  241. ? `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
  242. : '';
  243. return {
  244. id: line.id,
  245. lotNo: line.lotNo ?? null,
  246. adjustedQty: line.adjustedQty ?? 0,
  247. productlotNo: line.productlotNo ?? null,
  248. dnNo: line.dnNo ?? null,
  249. isOpeningInventory: line.isOpeningInventory ?? false,
  250. isNew: line.isNew ?? false,
  251. itemId: line.item?.id ?? 0,
  252. itemNo: line.item?.code ?? itemCode,
  253. expiryDate,
  254. warehouseId: line.warehouse?.id ?? 0,
  255. uom: line.uom ?? null,
  256. uomId: line.uomId ?? null,
  257. remarks: line.remarks?.trim() || null,
  258. };
  259. }, []);
  260. const handleAdjustmentSave = useCallback(async () => {
  261. if (!inventory) return;
  262. if (adjustSaveInFlightRef.current) return;
  263. adjustSaveInFlightRef.current = true;
  264. setIsAdjustSaving(true);
  265. try {
  266. const itemCode = inventory.itemCode;
  267. const currentIds = new Set(adjustmentEntries.map((line) => line.id));
  268. const originalLines = originalAdjustmentLinesRef.current.map((line) => {
  269. const api = toApiLine(line, itemCode);
  270. if (!currentIds.has(line.id)) {
  271. api.remarks = removalReasons[line.id]?.trim() || null;
  272. }
  273. return api;
  274. });
  275. const currentLines = adjustmentEntries.map((line) => toApiLine(line, itemCode));
  276. setIsUploading(true);
  277. await submitStockAdjustment({
  278. itemId: inventory.itemId,
  279. originalLines,
  280. currentLines,
  281. });
  282. msg(t("Saved successfully"));
  283. setStockAdjustmentModalOpen(false);
  284. await onStockAdjustmentSuccess?.();
  285. } catch (e: unknown) {
  286. const message = e instanceof Error ? e.message : String(e);
  287. msgError(message || t("Save failed"));
  288. } finally {
  289. setIsUploading(false);
  290. setIsAdjustSaving(false);
  291. adjustSaveInFlightRef.current = false;
  292. }
  293. }, [adjustmentEntries, inventory, t, toApiLine, onStockAdjustmentSuccess, removalReasons]);
  294. const handleOpenAddEntry = useCallback(() => {
  295. setAddEntryForm({
  296. lotNo: '',
  297. qty: 0,
  298. expiryDate: '',
  299. locationId: null,
  300. locationInput: '',
  301. productlotNo: '',
  302. dnNo: '',
  303. isOpeningInventory: false,
  304. remarks: '',
  305. });
  306. setAddEntryModalOpen(true);
  307. }, []);
  308. const handleAddEntrySubmit = useCallback(() => {
  309. if (addEntryForm.qty < 0 || !addEntryForm.expiryDate || !addEntryForm.locationId || !inventory) return;
  310. const warehouse = warehouses.find(w => w.id === addEntryForm.locationId);
  311. if (!warehouse) return;
  312. const [y, m, d] = addEntryForm.expiryDate.split('-').map(Number);
  313. const newEntry: AdjustmentEntry = {
  314. id: -Date.now(),
  315. lotNo: addEntryForm.lotNo.trim() || '',
  316. item: { id: inventory.itemId, code: inventory.itemCode, name: inventory.itemName, type: inventory.itemType },
  317. warehouse: { id: warehouse.id, code: warehouse.code, name: warehouse.name },
  318. inQty: 0, outQty: 0, holdQty: 0,
  319. expiryDate: [y, m, d],
  320. status: 'available',
  321. availableQty: addEntryForm.qty,
  322. uom: inventory.uomUdfudesc || inventory.uomShortDesc || inventory.uomCode,
  323. uomId: inventory.stockUomId ?? inventory.uomId,
  324. qtyPerSmallestUnit: inventory.qtyPerSmallestUnit ?? 1,
  325. baseUom: inventory.baseUom || '',
  326. stockInLineId: 0,
  327. originalQty: 0,
  328. adjustedQty: addEntryForm.qty,
  329. productlotNo: addEntryForm.productlotNo.trim() || undefined,
  330. dnNo: addEntryForm.dnNo.trim() || undefined,
  331. isNew: true,
  332. isOpeningInventory: addEntryForm.isOpeningInventory,
  333. remarks: addEntryForm.remarks?.trim() ?? '',
  334. };
  335. setAdjustmentEntries(prev => [...prev, newEntry]);
  336. setAddEntryModalOpen(false);
  337. }, [addEntryForm, inventory, warehouses]);
  338. const downloadQrCode = useCallback(async (lotLineId: number) => {
  339. setIsUploading(true);
  340. // const postData = { stockInLineIds: [42,43,44] };
  341. const postData: LotLineToQrcode = {
  342. inventoryLotLineId: lotLineId
  343. }
  344. const response = await fetchQrCodeByLotLineId(postData);
  345. if (response) {
  346. downloadFile(new Uint8Array(response.blobValue), response.filename!);
  347. }
  348. setIsUploading(false);
  349. }, [setIsUploading]);
  350. const handleStockTransfer = useCallback(
  351. (lotLine: InventoryLotLineResult) => {
  352. setSelectedLotLine(lotLine);
  353. setStockTransferModalOpen(true);
  354. setStartLocation(lotLine.warehouse.code || "");
  355. setTargetLocation(null);
  356. setTargetLocationInput("");
  357. setQtyToBeTransferred("");
  358. },
  359. [],
  360. );
  361. const handlePrintClick = useCallback((lotLine: InventoryLotLineResult) => {
  362. setLotLineForPrint(lotLine);
  363. const labelPrinters = (printerCombo || []).filter(p => p.type === 'Label');
  364. const savedId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(PRINT_PRINTER_ID_KEY) : null;
  365. const savedPrinter = savedId ? labelPrinters.find(p => p.id === Number(savedId)) : null;
  366. setPrintPrinter(savedPrinter ?? labelPrinters[0] ?? null);
  367. setPrintQty(1);
  368. setPrintModalOpen(true);
  369. }, [printerCombo]);
  370. const handlePrintConfirm = useCallback(async () => {
  371. if (!lotLineForPrint || !printPrinter) return;
  372. try {
  373. setIsUploading(true);
  374. await printLabelForInventoryLotLine({
  375. inventoryLotLineId: lotLineForPrint.id,
  376. printerId: printPrinter.id,
  377. printQty,
  378. });
  379. msg(t("Print sent"));
  380. setPrintModalOpen(false);
  381. } catch (e: any) {
  382. msgError(e?.message ?? t("Print failed"));
  383. } finally {
  384. setIsUploading(false);
  385. }
  386. }, [lotLineForPrint, printPrinter, printQty, setIsUploading, t]);
  387. const onDetailClick = useCallback(
  388. (lotLine: InventoryLotLineResult) => {
  389. downloadQrCode(lotLine.id)
  390. // lot line id to find stock in line
  391. },
  392. [downloadQrCode],
  393. );
  394. const columns = useMemo<Column<InventoryLotLineResult>[]>(
  395. () => [
  396. // {
  397. // name: "item",
  398. // label: t("Code"),
  399. // renderCell: (params) => {
  400. // return params.item.code;
  401. // },
  402. // },
  403. // {
  404. // name: "item",
  405. // label: t("Name"),
  406. // renderCell: (params) => {
  407. // return params.item.name;
  408. // },
  409. // },
  410. {
  411. name: "lotNo",
  412. label: t("Lot No"),
  413. },
  414. // {
  415. // name: "item",
  416. // label: t("Type"),
  417. // renderCell: (params) => {
  418. // return t(params.item.type);
  419. // },
  420. // },
  421. {
  422. name: "availableQty",
  423. label: t("Available Qty"),
  424. align: "right",
  425. headerAlign: "right",
  426. type: "integer",
  427. },
  428. {
  429. name: "uom",
  430. label: t("Stock UoM"),
  431. align: "left",
  432. headerAlign: "left",
  433. },
  434. // {
  435. // name: "qtyPerSmallestUnit",
  436. // label: t("Available Qty Per Smallest Unit"),
  437. // align: "right",
  438. // headerAlign: "right",
  439. // type: "integer",
  440. // },
  441. // {
  442. // name: "baseUom",
  443. // label: t("Base UoM"),
  444. // align: "left",
  445. // headerAlign: "left",
  446. // },
  447. {
  448. name: "expiryDate",
  449. label: t("Expiry Date"),
  450. renderCell: (params) => {
  451. return arrayToDateString(params.expiryDate)
  452. },
  453. },
  454. {
  455. name: "warehouse",
  456. label: t("Warehouse"),
  457. renderCell: (params) => {
  458. return `${params.warehouse.code}`
  459. },
  460. },
  461. {
  462. name: "id",
  463. label: t("Download QR Code"),
  464. onClick: onDetailClick,
  465. buttonIcon: <QrCodeIcon />,
  466. align: "center",
  467. headerAlign: "center",
  468. },
  469. {
  470. name: "id",
  471. label: t("Print QR Code"),
  472. onClick: handlePrintClick,
  473. buttonIcon: <PrintIcon />,
  474. align: "center",
  475. headerAlign: "center",
  476. },
  477. {
  478. name: "id",
  479. label: t("Stock Transfer"),
  480. onClick: handleStockTransfer,
  481. buttonIcon: <SwapHoriz />,
  482. align: "center",
  483. headerAlign: "center",
  484. },
  485. // {
  486. // name: "status",
  487. // label: t("Status"),
  488. // type: "icon",
  489. // icons: {
  490. // available: <CheckCircleOutline fontSize="small"/>,
  491. // unavailable: <DoDisturb fontSize="small"/>,
  492. // },
  493. // colors: {
  494. // available: "success",
  495. // unavailable: "error",
  496. // }
  497. // },
  498. ],
  499. [t, onDetailClick, downloadQrCode, handleStockTransfer, handlePrintClick],
  500. );
  501. const handleCloseStockTransferModal = useCallback(() => {
  502. setStockTransferModalOpen(false);
  503. setSelectedLotLine(null);
  504. setStartLocation("");
  505. setTargetLocation(null);
  506. setTargetLocationInput("");
  507. setQtyToBeTransferred("");
  508. }, []);
  509. const handleSubmitStockTransfer = useCallback(async () => {
  510. if (!selectedLotLine || !targetLocation || validatedTransferQty < 1 || validatedTransferQty > originalQty) {
  511. return;
  512. }
  513. try {
  514. setIsUploading(true);
  515. const request = {
  516. inventoryLotLineId: selectedLotLine.id,
  517. transferredQty: validatedTransferQty,
  518. warehouseId: targetLocation, // targetLocation now contains warehouse ID
  519. };
  520. const response = await createStockTransfer(request);
  521. if (response && response.type === "success") {
  522. const successMsg =
  523. response.code === "MERGED_EXISTING_LOT_AMBIGUOUS"
  524. ? t("Stock transfer merged ambiguous")
  525. : response.code === "MERGED_EXISTING_LOT"
  526. ? t("Stock transfer merged existing lot")
  527. : response.code === "CREATED_NEW_LOT"
  528. ? t("Stock transfer created new lot")
  529. : response.message?.trim() || t("Stock transfer successful");
  530. msg(successMsg);
  531. handleCloseStockTransferModal();
  532. await onStockTransferSuccess?.();
  533. } else {
  534. throw new Error(response?.message || t("Failed to transfer stock"));
  535. }
  536. } catch (error: any) {
  537. console.error("Error transferring stock:", error);
  538. msgError(error?.message || t("Failed to transfer stock. Please try again."));
  539. } finally {
  540. setIsUploading(false);
  541. }
  542. }, [selectedLotLine, targetLocation, validatedTransferQty, originalQty, handleCloseStockTransferModal, setIsUploading, t, onStockTransferSuccess]);
  543. return <>
  544. <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
  545. <Typography variant="h6">
  546. {inventory ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})` : t("No items are selected yet.")}
  547. </Typography>
  548. {inventory && canStockAdjust && (
  549. <Chip
  550. icon={<TuneIcon />}
  551. label={t("Stock Adjustment")}
  552. onClick={() => setStockAdjustmentModalOpen(true)}
  553. sx={{
  554. cursor: 'pointer',
  555. height: 30,
  556. fontWeight: 'bold',
  557. '& .MuiChip-label': {
  558. fontSize: '0.875rem',
  559. fontWeight: 'bold',
  560. },
  561. '& .MuiChip-icon': {
  562. fontSize: '1rem',
  563. },
  564. }}
  565. />
  566. )}
  567. </Box>
  568. <SearchResults<InventoryLotLineResult>
  569. items={availableLotLines}
  570. columns={columns}
  571. pagingController={pagingController}
  572. setPagingController={setPagingController}
  573. totalCount={totalCount}
  574. />
  575. <Modal
  576. open={stockTransferModalOpen}
  577. onClose={handleCloseStockTransferModal}
  578. sx={{
  579. display: 'flex',
  580. alignItems: 'center',
  581. justifyContent: 'center',
  582. }}
  583. >
  584. <Card
  585. sx={{
  586. position: 'relative',
  587. width: '95%',
  588. maxWidth: '1200px',
  589. maxHeight: '90vh',
  590. overflow: 'auto',
  591. p: 3,
  592. }}
  593. >
  594. <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
  595. <Typography variant="h6">
  596. {inventory && selectedLotLine
  597. ? `${inventory.itemCode} ${inventory.itemName} (${selectedLotLine.lotNo})`
  598. : t("Stock Transfer")
  599. }
  600. </Typography>
  601. <IconButton onClick={handleCloseStockTransferModal}>
  602. <CloseIcon />
  603. </IconButton>
  604. </Box>
  605. <Grid container spacing={1} sx={{ mt: 2 }}>
  606. <Grid item xs={5.5}>
  607. <TextField
  608. label={t("Start Location")}
  609. fullWidth
  610. variant="outlined"
  611. value={startLocation}
  612. disabled
  613. InputLabelProps={{
  614. shrink: !!startLocation,
  615. sx: { fontSize: "0.9375rem" },
  616. }}
  617. />
  618. </Grid>
  619. <Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
  620. <Typography variant="body1">{t("to")}</Typography>
  621. </Grid>
  622. <Grid item xs={5.5}>
  623. <Autocomplete
  624. options={warehouses.filter(w => w.code !== startLocation)}
  625. getOptionLabel={(option) => option.code || ""}
  626. value={targetLocation ? warehouses.find(w => w.id === targetLocation) || null : null}
  627. inputValue={targetLocationInput}
  628. onInputChange={(event, newInputValue) => {
  629. setTargetLocationInput(newInputValue);
  630. if (targetLocation && newInputValue !== warehouses.find(w => w.id === targetLocation)?.code) {
  631. setTargetLocation(null);
  632. }
  633. }}
  634. onChange={(event, newValue) => {
  635. if (newValue) {
  636. setTargetLocation(newValue.id);
  637. setTargetLocationInput(newValue.code);
  638. } else {
  639. setTargetLocation(null);
  640. setTargetLocationInput("");
  641. }
  642. }}
  643. filterOptions={(options, { inputValue }) => {
  644. if (!inputValue || inputValue.trim() === "") return options;
  645. const searchTerm = inputValue.toLowerCase().trim();
  646. return options.filter((option) =>
  647. (option.code || "").toLowerCase().includes(searchTerm) ||
  648. (option.name || "").toLowerCase().includes(searchTerm) ||
  649. (option.description || "").toLowerCase().includes(searchTerm)
  650. );
  651. }}
  652. isOptionEqualToValue={(option, value) => option.id === value.id}
  653. autoHighlight={false}
  654. autoSelect={false}
  655. clearOnBlur={false}
  656. renderOption={(props, option) => (
  657. <li {...props}>
  658. {option.code}
  659. </li>
  660. )}
  661. renderInput={(params) => (
  662. <TextField
  663. {...params}
  664. label={t("Target Location")}
  665. variant="outlined"
  666. fullWidth
  667. InputLabelProps={{
  668. shrink: !!targetLocation || !!targetLocationInput,
  669. sx: { fontSize: "0.9375rem" },
  670. }}
  671. />
  672. )}
  673. />
  674. </Grid>
  675. </Grid>
  676. <Grid container spacing={1} sx={{ mt: 2 }}>
  677. <Grid item xs={2}>
  678. <TextField
  679. label={t("Original Qty")}
  680. fullWidth
  681. variant="outlined"
  682. value={originalQty}
  683. disabled
  684. InputLabelProps={{
  685. shrink: true,
  686. sx: { fontSize: "0.9375rem" },
  687. }}
  688. />
  689. </Grid>
  690. <Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
  691. <Typography variant="body1">-</Typography>
  692. </Grid>
  693. <Grid item xs={2}>
  694. <TextField
  695. label={t("Qty To Be Transferred")}
  696. fullWidth
  697. variant="outlined"
  698. type="text"
  699. inputMode="numeric"
  700. value={qtyToBeTransferred}
  701. onChange={(e) => {
  702. const raw = e.target.value.replace(/\D/g, '');
  703. if (raw === '') {
  704. setQtyToBeTransferred('');
  705. return;
  706. }
  707. const parsed = parseInt(raw, 10);
  708. if (Number.isNaN(parsed)) {
  709. setQtyToBeTransferred('');
  710. return;
  711. }
  712. if (originalQty < 1) {
  713. setQtyToBeTransferred('');
  714. return;
  715. }
  716. const clamped = Math.min(Math.max(1, parsed), originalQty);
  717. setQtyToBeTransferred(String(clamped));
  718. }}
  719. onFocus={(e) => (e.target as HTMLInputElement).select()}
  720. inputProps={{ pattern: "[0-9]*" }}
  721. InputLabelProps={{
  722. shrink: true,
  723. sx: { fontSize: "0.9375rem" },
  724. }}
  725. />
  726. </Grid>
  727. <Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
  728. <Typography variant="body1">=</Typography>
  729. </Grid>
  730. <Grid item xs={2}>
  731. <TextField
  732. label={t("Remaining Qty")}
  733. fullWidth
  734. variant="outlined"
  735. value={remainingQty}
  736. disabled
  737. InputLabelProps={{
  738. shrink: true,
  739. sx: { fontSize: "0.9375rem" },
  740. }}
  741. />
  742. </Grid>
  743. <Grid item xs={2}>
  744. <TextField
  745. label={t("Stock UoM")}
  746. fullWidth
  747. variant="outlined"
  748. value={selectedLotLine?.uom || ""}
  749. disabled
  750. InputLabelProps={{
  751. shrink: true,
  752. sx: { fontSize: "0.9375rem" },
  753. }}
  754. />
  755. </Grid>
  756. <Grid item xs={2} sx={{ display: 'flex', alignItems: 'center' }}>
  757. <Button
  758. variant="contained"
  759. fullWidth
  760. sx={{
  761. height: '56px',
  762. fontSize: '0.9375rem',
  763. }}
  764. onClick={handleSubmitStockTransfer}
  765. disabled={!selectedLotLine || !targetLocation || validatedTransferQty < 1 || validatedTransferQty > originalQty}
  766. >
  767. {t("Submit")}
  768. </Button>
  769. </Grid>
  770. </Grid>
  771. </Card>
  772. </Modal>
  773. <Modal
  774. open={printModalOpen}
  775. onClose={() => setPrintModalOpen(false)}
  776. sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
  777. >
  778. <Card sx={{ position: 'relative', minWidth: 320, maxWidth: 480, p: 3 }}>
  779. <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
  780. <Typography variant="h6">{t("Print QR Code")}</Typography>
  781. <IconButton onClick={() => setPrintModalOpen(false)}><CloseIcon /></IconButton>
  782. </Box>
  783. <Grid container spacing={2}>
  784. <Grid item xs={12}>
  785. <Autocomplete
  786. options={(printerCombo || []).filter(printer => printer.type === 'Label')}
  787. getOptionLabel={(opt) => opt.name ?? opt.label ?? opt.code ?? `Printer ${opt.id}`}
  788. value={printPrinter}
  789. onChange={(_, v) => {
  790. setPrintPrinter(v);
  791. if (typeof sessionStorage !== 'undefined') {
  792. if (v?.id != null) sessionStorage.setItem(PRINT_PRINTER_ID_KEY, String(v.id));
  793. else sessionStorage.removeItem(PRINT_PRINTER_ID_KEY);
  794. }
  795. }}
  796. renderInput={(params) => <TextField {...params} label={t("Printer")} />}
  797. />
  798. </Grid>
  799. <Grid item xs={12}>
  800. <TextField
  801. label={t("Print Qty")}
  802. type="number"
  803. value={printQty}
  804. onChange={(e) => setPrintQty(Math.max(1, parseInt(e.target.value) ))}
  805. inputProps={{ min: 1 }}
  806. fullWidth
  807. />
  808. </Grid>
  809. <Grid item xs={12}>
  810. <Button variant="contained" fullWidth onClick={handlePrintConfirm} disabled={!printPrinter}>
  811. {t("Print")}
  812. </Button>
  813. </Grid>
  814. </Grid>
  815. </Card>
  816. </Modal>
  817. <Modal
  818. open={stockAdjustmentModalOpen}
  819. onClose={() => setStockAdjustmentModalOpen(false)}
  820. sx={{
  821. display: 'flex',
  822. alignItems: 'center',
  823. justifyContent: 'center',
  824. }}
  825. >
  826. <Card
  827. sx={{
  828. position: 'relative',
  829. width: '95%',
  830. maxWidth: '1400px',
  831. maxHeight: '92vh',
  832. overflow: 'auto',
  833. p: 3,
  834. }}
  835. >
  836. <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
  837. <Typography variant="h6">
  838. {inventory
  839. ? `${t("Edit mode")}: ${inventory.itemCode} ${inventory.itemName}`
  840. : t("Stock Adjustment")
  841. }
  842. </Typography>
  843. <IconButton onClick={() => setStockAdjustmentModalOpen(false)}>
  844. <CloseIcon />
  845. </IconButton>
  846. </Box>
  847. <Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
  848. <Button
  849. variant="contained"
  850. startIcon={<AddIcon />}
  851. onClick={handleOpenAddEntry}
  852. >
  853. {t("Add entry")}
  854. </Button>
  855. <Button
  856. variant="outlined"
  857. startIcon={<RestartAltIcon />}
  858. onClick={handleAdjustmentReset}
  859. >
  860. {t("Reset")}
  861. </Button>
  862. <Button
  863. variant="contained"
  864. color="primary"
  865. startIcon={<SaveIcon />}
  866. onClick={handleAdjustmentSave}
  867. disabled={!hasAdjustmentChange || isAdjustSaving}
  868. >
  869. {t("Save")}
  870. </Button>
  871. </Box>
  872. {/* List view */}
  873. <Box sx={{ overflow: 'auto' }}>
  874. <Table size="small">
  875. <TableHead>
  876. <TableRow>
  877. <TableCell>{t("Lot No")}</TableCell>
  878. <TableCell align="right">{t("Original Qty")}</TableCell>
  879. <TableCell align="right">{t("Adjusted Qty")}</TableCell>
  880. <TableCell align="right" sx={{ minWidth: 100 }}>{t("Difference")}</TableCell>
  881. <TableCell>{t("Stock UoM")}</TableCell>
  882. <TableCell>{t("Expiry Date")}</TableCell>
  883. <TableCell>{t("Location")}</TableCell>
  884. <TableCell>{t("Remarks")}</TableCell>
  885. <TableCell align="center" sx={{ minWidth: 240 }}>{t("Action")}</TableCell>
  886. </TableRow>
  887. </TableHead>
  888. <TableBody>
  889. {adjustmentEntries.map((line) => (
  890. <TableRow
  891. key={line.id}
  892. sx={{
  893. backgroundColor: pendingRemovalLineId === line.id ? 'action.hover' : undefined,
  894. }}
  895. >
  896. <TableCell>
  897. <Box component="span" sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
  898. <span>
  899. {line.lotNo?.trim() ? line.lotNo : t("No lot no entered, will be generated by system.")}
  900. {line.isOpeningInventory && ` (${t("Opening Inventory")})`}
  901. </span>
  902. {line.productlotNo && <span>{t("productLotNo")}: {line.productlotNo}</span>}
  903. {line.dnNo && <span>{t("dnNo")}: {line.dnNo}</span>}
  904. </Box>
  905. </TableCell>
  906. <TableCell align="right">{line.originalQty ?? 0}</TableCell>
  907. <TableCell align="right">
  908. <TextField
  909. type="text"
  910. inputMode="numeric"
  911. value={String(line.adjustedQty)}
  912. onChange={(e) => {
  913. const raw = e.target.value.replace(/\D/g, '');
  914. if (raw === '') {
  915. handleAdjustmentQtyChange(line.id, 0);
  916. return;
  917. }
  918. const num = parseInt(raw, 10);
  919. if (!Number.isNaN(num) && num >= 0) handleAdjustmentQtyChange(line.id, num);
  920. }}
  921. inputProps={{ style: { textAlign: 'right' } }}
  922. size="small"
  923. sx={{
  924. width: 120,
  925. '& .MuiInputBase-root': {
  926. display: 'flex',
  927. alignItems: 'center',
  928. height: 56,
  929. },
  930. '& .MuiInputBase-input': {
  931. fontSize: 16,
  932. textAlign: 'right',
  933. height: 40,
  934. lineHeight: '40px',
  935. paddingTop: 0,
  936. paddingBottom: 0,
  937. boxSizing: 'border-box',
  938. MozAppearance: 'textfield',
  939. },
  940. '& .MuiInputBase-input::-webkit-outer-spin-button': {
  941. WebkitAppearance: 'none',
  942. margin: 0,
  943. },
  944. '& .MuiInputBase-input::-webkit-inner-spin-button': {
  945. WebkitAppearance: 'none',
  946. margin: 0,
  947. },
  948. }}
  949. />
  950. </TableCell>
  951. <TableCell align="right" sx={{ minWidth: 100, fontWeight: 700 }}>
  952. {(() => {
  953. const diff = line.adjustedQty - (line.originalQty ?? 0);
  954. const text = diff > 0 ? `+${diff}` : diff < 0 ? `${diff}` : '±0';
  955. const color = diff > 0 ? 'success.main' : diff < 0 ? 'error.main' : 'text.secondary';
  956. return <Box component="span" sx={{ color }}>{text}</Box>;
  957. })()}
  958. </TableCell>
  959. <TableCell>{line.uom}</TableCell>
  960. <TableCell>{arrayToDateString(line.expiryDate)}</TableCell>
  961. <TableCell>{line.warehouse?.code ?? ""}</TableCell>
  962. <TableCell>
  963. {pendingRemovalLineId === line.id ? (
  964. <TextField
  965. size="small"
  966. placeholder={t("Reason for removal")}
  967. value={removalReasons[line.id] ?? ""}
  968. onChange={(e) => handleRemovalReasonChange(line.id, e.target.value)}
  969. sx={{
  970. width: 160,
  971. maxWidth: '100%',
  972. '& .MuiInputBase-root': {
  973. display: 'flex',
  974. alignItems: 'center',
  975. height: 56,
  976. },
  977. '& .MuiInputBase-input': {
  978. fontSize: '1rem',
  979. height: 40,
  980. lineHeight: '40px',
  981. paddingTop: 0,
  982. paddingBottom: 0,
  983. boxSizing: 'border-box',
  984. '&::placeholder': { color: '#9e9e9e', opacity: 1 },
  985. },
  986. }}
  987. />
  988. ) : (line.adjustedQty - (line.originalQty ?? 0)) !== 0 ? (
  989. <TextField
  990. size="small"
  991. placeholder={t("Reason for adjustment")}
  992. value={line.remarks ?? ""}
  993. onChange={(e) => handleAdjustmentRemarksChange(line.id, e.target.value)}
  994. sx={{
  995. width: 160,
  996. maxWidth: '100%',
  997. '& .MuiInputBase-root': {
  998. display: 'flex',
  999. alignItems: 'center',
  1000. height: 56,
  1001. },
  1002. '& .MuiInputBase-input': {
  1003. fontSize: '1rem',
  1004. height: 40,
  1005. lineHeight: '40px',
  1006. paddingTop: 0,
  1007. paddingBottom: 0,
  1008. boxSizing: 'border-box',
  1009. '&::placeholder': { color: '#9e9e9e', opacity: 1 },
  1010. },
  1011. }}
  1012. />
  1013. ) : (
  1014. line.remarks || null
  1015. )}
  1016. </TableCell>
  1017. <TableCell align="center">
  1018. {pendingRemovalLineId === line.id ? (
  1019. <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5 }}>
  1020. <Button size="small" variant="outlined" onClick={handleCancelRemoval}>
  1021. {t("Cancel")}
  1022. </Button>
  1023. <Button
  1024. size="small"
  1025. variant="contained"
  1026. color="error"
  1027. startIcon={<CheckIcon />}
  1028. onClick={() => handleConfirmRemoval(line.id)}
  1029. >
  1030. {t("Confirm remove")}
  1031. </Button>
  1032. </Box>
  1033. ) : (
  1034. <IconButton
  1035. size="small"
  1036. onClick={() => handleRemoveClick(line.id)}
  1037. color="error"
  1038. title={t("Remove")}
  1039. >
  1040. <DeleteIcon fontSize="small" />
  1041. </IconButton>
  1042. )}
  1043. </TableCell>
  1044. </TableRow>
  1045. ))}
  1046. </TableBody>
  1047. </Table>
  1048. </Box>
  1049. </Card>
  1050. </Modal>
  1051. <Modal
  1052. open={addEntryModalOpen}
  1053. onClose={() => setAddEntryModalOpen(false)}
  1054. sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
  1055. >
  1056. <Card sx={{ position: 'relative', minWidth: 600, maxWidth: 900, p: 3 }}>
  1057. <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
  1058. <Typography variant="h6">{t("Add entry")}</Typography>
  1059. <IconButton onClick={() => setAddEntryModalOpen(false)}><CloseIcon /></IconButton>
  1060. </Box>
  1061. <Grid container spacing={2}>
  1062. <Grid item xs={4}>
  1063. <TextField label={t("Available Qty")} type="number" fullWidth required value={addEntryForm.qty || ''} onChange={(e) => setAddEntryForm(f => ({ ...f, qty: Math.max(0, parseInt(e.target.value) || 0) }))} inputProps={{ min: 0 }} />
  1064. </Grid>
  1065. <Grid item xs={4}>
  1066. <TextField label={t("Stock UoM")} fullWidth disabled value={inventory?.uomUdfudesc || inventory?.uomShortDesc || inventory?.uomCode || ''} sx={{ '& .MuiInputBase-input': { color: 'text.secondary' } }} InputLabelProps={{ shrink: true }} />
  1067. </Grid>
  1068. <Grid item xs={4}>
  1069. <LocalizationProvider dateAdapter={AdapterDayjs}>
  1070. <DatePicker label={t("Expiry Date")} format={INPUT_DATE_FORMAT} value={addEntryForm.expiryDate ? dayjs(addEntryForm.expiryDate) : null} onChange={(value) => setAddEntryForm(f => ({ ...f, expiryDate: value ? dayjs(value).format(INPUT_DATE_FORMAT) : '' }))} slotProps={{ textField: { fullWidth: true, required: true } }} />
  1071. </LocalizationProvider>
  1072. </Grid>
  1073. <Grid item xs={6}>
  1074. <Autocomplete options={warehouses} getOptionLabel={(o) => o.code || ''} value={addEntryForm.locationId ? warehouses.find(w => w.id === addEntryForm.locationId) ?? null : null} inputValue={addEntryForm.locationInput} onInputChange={(_, v) => setAddEntryForm(f => ({ ...f, locationInput: v }))} onChange={(_, v) => setAddEntryForm(f => ({ ...f, locationId: v?.id ?? null, locationInput: v?.code ?? '' }))} renderInput={(params) => <TextField {...params} label={t("Location")} required />} />
  1075. </Grid>
  1076. <Grid item xs={6} sx={{ display: 'flex', alignItems: 'center' }}>
  1077. <FormControlLabel control={<Checkbox checked={addEntryForm.isOpeningInventory} onChange={(e) => setAddEntryForm(f => ({ ...f, isOpeningInventory: e.target.checked }))} />} label={t("Opening Inventory")} />
  1078. </Grid>
  1079. <Grid item xs={4}>
  1080. <TextField label={t("productLotNo")} fullWidth placeholder={t("Optional - system will generate")} value={addEntryForm.productlotNo} onChange={(e) => setAddEntryForm(f => ({ ...f, productlotNo: e.target.value }))} sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }} />
  1081. </Grid>
  1082. <Grid item xs={4}>
  1083. <TextField label={t("dnNo")} fullWidth placeholder={t("Optional - system will generate")} value={addEntryForm.dnNo} onChange={(e) => setAddEntryForm(f => ({ ...f, dnNo: e.target.value }))} sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }} />
  1084. </Grid>
  1085. <Grid item xs={4}>
  1086. <TextField label={t("Lot No")} fullWidth placeholder={t("Optional - system will generate")} value={addEntryForm.lotNo} onChange={(e) => setAddEntryForm(f => ({ ...f, lotNo: e.target.value }))} sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }} />
  1087. </Grid>
  1088. <Grid item xs={12}>
  1089. <TextField
  1090. label={t("Remarks")}
  1091. fullWidth
  1092. placeholder={t("Reason for adjustment")}
  1093. value={addEntryForm.remarks}
  1094. onChange={(e) => setAddEntryForm(f => ({ ...f, remarks: e.target.value }))}
  1095. multiline
  1096. minRows={2}
  1097. sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }}
  1098. />
  1099. </Grid>
  1100. <Grid item xs={12}>
  1101. <Button variant="contained" fullWidth onClick={handleAddEntrySubmit} disabled={addEntryForm.qty < 0 || !addEntryForm.expiryDate || !addEntryForm.locationId}>
  1102. {t("Add")}
  1103. </Button>
  1104. </Grid>
  1105. </Grid>
  1106. </Card>
  1107. </Modal>
  1108. </>
  1109. }
  1110. export default InventoryLotLineTable;