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

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