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.
 
 

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