Sfoglia il codice sorgente

Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1

reset-do-picking-order
CANCERYS\kw093 3 settimane fa
parent
commit
2040ef798e
9 ha cambiato i file con 749 aggiunte e 19 eliminazioni
  1. +22
    -3
      src/app/api/pdf/actions.ts
  2. +1
    -1
      src/app/api/po/actions.ts
  3. +48
    -0
      src/app/api/stockAdjustment/actions.ts
  4. +2
    -1
      src/app/api/stockIn/actions.ts
  5. +636
    -8
      src/components/InventorySearch/InventoryLotLineTable.tsx
  6. +7
    -1
      src/components/InventorySearch/InventorySearch.tsx
  7. +6
    -2
      src/components/InventorySearch/InventorySearchWrapper.tsx
  8. +2
    -2
      src/components/NavigationContent/NavigationContent.tsx
  9. +25
    -1
      src/i18n/zh/inventory.json

+ 22
- 3
src/app/api/pdf/actions.ts Vedi File

@@ -2,7 +2,7 @@

// import { serverFetchBlob } from "@/app/utils/fetchUtil";
// import { BASE_API_URL } from "@/config/api";
import { serverFetchBlob } from "../../utils/fetchUtil";
import { serverFetchBlob, serverFetchWithNoContent } from "../../utils/fetchUtil";
import { BASE_API_URL } from "../../../config/api";

export interface FileResponse {
@@ -12,7 +12,7 @@ export interface FileResponse {

export const fetchPoQrcode = async (data: any) => {
const reportBlob = await serverFetchBlob<FileResponse>(
`${BASE_API_URL}/stockInLine/print-label`,
`${BASE_API_URL}/stockInLine/download-label`,
{
method: "POST",
body: JSON.stringify(data),
@@ -27,7 +27,7 @@ export interface LotLineToQrcode {
}
export const fetchQrCodeByLotLineId = async (data: LotLineToQrcode) => {
const reportBlob = await serverFetchBlob<FileResponse>(
`${BASE_API_URL}/inventoryLotLine/print-label`,
`${BASE_API_URL}/inventoryLotLine/download-label`,
{
method: "POST",
body: JSON.stringify(data),
@@ -37,3 +37,22 @@ export const fetchQrCodeByLotLineId = async (data: LotLineToQrcode) => {

return reportBlob;
}

export interface PrintLabelForInventoryLotLineRequest {
inventoryLotLineId: number;
printerId: number;
printQty?: number;
}

export async function printLabelForInventoryLotLine(data: PrintLabelForInventoryLotLineRequest) {
const params = new URLSearchParams();
params.append("inventoryLotLineId", data.inventoryLotLineId.toString());
params.append("printerId", data.printerId.toString());
if (data.printQty != null && data.printQty !== undefined) {
params.append("printQty", data.printQty.toString());
}
return serverFetchWithNoContent(
`${BASE_API_URL}/inventoryLotLine/print-label?${params.toString()}`,
{ method: "GET" }
);
}

+ 1
- 1
src/app/api/po/actions.ts Vedi File

@@ -250,7 +250,7 @@ export const testing = cache(async (queryParams?: Record<string, any>) => {
// DEPRECIATED
export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => {
const params = convertObjToURLSearchParams(data)
return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/printQrCode?${params}`,
return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/print-label?${params}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },


+ 48
- 0
src/app/api/stockAdjustment/actions.ts Vedi File

@@ -0,0 +1,48 @@
"use server";
import { BASE_API_URL } from "@/config/api";
import { revalidateTag } from "next/cache";
import { serverFetchJson } from "@/app/utils/fetchUtil";

export interface StockAdjustmentLineRequest {
id: number;
lotNo?: string | null;
adjustedQty: number;
productlotNo?: string | null;
dnNo?: string | null;
isOpeningInventory: boolean;
isNew: boolean;
itemId: number;
itemNo: string;
expiryDate: string;
warehouseId: number;
uom?: string | null;
}

export interface StockAdjustmentRequest {
itemId: number;
originalLines: StockAdjustmentLineRequest[];
currentLines: StockAdjustmentLineRequest[];
}

export interface MessageResponse {
id: number | null;
name: string;
code: string;
type: string;
message: string | null;
errorPosition: string | null;
}

export const submitStockAdjustment = async (data: StockAdjustmentRequest) => {
const result = await serverFetchJson<MessageResponse>(
`${BASE_API_URL}/stockAdjustment/submit`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);
revalidateTag("inventoryLotLines");
revalidateTag("inventories");
return result;
};

+ 2
- 1
src/app/api/stockIn/actions.ts Vedi File

@@ -13,6 +13,7 @@ import { Uom } from "../settings/uom";
import { convertObjToURLSearchParams } from "@/app/utils/commonUtil";
// import { BASE_API_URL } from "@/config/api";
import { Result } from "../settings/item";

export interface PostStockInLineResponse<T> {
id: number | null;
name: string;
@@ -232,7 +233,7 @@ export const testing = cache(async (queryParams?: Record<string, any>) => {

export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => {
const params = convertObjToURLSearchParams(data)
return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/printQrCode?${params}`,
return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/print-label?${params}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },


+ 636
- 8
src/components/InventorySearch/InventoryLotLineTable.tsx Vedi File

@@ -1,10 +1,13 @@
import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory";
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
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, Grid, IconButton, Modal, TextField, Typography, Button } from "@mui/material";
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";
@@ -17,6 +20,30 @@ 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;
@@ -25,10 +52,17 @@ interface Props {
totalCount: number;
inventory: InventoryResult | null;
onStockTransferSuccess?: () => void | Promise<void>;
printerCombo?: PrinterCombo[];
onStockAdjustmentSuccess?: () => void | Promise<void>;
}

const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory, onStockTransferSuccess }) => {
const InventoryLotLineTable: React.FC<Props> = ({
inventoryLotLines, pagingController, setPagingController, totalCount, inventory,
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);
@@ -37,7 +71,27 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
const [targetLocationInput, setTargetLocationInput] = useState<string>("");
const [qtyToBeTransferred, setQtyToBeTransferred] = useState<number>(0);
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()
@@ -46,6 +100,14 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
}
}, [stockTransferModalOpen]);

useEffect(() => {
if (addEntryModalOpen) {
fetchWarehouseListClient()
.then(setWarehouses)
.catch(console.error);
}
}, [addEntryModalOpen]);

const availableLotLines = useMemo(
() => (inventoryLotLines ?? []).filter((line) => line.status?.toLowerCase() === "available"),
[inventoryLotLines]
@@ -53,6 +115,182 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
const originalQty = selectedLotLine?.availableQty || 0;
const remainingQty = originalQty - qtyToBeTransferred;

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] };
@@ -78,6 +316,34 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
[],
);

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)
@@ -163,7 +429,7 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
{
name: "id",
label: t("Print QR Code"),
onClick: () => {},
onClick: handlePrintClick,
buttonIcon: <PrintIcon />,
align: "center",
headerAlign: "center",
@@ -190,9 +456,11 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
// }
// },
],
[t, onDetailClick, downloadQrCode, handleStockTransfer],
[t, onDetailClick, downloadQrCode, handleStockTransfer, handlePrintClick],
);


const handleCloseStockTransferModal = useCallback(() => {
setStockTransferModalOpen(false);
setSelectedLotLine(null);
@@ -234,7 +502,31 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
}, [selectedLotLine, targetLocation, qtyToBeTransferred, handleCloseStockTransferModal, setIsUploading, t, onStockTransferSuccess]);

return <>
<Typography variant="h6">{inventory ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})` : t("No items are selected yet.")}</Typography>
<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}
@@ -428,7 +720,343 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
</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;

+ 7
- 1
src/components/InventorySearch/InventorySearch.tsx Vedi File

@@ -10,9 +10,11 @@ import InventoryTable from "./InventoryTable";
import { defaultPagingController } from "../SearchResults/SearchResults";
import InventoryLotLineTable from "./InventoryLotLineTable";
import { SearchInventory, SearchInventoryLotLine, fetchInventories, fetchInventoryLotLines } from "@/app/api/inventory/actions";
import { PrinterCombo } from "@/app/api/settings/printer";

interface Props {
inventories: InventoryResult[];
printerCombo?: PrinterCombo[];
}

type SearchQuery = Partial<
@@ -32,7 +34,7 @@ type SearchQuery = Partial<
>;
type SearchParamNames = keyof SearchQuery;

const InventorySearch: React.FC<Props> = ({ inventories }) => {
const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
const { t } = useTranslation(["inventory", "common"]);

// Inventory
@@ -249,9 +251,13 @@ const InventorySearch: React.FC<Props> = ({ inventories }) => {
setPagingController={setInventoryLotLinesPagingController}
totalCount={inventoryLotLinesTotalCount}
inventory={selectedInventory}
printerCombo={printerCombo ?? []}
onStockTransferSuccess={() =>
refetchInventoryLotLineData(selectedInventory?.itemId ?? null, "search", inventoryLotLinesPagingController)
}
onStockAdjustmentSuccess={() =>
refetchInventoryLotLineData(selectedInventory?.itemId ?? null, "search", inventoryLotLinesPagingController)
}
/>
</>
);


+ 6
- 2
src/components/InventorySearch/InventorySearchWrapper.tsx Vedi File

@@ -2,15 +2,19 @@ import React from "react";
import GeneralLoading from "../General/GeneralLoading";
import { fetchInventories } from "@/app/api/inventory";
import InventorySearch from "./InventorySearch";
import { fetchPrinterCombo } from "@/app/api/settings/printer";

interface SubComponents {
Loading: typeof GeneralLoading;
}

const InventorySearchWrapper: React.FC & SubComponents = async () => {
const [inventories] = await Promise.all([fetchInventories()]);
const [inventories, printerCombo] = await Promise.all([
fetchInventories(),
fetchPrinterCombo(),
]);

return <InventorySearch inventories={inventories} />;
return <InventorySearch inventories={inventories} printerCombo={printerCombo ?? []} />;
};

InventorySearchWrapper.Loading = GeneralLoading;


+ 2
- 2
src/components/NavigationContent/NavigationContent.tsx Vedi File

@@ -96,7 +96,7 @@ const NavigationContent: React.FC = () => {
{
icon: <AssignmentTurnedIn />,
label: "Stock Take Management",
requiredAbility: [AUTH.STOCK_TAKE, AUTH.ADMIN],
requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.ADMIN],
path: "/stocktakemanagement",
},
{
@@ -120,7 +120,7 @@ const NavigationContent: React.FC = () => {
{
icon: <Description />,
label: "Stock Record",
requiredAbility: [AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN],
requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN],
path: "/stockRecord",
},
],


+ 25
- 1
src/i18n/zh/inventory.json Vedi File

@@ -216,6 +216,9 @@
"Loading": "加載中",
"adj": "調整",
"nor": "正常",
"trf": "轉倉",


"Stock transfer successful": "轉倉成功",
"Failed to transfer stock": "轉倉失敗",
@@ -228,6 +231,27 @@
"Target Location": "目標倉位",
"Original Qty": "原有數量",
"Qty To Be Transferred": "待轉數量",
"Submit": "提交"
"Submit": "提交",

"Printer": "列印機",
"Print Qty": "列印數量",
"Print": "列印",
"Print sent": "已送出列印",
"Print failed": "列印失敗",

"Stock Adjustment": "庫存調整",
"Edit mode": "編輯模式",
"Add entry": "新增倉存",

"productLotNo": "產品批號",
"dnNo": "送貨單編號",
"Optional - system will generate": "選填,系統將自動生成",
"Add": "新增",
"Opening Inventory": "開倉",
"Reason for adjustment": "調整原因",
"No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。",
"Reason for removal": "移除原因",
"Confirm remove": "確認移除",
"Adjusted Qty": "調整後倉存"

}

Caricamento…
Annulla
Salva