FPSMS-frontend
25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 

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