|
- import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory";
- import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react";
- import SaveIcon from "@mui/icons-material/Save";
- import EditIcon from "@mui/icons-material/Edit";
- import RestartAltIcon from "@mui/icons-material/RestartAlt";
- import { useTranslation } from "react-i18next";
- import { Column } from "../SearchResults";
- import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults";
- import { arrayToDateString } from "@/app/utils/formatUtil";
- import { Box, Card, Checkbox, FormControlLabel, Grid, IconButton, Modal, TextField, Typography, Button, Chip } from "@mui/material";
- import useUploadContext from "../UploadProvider/useUploadContext";
- import { downloadFile } from "@/app/utils/commonUtil";
- import { fetchQrCodeByLotLineId, LotLineToQrcode } from "@/app/api/pdf/actions";
- import QrCodeIcon from "@mui/icons-material/QrCode";
- import PrintIcon from "@mui/icons-material/Print";
- import SwapHoriz from "@mui/icons-material/SwapHoriz";
- import CloseIcon from "@mui/icons-material/Close";
- import { Autocomplete } from "@mui/material";
- import { WarehouseResult } from "@/app/api/warehouse";
- import { fetchWarehouseListClient } from "@/app/api/warehouse/client";
- import { createStockTransfer } from "@/app/api/inventory/actions";
- import { msg, msgError } from "@/components/Swal/CustomAlerts";
- import { PrinterCombo } from "@/app/api/settings/printer";
- import { printLabelForInventoryLotLine } from "@/app/api/pdf/actions";
- import TuneIcon from "@mui/icons-material/Tune";
- import AddIcon from "@mui/icons-material/Add";
- import { Table, TableBody, TableCell, TableHead, TableRow } from "@mui/material";
- import DeleteIcon from "@mui/icons-material/Delete";
- import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
- import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
- import { DatePicker } from "@mui/x-date-pickers/DatePicker";
- import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
- import dayjs from "dayjs";
- import CheckIcon from "@mui/icons-material/Check";
- import { submitStockAdjustment, StockAdjustmentLineRequest } from "@/app/api/stockAdjustment/actions";
-
- type AdjustmentEntry = InventoryLotLineResult & {
- adjustedQty: number;
- originalQty?: number;
- productlotNo?: string;
- dnNo?: string;
- isNew?: boolean;
- isOpeningInventory?: boolean;
- remarks?: string;
- };
-
-
- interface Props {
- inventoryLotLines: InventoryLotLineResult[] | null;
- setPagingController: defaultSetPagingController;
- pagingController: typeof defaultPagingController;
- totalCount: number;
- inventory: InventoryResult | null;
- filterLotNo?: string;
- onStockTransferSuccess?: () => void | Promise<void>;
- printerCombo?: PrinterCombo[];
- onStockAdjustmentSuccess?: () => void | Promise<void>;
- }
-
- const InventoryLotLineTable: React.FC<Props> = ({
- inventoryLotLines, pagingController, setPagingController, totalCount, inventory,
- filterLotNo,
- onStockTransferSuccess, printerCombo = [],
- onStockAdjustmentSuccess,
- }) => {
- const { t } = useTranslation(["inventory"]);
- const PRINT_PRINTER_ID_KEY = 'inventoryLotLinePrintPrinterId';
- const { setIsUploading } = useUploadContext();
- const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false);
- const [selectedLotLine, setSelectedLotLine] = useState<InventoryLotLineResult | null>(null);
- const [startLocation, setStartLocation] = useState<string>("");
- const [targetLocation, setTargetLocation] = useState<number | null>(null); // Store warehouse ID instead of code
- const [targetLocationInput, setTargetLocationInput] = useState<string>("");
- const [qtyToBeTransferred, setQtyToBeTransferred] = useState<string>("");
- const [warehouses, setWarehouses] = useState<WarehouseResult[]>([]);
- const [printModalOpen, setPrintModalOpen] = useState(false);
- const [lotLineForPrint, setLotLineForPrint] = useState<InventoryLotLineResult | null>(null);
- const [printPrinter, setPrintPrinter] = useState<PrinterCombo | null>(null);
- const [printQty, setPrintQty] = useState(1);
- const [stockAdjustmentModalOpen, setStockAdjustmentModalOpen] = useState(false);
- const [pendingRemovalLineId, setPendingRemovalLineId] = useState<number | null>(null);
- const [removalReasons, setRemovalReasons] = useState<Record<number, string>>({});
- const [addEntryModalOpen, setAddEntryModalOpen] = useState(false);
- const [addEntryForm, setAddEntryForm] = useState({
- lotNo: '',
- qty: 0,
- expiryDate: '',
- locationId: null as number | null,
- locationInput: '',
- productlotNo: '',
- dnNo: '',
- isOpeningInventory: false,
- remarks: '',
- });
- const originalAdjustmentLinesRef = useRef<AdjustmentEntry[]>([]);
- const [adjustmentEntries, setAdjustmentEntries] = useState<AdjustmentEntry[]>([]);
- useEffect(() => {
- if (stockTransferModalOpen) {
- fetchWarehouseListClient()
- .then(setWarehouses)
- .catch(console.error);
- }
- }, [stockTransferModalOpen]);
-
- useEffect(() => {
- if (addEntryModalOpen) {
- fetchWarehouseListClient()
- .then(setWarehouses)
- .catch(console.error);
- }
- }, [addEntryModalOpen]);
-
- const availableLotLines = useMemo(() => {
- const base = (inventoryLotLines ?? []).filter((line) => line.status?.toLowerCase() === "available");
- const f = filterLotNo?.trim?.() ? filterLotNo.trim() : '';
- if (!f) return base;
- return base.filter((line) => line.lotNo === f);
- }, [inventoryLotLines, filterLotNo]);
- const originalQty = selectedLotLine?.availableQty || 0;
- const validatedTransferQty = useMemo(() => {
- const raw = (qtyToBeTransferred ?? '').replace(/\D/g, '');
- if (raw === '') return 0;
-
- const parsed = parseInt(raw, 10);
- if (Number.isNaN(parsed)) return 0;
- if (originalQty < 1) return 0;
-
- const minClamped = Math.max(1, parsed);
- return Math.min(minClamped, originalQty);
- }, [qtyToBeTransferred, originalQty]);
- const remainingQty = originalQty - validatedTransferQty;
-
- const prevAdjustmentModalOpenRef = useRef(false);
-
- useEffect(() => {
- const wasOpen = prevAdjustmentModalOpenRef.current;
- prevAdjustmentModalOpenRef.current = stockAdjustmentModalOpen;
-
- if (stockAdjustmentModalOpen && inventory) {
- // Only init when we transition to open (modal just opened)
- if (!wasOpen) {
- const initial = (availableLotLines ?? []).map((line) => ({
- ...line,
- adjustedQty: line.availableQty ?? 0,
- originalQty: line.availableQty ?? 0,
- remarks: '',
- }));
- setAdjustmentEntries(initial);
- originalAdjustmentLinesRef.current = initial;
- }
- setPendingRemovalLineId(null);
- setRemovalReasons({});
- }
- }, [stockAdjustmentModalOpen, inventory, availableLotLines]);
-
- const handleAdjustmentReset = useCallback(() => {
-
- setPendingRemovalLineId(null);
- setRemovalReasons({});
- setAdjustmentEntries(
- (availableLotLines ?? []).map((line) => ({
- ...line,
- adjustedQty: line.availableQty ?? 0,
- originalQty: line.availableQty ?? 0,
- remarks: '',
- }))
- );
- }, [availableLotLines]);
-
- const handleAdjustmentQtyChange = useCallback((lineId: number, value: number) => {
- setAdjustmentEntries((prev) =>
- prev.map((line) =>
- line.id === lineId ? { ...line, adjustedQty: Math.max(0, value) } : line
- )
- );
- }, []);
-
- const handleAdjustmentRemarksChange = useCallback((lineId: number, value: string) => {
- setAdjustmentEntries((prev) =>
- prev.map((line) =>
- line.id === lineId ? { ...line, remarks: value } : line
- )
- );
- }, []);
-
- const handleRemoveAdjustmentLine = useCallback((lineId: number) => {
- setAdjustmentEntries((prev) => prev.filter((line) => line.id !== lineId));
- }, []);
-
- const handleRemoveClick = useCallback((lineId: number) => {
- setPendingRemovalLineId((prev) => (prev === lineId ? null : lineId));
- }, []);
-
- const handleRemovalReasonChange = useCallback((lineId: number, value: string) => {
- setRemovalReasons((prev) => ({ ...prev, [lineId]: value }));
- }, []);
-
- const handleConfirmRemoval = useCallback((lineId: number) => {
- setAdjustmentEntries((prev) => prev.filter((line) => line.id !== lineId));
- setPendingRemovalLineId(null);
- }, []);
-
- const handleCancelRemoval = useCallback(() => {
- setPendingRemovalLineId(null);
- }, []);
-
- const hasAdjustmentChange = useMemo(() => {
- const original = originalAdjustmentLinesRef.current;
- const current = adjustmentEntries;
- if (original.length !== current.length) return true;
- const origById = new Map(original.map((line) => [line.id, { adjustedQty: line.adjustedQty ?? 0, remarks: line.remarks ?? '' }]));
- for (const line of current) {
- const o = origById.get(line.id);
- if (!o) return true;
- if (o.adjustedQty !== (line.adjustedQty ?? 0) || (o.remarks ?? '') !== (line.remarks ?? '')) return true;
- }
- return false;
- }, [adjustmentEntries]);
-
- const toApiLine = useCallback((line: AdjustmentEntry, itemCode: string): StockAdjustmentLineRequest => {
- const [y, m, d] = Array.isArray(line.expiryDate) ? line.expiryDate : [];
- const expiryDate = y != null && m != null && d != null
- ? `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
- : '';
- return {
- id: line.id,
- lotNo: line.lotNo ?? null,
- adjustedQty: line.adjustedQty ?? 0,
- productlotNo: line.productlotNo ?? null,
- dnNo: line.dnNo ?? null,
- isOpeningInventory: line.isOpeningInventory ?? false,
- isNew: line.isNew ?? false,
- itemId: line.item?.id ?? 0,
- itemNo: line.item?.code ?? itemCode,
- expiryDate,
- warehouseId: line.warehouse?.id ?? 0,
- uom: line.uom ?? null,
- };
- }, []);
-
- const handleAdjustmentSave = useCallback(async () => {
- if (!inventory) return;
- const itemCode = inventory.itemCode;
- const originalLines = originalAdjustmentLinesRef.current.map((line) => toApiLine(line, itemCode));
- const currentLines = adjustmentEntries.map((line) => toApiLine(line, itemCode));
- try {
- setIsUploading(true);
- await submitStockAdjustment({
- itemId: inventory.itemId,
- originalLines,
- currentLines,
- });
- msg(t("Saved successfully"));
- setStockAdjustmentModalOpen(false);
- await onStockAdjustmentSuccess?.();
- } catch (e: unknown) {
- const message = e instanceof Error ? e.message : String(e);
- msgError(message || t("Save failed"));
- } finally {
- setIsUploading(false);
- }
- }, [adjustmentEntries, inventory, t, toApiLine, onStockAdjustmentSuccess]);
-
- const handleOpenAddEntry = useCallback(() => {
- setAddEntryForm({
- lotNo: '',
- qty: 0,
- expiryDate: '',
- locationId: null,
- locationInput: '',
- productlotNo: '',
- dnNo: '',
- isOpeningInventory: false,
- remarks: '',
- });
- setAddEntryModalOpen(true);
- }, []);
-
- const handleAddEntrySubmit = useCallback(() => {
- if (addEntryForm.qty < 0 || !addEntryForm.expiryDate || !addEntryForm.locationId || !inventory) return;
- const warehouse = warehouses.find(w => w.id === addEntryForm.locationId);
- if (!warehouse) return;
- const [y, m, d] = addEntryForm.expiryDate.split('-').map(Number);
- const newEntry: AdjustmentEntry = {
- id: -Date.now(),
- lotNo: addEntryForm.lotNo.trim() || '',
- item: { id: inventory.itemId, code: inventory.itemCode, name: inventory.itemName, type: inventory.itemType },
- warehouse: { id: warehouse.id, code: warehouse.code, name: warehouse.name },
- inQty: 0, outQty: 0, holdQty: 0,
- expiryDate: [y, m, d],
- status: 'available',
- availableQty: addEntryForm.qty,
- uom: inventory.uomUdfudesc || inventory.uomShortDesc || inventory.uomCode,
- qtyPerSmallestUnit: inventory.qtyPerSmallestUnit ?? 1,
- baseUom: inventory.baseUom || '',
- stockInLineId: 0,
- originalQty: 0,
- adjustedQty: addEntryForm.qty,
- productlotNo: addEntryForm.productlotNo.trim() || undefined,
- dnNo: addEntryForm.dnNo.trim() || undefined,
- isNew: true,
- isOpeningInventory: addEntryForm.isOpeningInventory,
- remarks: addEntryForm.remarks?.trim() ?? '',
- };
- setAdjustmentEntries(prev => [...prev, newEntry]);
- setAddEntryModalOpen(false);
- }, [addEntryForm, inventory, warehouses]);
-
- const downloadQrCode = useCallback(async (lotLineId: number) => {
- setIsUploading(true);
- // const postData = { stockInLineIds: [42,43,44] };
- const postData: LotLineToQrcode = {
- inventoryLotLineId: lotLineId
- }
- const response = await fetchQrCodeByLotLineId(postData);
- if (response) {
- downloadFile(new Uint8Array(response.blobValue), response.filename!);
- }
- setIsUploading(false);
- }, [setIsUploading]);
-
- const handleStockTransfer = useCallback(
- (lotLine: InventoryLotLineResult) => {
- setSelectedLotLine(lotLine);
- setStockTransferModalOpen(true);
- setStartLocation(lotLine.warehouse.code || "");
- setTargetLocation(null);
- setTargetLocationInput("");
- setQtyToBeTransferred("");
- },
- [],
- );
-
- const handlePrintClick = useCallback((lotLine: InventoryLotLineResult) => {
- setLotLineForPrint(lotLine);
- const labelPrinters = (printerCombo || []).filter(p => p.type === 'Label');
- const savedId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(PRINT_PRINTER_ID_KEY) : null;
- const savedPrinter = savedId ? labelPrinters.find(p => p.id === Number(savedId)) : null;
- setPrintPrinter(savedPrinter ?? labelPrinters[0] ?? null);
- setPrintQty(1);
- setPrintModalOpen(true);
- }, [printerCombo]);
-
- const handlePrintConfirm = useCallback(async () => {
- if (!lotLineForPrint || !printPrinter) return;
- try {
- setIsUploading(true);
- await printLabelForInventoryLotLine({
- inventoryLotLineId: lotLineForPrint.id,
- printerId: printPrinter.id,
- printQty,
- });
- msg(t("Print sent"));
- setPrintModalOpen(false);
- } catch (e: any) {
- msgError(e?.message ?? t("Print failed"));
- } finally {
- setIsUploading(false);
- }
- }, [lotLineForPrint, printPrinter, printQty, setIsUploading, t]);
-
- const onDetailClick = useCallback(
- (lotLine: InventoryLotLineResult) => {
- downloadQrCode(lotLine.id)
- // lot line id to find stock in line
- },
- [downloadQrCode],
- );
- const columns = useMemo<Column<InventoryLotLineResult>[]>(
- () => [
- // {
- // name: "item",
- // label: t("Code"),
- // renderCell: (params) => {
- // return params.item.code;
- // },
- // },
- // {
- // name: "item",
- // label: t("Name"),
- // renderCell: (params) => {
- // return params.item.name;
- // },
- // },
- {
- name: "lotNo",
- label: t("Lot No"),
- },
- // {
- // name: "item",
- // label: t("Type"),
- // renderCell: (params) => {
- // return t(params.item.type);
- // },
- // },
- {
- name: "availableQty",
- label: t("Available Qty"),
- align: "right",
- headerAlign: "right",
- type: "integer",
- },
- {
- name: "uom",
- label: t("Stock UoM"),
- align: "left",
- headerAlign: "left",
- },
- // {
- // name: "qtyPerSmallestUnit",
- // label: t("Available Qty Per Smallest Unit"),
- // align: "right",
- // headerAlign: "right",
- // type: "integer",
- // },
- // {
- // name: "baseUom",
- // label: t("Base UoM"),
- // align: "left",
- // headerAlign: "left",
- // },
- {
- name: "expiryDate",
- label: t("Expiry Date"),
- renderCell: (params) => {
- return arrayToDateString(params.expiryDate)
- },
- },
- {
- name: "warehouse",
- label: t("Warehouse"),
- renderCell: (params) => {
- return `${params.warehouse.code}`
- },
- },
- {
- name: "id",
- label: t("Download QR Code"),
- onClick: onDetailClick,
- buttonIcon: <QrCodeIcon />,
- align: "center",
- headerAlign: "center",
- },
- {
- name: "id",
- label: t("Print QR Code"),
- onClick: handlePrintClick,
- buttonIcon: <PrintIcon />,
- align: "center",
- headerAlign: "center",
- },
- {
- name: "id",
- label: t("Stock Transfer"),
- onClick: handleStockTransfer,
- buttonIcon: <SwapHoriz />,
- align: "center",
- headerAlign: "center",
- },
- // {
- // name: "status",
- // label: t("Status"),
- // type: "icon",
- // icons: {
- // available: <CheckCircleOutline fontSize="small"/>,
- // unavailable: <DoDisturb fontSize="small"/>,
- // },
- // colors: {
- // available: "success",
- // unavailable: "error",
- // }
- // },
- ],
- [t, onDetailClick, downloadQrCode, handleStockTransfer, handlePrintClick],
- );
-
-
-
- const handleCloseStockTransferModal = useCallback(() => {
- setStockTransferModalOpen(false);
- setSelectedLotLine(null);
- setStartLocation("");
- setTargetLocation(null);
- setTargetLocationInput("");
- setQtyToBeTransferred("");
- }, []);
-
- const handleSubmitStockTransfer = useCallback(async () => {
- if (!selectedLotLine || !targetLocation || validatedTransferQty < 1 || validatedTransferQty > originalQty) {
- return;
- }
-
- try {
- setIsUploading(true);
-
- const request = {
- inventoryLotLineId: selectedLotLine.id,
- transferredQty: validatedTransferQty,
- warehouseId: targetLocation, // targetLocation now contains warehouse ID
- };
-
- const response = await createStockTransfer(request);
-
- if (response && response.type === "success") {
- msg(t("Stock transfer successful"));
- handleCloseStockTransferModal();
- await onStockTransferSuccess?.();
- } else {
- throw new Error(response?.message || t("Failed to transfer stock"));
- }
- } catch (error: any) {
- console.error("Error transferring stock:", error);
- msgError(error?.message || t("Failed to transfer stock. Please try again."));
- } finally {
- setIsUploading(false);
- }
- }, [selectedLotLine, targetLocation, validatedTransferQty, originalQty, handleCloseStockTransferModal, setIsUploading, t, onStockTransferSuccess]);
-
- return <>
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
- <Typography variant="h6">
- {inventory ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})` : t("No items are selected yet.")}
- </Typography>
- {inventory && (
- <Chip
- icon={<TuneIcon />}
- label={t("Stock Adjustment")}
- onClick={() => setStockAdjustmentModalOpen(true)}
- sx={{
- cursor: 'pointer',
- height: 30,
- fontWeight: 'bold',
- '& .MuiChip-label': {
- fontSize: '0.875rem',
- fontWeight: 'bold',
- },
- '& .MuiChip-icon': {
- fontSize: '1rem',
- },
- }}
- />
- )}
- </Box>
-
- <SearchResults<InventoryLotLineResult>
- items={availableLotLines}
- columns={columns}
- pagingController={pagingController}
- setPagingController={setPagingController}
- totalCount={totalCount}
- />
-
- <Modal
- open={stockTransferModalOpen}
- onClose={handleCloseStockTransferModal}
- sx={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- }}
- >
- <Card
- sx={{
- position: 'relative',
- width: '95%',
- maxWidth: '1200px',
- maxHeight: '90vh',
- overflow: 'auto',
- p: 3,
- }}
- >
- <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
- <Typography variant="h6">
- {inventory && selectedLotLine
- ? `${inventory.itemCode} ${inventory.itemName} (${selectedLotLine.lotNo})`
- : t("Stock Transfer")
- }
- </Typography>
- <IconButton onClick={handleCloseStockTransferModal}>
- <CloseIcon />
- </IconButton>
- </Box>
- <Grid container spacing={1} sx={{ mt: 2 }}>
- <Grid item xs={5.5}>
- <TextField
- label={t("Start Location")}
- fullWidth
- variant="outlined"
- value={startLocation}
- disabled
- InputLabelProps={{
- shrink: !!startLocation,
- sx: { fontSize: "0.9375rem" },
- }}
- />
- </Grid>
- <Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
- <Typography variant="body1">{t("to")}</Typography>
- </Grid>
- <Grid item xs={5.5}>
- <Autocomplete
- options={warehouses.filter(w => w.code !== startLocation)}
- getOptionLabel={(option) => option.code || ""}
- value={targetLocation ? warehouses.find(w => w.id === targetLocation) || null : null}
- inputValue={targetLocationInput}
- onInputChange={(event, newInputValue) => {
- setTargetLocationInput(newInputValue);
- if (targetLocation && newInputValue !== warehouses.find(w => w.id === targetLocation)?.code) {
- setTargetLocation(null);
- }
- }}
- onChange={(event, newValue) => {
- if (newValue) {
- setTargetLocation(newValue.id);
- setTargetLocationInput(newValue.code);
- } else {
- setTargetLocation(null);
- setTargetLocationInput("");
- }
- }}
- filterOptions={(options, { inputValue }) => {
- if (!inputValue || inputValue.trim() === "") return options;
- const searchTerm = inputValue.toLowerCase().trim();
- return options.filter((option) =>
- (option.code || "").toLowerCase().includes(searchTerm) ||
- (option.name || "").toLowerCase().includes(searchTerm) ||
- (option.description || "").toLowerCase().includes(searchTerm)
- );
- }}
- isOptionEqualToValue={(option, value) => option.id === value.id}
- autoHighlight={false}
- autoSelect={false}
- clearOnBlur={false}
- renderOption={(props, option) => (
- <li {...props}>
- {option.code}
- </li>
- )}
- renderInput={(params) => (
- <TextField
- {...params}
- label={t("Target Location")}
- variant="outlined"
- fullWidth
- InputLabelProps={{
- shrink: !!targetLocation || !!targetLocationInput,
- sx: { fontSize: "0.9375rem" },
- }}
- />
- )}
- />
- </Grid>
- </Grid>
- <Grid container spacing={1} sx={{ mt: 2 }}>
- <Grid item xs={2}>
- <TextField
- label={t("Original Qty")}
- fullWidth
- variant="outlined"
- value={originalQty}
- disabled
- InputLabelProps={{
- shrink: true,
- sx: { fontSize: "0.9375rem" },
- }}
- />
- </Grid>
- <Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
- <Typography variant="body1">-</Typography>
- </Grid>
- <Grid item xs={2}>
- <TextField
- label={t("Qty To Be Transferred")}
- fullWidth
- variant="outlined"
- type="text"
- inputMode="numeric"
- value={qtyToBeTransferred}
- onChange={(e) => {
- const raw = e.target.value.replace(/\D/g, '');
- if (raw === '') {
- setQtyToBeTransferred('');
- return;
- }
- const parsed = parseInt(raw, 10);
- if (Number.isNaN(parsed)) {
- setQtyToBeTransferred('');
- return;
- }
- if (originalQty < 1) {
- setQtyToBeTransferred('');
- return;
- }
- const clamped = Math.min(Math.max(1, parsed), originalQty);
- setQtyToBeTransferred(String(clamped));
- }}
- onFocus={(e) => (e.target as HTMLInputElement).select()}
- inputProps={{ pattern: "[0-9]*" }}
- InputLabelProps={{
- shrink: true,
- sx: { fontSize: "0.9375rem" },
- }}
- />
- </Grid>
- <Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
- <Typography variant="body1">=</Typography>
- </Grid>
- <Grid item xs={2}>
- <TextField
- label={t("Remaining Qty")}
- fullWidth
- variant="outlined"
- value={remainingQty}
- disabled
- InputLabelProps={{
- shrink: true,
- sx: { fontSize: "0.9375rem" },
- }}
- />
- </Grid>
- <Grid item xs={2}>
- <TextField
- label={t("Stock UoM")}
- fullWidth
- variant="outlined"
- value={selectedLotLine?.uom || ""}
- disabled
- InputLabelProps={{
- shrink: true,
- sx: { fontSize: "0.9375rem" },
- }}
- />
- </Grid>
- <Grid item xs={2} sx={{ display: 'flex', alignItems: 'center' }}>
- <Button
- variant="contained"
- fullWidth
- sx={{
- height: '56px',
- fontSize: '0.9375rem',
- }}
- onClick={handleSubmitStockTransfer}
- disabled={!selectedLotLine || !targetLocation || validatedTransferQty < 1 || validatedTransferQty > originalQty}
- >
- {t("Submit")}
- </Button>
- </Grid>
- </Grid>
- </Card>
- </Modal>
-
- <Modal
- open={printModalOpen}
- onClose={() => setPrintModalOpen(false)}
- sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
- >
- <Card sx={{ position: 'relative', minWidth: 320, maxWidth: 480, p: 3 }}>
- <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
- <Typography variant="h6">{t("Print QR Code")}</Typography>
- <IconButton onClick={() => setPrintModalOpen(false)}><CloseIcon /></IconButton>
- </Box>
- <Grid container spacing={2}>
- <Grid item xs={12}>
- <Autocomplete
- options={(printerCombo || []).filter(printer => printer.type === 'Label')}
- getOptionLabel={(opt) => opt.name ?? opt.label ?? opt.code ?? `Printer ${opt.id}`}
- value={printPrinter}
- onChange={(_, v) => {
- setPrintPrinter(v);
- if (typeof sessionStorage !== 'undefined') {
- if (v?.id != null) sessionStorage.setItem(PRINT_PRINTER_ID_KEY, String(v.id));
- else sessionStorage.removeItem(PRINT_PRINTER_ID_KEY);
- }
- }}
- renderInput={(params) => <TextField {...params} label={t("Printer")} />}
- />
- </Grid>
- <Grid item xs={12}>
- <TextField
- label={t("Print Qty")}
- type="number"
- value={printQty}
- onChange={(e) => setPrintQty(Math.max(1, parseInt(e.target.value) || 1))}
- inputProps={{ min: 1 }}
- fullWidth
- />
- </Grid>
- <Grid item xs={12}>
- <Button variant="contained" fullWidth onClick={handlePrintConfirm} disabled={!printPrinter}>
- {t("Print")}
- </Button>
- </Grid>
- </Grid>
- </Card>
- </Modal>
- <Modal
- open={stockAdjustmentModalOpen}
- onClose={() => setStockAdjustmentModalOpen(false)}
- sx={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- }}
- >
- <Card
- sx={{
- position: 'relative',
- width: '95%',
- maxWidth: '1400px',
- maxHeight: '92vh',
- overflow: 'auto',
- p: 3,
- }}
- >
- <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
- <Typography variant="h6">
- {inventory
- ? `${t("Edit mode")}: ${inventory.itemCode} ${inventory.itemName}`
- : t("Stock Adjustment")
- }
- </Typography>
- <IconButton onClick={() => setStockAdjustmentModalOpen(false)}>
- <CloseIcon />
- </IconButton>
- </Box>
- <Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
- <Button
- variant="contained"
- startIcon={<AddIcon />}
- onClick={handleOpenAddEntry}
- >
- {t("Add entry")}
- </Button>
- <Button
- variant="outlined"
- startIcon={<RestartAltIcon />}
- onClick={handleAdjustmentReset}
- >
- {t("Reset")}
- </Button>
- <Button
- variant="contained"
- color="primary"
- startIcon={<SaveIcon />}
- onClick={handleAdjustmentSave}
- disabled={!hasAdjustmentChange}
- >
- {t("Save")}
- </Button>
- </Box>
-
- {/* List view */}
- <Box sx={{ overflow: 'auto' }}>
- <Table size="small">
- <TableHead>
- <TableRow>
- <TableCell>{t("Lot No")}</TableCell>
- <TableCell align="right">{t("Original Qty")}</TableCell>
- <TableCell align="right">{t("Adjusted Qty")}</TableCell>
- <TableCell align="right" sx={{ minWidth: 100 }}>{t("Difference")}</TableCell>
- <TableCell>{t("Stock UoM")}</TableCell>
- <TableCell>{t("Expiry Date")}</TableCell>
- <TableCell>{t("Location")}</TableCell>
- <TableCell>{t("Remarks")}</TableCell>
- <TableCell align="center" sx={{ minWidth: 240 }}>{t("Action")}</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {adjustmentEntries.map((line) => (
- <TableRow
- key={line.id}
- sx={{
- backgroundColor: pendingRemovalLineId === line.id ? 'action.hover' : undefined,
- }}
- >
- <TableCell>
- <Box component="span" sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
- <span>
- {line.lotNo?.trim() ? line.lotNo : t("No lot no entered, will be generated by system.")}
- {line.isOpeningInventory && ` (${t("Opening Inventory")})`}
- </span>
- {line.productlotNo && <span>{t("productLotNo")}: {line.productlotNo}</span>}
- {line.dnNo && <span>{t("dnNo")}: {line.dnNo}</span>}
- </Box>
- </TableCell>
- <TableCell align="right">{line.originalQty ?? 0}</TableCell>
- <TableCell align="right">
- <TextField
- type="text"
- inputMode="numeric"
- value={String(line.adjustedQty)}
- onChange={(e) => {
- const raw = e.target.value.replace(/\D/g, '');
- if (raw === '') {
- handleAdjustmentQtyChange(line.id, 0);
- return;
- }
- const num = parseInt(raw, 10);
- if (!Number.isNaN(num) && num >= 0) handleAdjustmentQtyChange(line.id, num);
- }}
- inputProps={{ style: { textAlign: 'right' } }}
- size="small"
- sx={{
- width: 120,
- '& .MuiInputBase-root': {
- display: 'flex',
- alignItems: 'center',
- height: 56,
- },
- '& .MuiInputBase-input': {
- fontSize: 16,
- textAlign: 'right',
- height: 40,
- lineHeight: '40px',
- paddingTop: 0,
- paddingBottom: 0,
- boxSizing: 'border-box',
- MozAppearance: 'textfield',
- },
- '& .MuiInputBase-input::-webkit-outer-spin-button': {
- WebkitAppearance: 'none',
- margin: 0,
- },
- '& .MuiInputBase-input::-webkit-inner-spin-button': {
- WebkitAppearance: 'none',
- margin: 0,
- },
- }}
- />
- </TableCell>
- <TableCell align="right" sx={{ minWidth: 100, fontWeight: 700 }}>
- {(() => {
- const diff = line.adjustedQty - (line.originalQty ?? 0);
- const text = diff > 0 ? `+${diff}` : diff < 0 ? `${diff}` : '±0';
- const color = diff > 0 ? 'success.main' : diff < 0 ? 'error.main' : 'text.secondary';
- return <Box component="span" sx={{ color }}>{text}</Box>;
- })()}
- </TableCell>
- <TableCell>{line.uom}</TableCell>
- <TableCell>{arrayToDateString(line.expiryDate)}</TableCell>
- <TableCell>{line.warehouse?.code ?? ""}</TableCell>
- <TableCell>
- {pendingRemovalLineId === line.id ? (
- <TextField
- size="small"
- placeholder={t("Reason for removal")}
- value={removalReasons[line.id] ?? ""}
- onChange={(e) => handleRemovalReasonChange(line.id, e.target.value)}
- sx={{
- width: 160,
- maxWidth: '100%',
- '& .MuiInputBase-root': {
- display: 'flex',
- alignItems: 'center',
- height: 56,
- },
- '& .MuiInputBase-input': {
- fontSize: '1rem',
- height: 40,
- lineHeight: '40px',
- paddingTop: 0,
- paddingBottom: 0,
- boxSizing: 'border-box',
- '&::placeholder': { color: '#9e9e9e', opacity: 1 },
- },
- }}
- />
- ) : (line.adjustedQty - (line.originalQty ?? 0)) !== 0 ? (
- <TextField
- size="small"
- placeholder={t("Reason for adjustment")}
- value={line.remarks ?? ""}
- onChange={(e) => handleAdjustmentRemarksChange(line.id, e.target.value)}
- sx={{
- width: 160,
- maxWidth: '100%',
- '& .MuiInputBase-root': {
- display: 'flex',
- alignItems: 'center',
- height: 56,
- },
- '& .MuiInputBase-input': {
- fontSize: '1rem',
- height: 40,
- lineHeight: '40px',
- paddingTop: 0,
- paddingBottom: 0,
- boxSizing: 'border-box',
- '&::placeholder': { color: '#9e9e9e', opacity: 1 },
- },
- }}
- />
- ) : null}
- </TableCell>
- <TableCell align="center">
- {pendingRemovalLineId === line.id ? (
- <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5 }}>
- <Button size="small" variant="outlined" onClick={handleCancelRemoval}>
- {t("Cancel")}
- </Button>
- <Button
- size="small"
- variant="contained"
- color="error"
- startIcon={<CheckIcon />}
- onClick={() => handleConfirmRemoval(line.id)}
- >
- {t("Confirm remove")}
- </Button>
- </Box>
- ) : (
- <IconButton
- size="small"
- onClick={() => handleRemoveClick(line.id)}
- color="error"
- title={t("Remove")}
- >
- <DeleteIcon fontSize="small" />
- </IconButton>
- )}
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </Box>
- </Card>
- </Modal>
-
- <Modal
- open={addEntryModalOpen}
- onClose={() => setAddEntryModalOpen(false)}
- sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
- >
- <Card sx={{ position: 'relative', minWidth: 600, maxWidth: 900, p: 3 }}>
- <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
- <Typography variant="h6">{t("Add entry")}</Typography>
- <IconButton onClick={() => setAddEntryModalOpen(false)}><CloseIcon /></IconButton>
- </Box>
- <Grid container spacing={2}>
- <Grid item xs={4}>
- <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 }} />
- </Grid>
- <Grid item xs={4}>
- <TextField label={t("Stock UoM")} fullWidth disabled value={inventory?.uomUdfudesc || inventory?.uomShortDesc || inventory?.uomCode || ''} sx={{ '& .MuiInputBase-input': { color: 'text.secondary' } }} InputLabelProps={{ shrink: true }} />
- </Grid>
- <Grid item xs={4}>
- <LocalizationProvider dateAdapter={AdapterDayjs}>
- <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 } }} />
- </LocalizationProvider>
- </Grid>
- <Grid item xs={6}>
- <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 />} />
- </Grid>
- <Grid item xs={6} sx={{ display: 'flex', alignItems: 'center' }}>
- <FormControlLabel control={<Checkbox checked={addEntryForm.isOpeningInventory} onChange={(e) => setAddEntryForm(f => ({ ...f, isOpeningInventory: e.target.checked }))} />} label={t("Opening Inventory")} />
- </Grid>
- <Grid item xs={4}>
- <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 } }} />
- </Grid>
- <Grid item xs={4}>
- <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 } }} />
- </Grid>
- <Grid item xs={4}>
- <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 } }} />
- </Grid>
- <Grid item xs={12}>
- <TextField
- label={t("Remarks")}
- fullWidth
- placeholder={t("Reason for adjustment")}
- value={addEntryForm.remarks}
- onChange={(e) => setAddEntryForm(f => ({ ...f, remarks: e.target.value }))}
- multiline
- minRows={2}
- sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }}
- />
- </Grid>
- <Grid item xs={12}>
- <Button variant="contained" fullWidth onClick={handleAddEntrySubmit} disabled={addEntryForm.qty < 0 || !addEntryForm.expiryDate || !addEntryForm.locationId}>
- {t("Add")}
- </Button>
- </Grid>
- </Grid>
- </Card>
- </Modal>
- </>
-
- }
-
- export default InventoryLotLineTable;
|