Procházet zdrojové kódy

Merge branch 'production' into POroundupdown

# Conflicts:
#	src/components/PoDetail/PoDetail.tsx
#	src/i18n/en/purchaseOrder.json
#	src/i18n/zh/purchaseOrder.json
production
CANCERYS\kw093 před 1 týdnem
rodič
revize
d0f1b85ad6
30 změnil soubory, kde provedl 1144 přidání a 763 odebrání
  1. +2
    -2
      src/app/(main)/po/edit/page.tsx
  2. +1
    -1
      src/app/api/inventory/actions.ts
  3. +22
    -0
      src/app/api/settings/item/actions.ts
  4. +14
    -0
      src/app/api/stockAdjustment/actions.ts
  5. +13
    -2
      src/app/api/stockIssue/actions.ts
  6. +67
    -0
      src/app/api/stockIssue/client.ts
  7. +3
    -24
      src/components/BagPrint/BagPrintSearch.tsx
  8. +7
    -15
      src/components/DoDetail/DoDetail.tsx
  9. +13
    -2
      src/components/DoSearch/DoSearch.tsx
  10. +11
    -1
      src/components/DoSearchWorkbench/DoSearchWorkbench.tsx
  11. +64
    -17
      src/components/InventorySearch/InventoryLotLineTable.tsx
  12. +111
    -285
      src/components/InventorySearch/InventorySearch.tsx
  13. +12
    -14
      src/components/PoDetail/PoDetail.tsx
  14. +42
    -92
      src/components/PoDetail/PoDetailRow.tsx
  15. +158
    -62
      src/components/PoDetail/PoInputGrid.tsx
  16. +1
    -1
      src/components/PoDetail/QcStockInModal.tsx
  17. +100
    -58
      src/components/PoDetail/StockInLineRowActions.tsx
  18. +6
    -1
      src/components/PoDetail/stockQtyRound.ts
  19. +6
    -5
      src/components/PoSearch/PoSearch.tsx
  20. +5
    -27
      src/components/Qc/QcStockInModal.tsx
  21. +1
    -63
      src/components/StockIn/StockInForm.tsx
  22. +372
    -65
      src/components/StockIssue/ExpiryHandleTab.tsx
  23. +45
    -18
      src/components/StockIssue/StockIssueRecordTab.tsx
  24. +22
    -2
      src/components/StockIssue/StockIssueSearchPanel.tsx
  25. +3
    -0
      src/i18n/en/do.json
  26. +1
    -0
      src/i18n/en/purchaseOrder.json
  27. +18
    -1
      src/i18n/en/stockIssue.json
  28. +2
    -1
      src/i18n/zh/do.json
  29. +1
    -0
      src/i18n/zh/purchaseOrder.json
  30. +21
    -4
      src/i18n/zh/stockIssue.json

+ 2
- 2
src/app/(main)/po/edit/page.tsx Zobrazit soubor

@@ -16,11 +16,11 @@ type Props = {} & SearchParams;
const PoEdit: React.FC<Props> = async ({ searchParams }) => {
const type = "purchaseOrder";
const { t } = await getServerI18n(type);
console.log(searchParams["id"]);
//console.log(searchParams["id"]);
const id = isString(searchParams["id"])
? parseInt(searchParams["id"])
: undefined;
console.log(id);
//console.log(id);
if (!id) {
notFound();
}


+ 1
- 1
src/app/api/inventory/actions.ts Zobrazit soubor

@@ -172,7 +172,7 @@ async function fetchInventoriesLatestImpl(data: SearchInventory) {
export const fetchInventories = cache(fetchInventoriesImpl);

/**
* FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17
* FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07
* Inventory search page: latest inventory row per item (no baseUnit/uomId filter).
*/
export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl);


+ 22
- 0
src/app/api/settings/item/actions.ts Zobrazit soubor

@@ -138,6 +138,28 @@ export const fetchItemsWithDetails = cache(async (searchParams?: Record<string,
}
});

/** Paged item lookup for Inventory Search (code / name). */
export const fetchItemsByPage = cache(async (queryParams?: Record<string, string | number>) => {
const searchParams = new URLSearchParams();
if (queryParams) {
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined && value !== null && `${value}` !== "") {
searchParams.set(key, String(value));
}
});
}
const queryString = searchParams.toString();
return serverFetchJson<RecordsRes<ItemsResult>>(
queryString
? `${BASE_API_URL}/items/getRecordByPage?${queryString}`
: `${BASE_API_URL}/items/getRecordByPage`,
{
method: "GET",
next: { tags: ["items"] },
},
);
});

export const fetchAllItemsInClient = cache(async () => {
return serverFetchJson<ItemCombo[]>(`${BASE_API_URL}/items/consumables`, {
next: { tags: ["items"] },


+ 14
- 0
src/app/api/stockAdjustment/actions.ts Zobrazit soubor

@@ -16,6 +16,7 @@ export interface StockAdjustmentLineRequest {
expiryDate: string;
warehouseId: number;
uom?: string | null;
remarks?: string | null;
}

export interface StockAdjustmentRequest {
@@ -33,6 +34,19 @@ export interface MessageResponse {
errorPosition: string | null;
}

export interface StockAdjustmentRemarksResponse {
lotNo: string | null;
remarks: string;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */
export const fetchLatestAdjustmentRemarks = async (itemId: number) => {
return serverFetchJson<StockAdjustmentRemarksResponse[]>(
`${BASE_API_URL}/stockAdjustment/latestRemarks?itemId=${itemId}`,
{ method: "GET" },
);
};

export const submitStockAdjustment = async (data: StockAdjustmentRequest) => {
const result = await serverFetchJson<MessageResponse>(
`${BASE_API_URL}/stockAdjustment/submit`,


+ 13
- 2
src/app/api/stockIssue/actions.ts Zobrazit soubor

@@ -17,12 +17,17 @@ export interface ExpiryItemResult {
storeLocation: string | null;
expiryDate: string | null;
remainingQty: number;
uomDesc?: string | null;
/** True when expiryDate is today or earlier. */
canHandle?: boolean;
}

export interface ExpiryItemFilter {
expiryDate?: string;
itemCode?: string;
itemName?: string;
lotNo?: string;
/** Inclusive lookahead from today; default 7. */
daysAhead?: number;
}

export interface HandleBadItemRequest {
@@ -54,6 +59,8 @@ export interface StockIssueHandleRecord {
export interface SearchStockIssueRecordParams {
startDate?: string;
endDate?: string;
handledStartDate?: string;
handledEndDate?: string;
itemCode?: string;
itemName?: string;
lotNo?: string;
@@ -61,11 +68,13 @@ export interface SearchStockIssueRecordParams {
pageSize?: number;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */
export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => {
const params = new URLSearchParams();
if (filters?.expiryDate) params.set("expiryDate", filters.expiryDate);
if (filters?.itemCode) params.set("itemCode", filters.itemCode);
if (filters?.itemName) params.set("itemName", filters.itemName);
if (filters?.lotNo) params.set("lotNo", filters.lotNo);
if (filters?.daysAhead != null) params.set("daysAhead", String(filters.daysAhead));
const queryString = params.toString();
const url = `${BASE_API_URL}/pickExecution/issues/expiryItem${queryString ? `?${queryString}` : ""}`;
return serverFetchJson<ExpiryItemResult[]>(url, {
@@ -107,6 +116,8 @@ export async function fetchExpiryItemRecords(params: SearchStockIssueRecordParam
const qs = new URLSearchParams();
if (params.startDate) qs.set("startDate", params.startDate);
if (params.endDate) qs.set("endDate", params.endDate);
if (params.handledStartDate) qs.set("handledStartDate", params.handledStartDate);
if (params.handledEndDate) qs.set("handledEndDate", params.handledEndDate);
if (params.itemCode) qs.set("itemCode", params.itemCode);
if (params.itemName) qs.set("itemName", params.itemName);
if (params.lotNo) qs.set("lotNo", params.lotNo);


+ 67
- 0
src/app/api/stockIssue/client.ts Zobrazit soubor

@@ -0,0 +1,67 @@
"use client";

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import type { ExpiryItemFilter } from "@/app/api/stockIssue/actions";

/** Result tab currently shown; backend may filter the workbook by this bucket. */
export type ExpiryExportBucket = "expired" | "today" | "upcoming";

export interface ExportExpiryItemExcelParams extends ExpiryItemFilter {
bucket?: ExpiryExportBucket;
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07
* GET /pickExecution/issues/expiryItem/excel
* Query: itemCode, itemName, lotNo, daysAhead, bucket (expired|today|upcoming; omit for all categories)
* Response: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
* Optional Content-Disposition filename.
*/
export async function exportExpiryItemExcel(
filters: ExportExpiryItemExcelParams,
): Promise<void> {
const params = new URLSearchParams();
if (filters.itemCode) params.set("itemCode", filters.itemCode);
if (filters.itemName) params.set("itemName", filters.itemName);
if (filters.lotNo) params.set("lotNo", filters.lotNo);
if (filters.daysAhead != null) params.set("daysAhead", String(filters.daysAhead));
if (filters.bucket) params.set("bucket", filters.bucket);

const queryString = params.toString();
const url = `${NEXT_PUBLIC_API_URL}/pickExecution/issues/expiryItem/excel${queryString ? `?${queryString}` : ""}`;

const response = await clientAuthFetch(url, {
method: "GET",
headers: {
Accept: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
});

if (response.status === 401 || response.status === 403) {
throw new Error("Unauthorized");
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;

const contentDisposition = response.headers.get("Content-Disposition");
let fileName = "expiry-items.xlsx";
if (contentDisposition?.includes("filename=")) {
fileName = contentDisposition
.split("filename=")[1]
.split(";")[0]
.replace(/"/g, "");
}

link.setAttribute("download", fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
}

+ 3
- 24
src/components/BagPrint/BagPrintSearch.tsx Zobrazit soubor

@@ -55,6 +55,7 @@ import {
import dayjs from "dayjs";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import { AUTH, hasAbility } from "@/authorities";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";

@@ -77,28 +78,6 @@ const REFRESH_MS = 60 * 1000;
const PRINTER_CHECK_MS = 60 * 1000;
const PRINTER_RETRY_MS = 30 * 1000;
const SETTINGS_KEY = "bagPrint_settings";
const ONPACK_ADMIN_USERNAME = "2fi";

/** Login username from backend JWT `sub` (UserDetails.username). */
function loginUsernameFromSession(session: SessionWithTokens | null | undefined): string {
const token = session?.accessToken?.trim();
if (token) {
try {
const parts = token.split(".");
if (parts.length >= 2) {
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
const payload = JSON.parse(atob(padded)) as { sub?: unknown };
if (typeof payload.sub === "string" && payload.sub.trim()) {
return payload.sub.trim();
}
}
} catch {
// fall through to display name
}
}
return (session?.user?.name ?? "").trim();
}

const DEFAULT_SETTINGS = {
dabag_ip: "",
@@ -221,8 +200,8 @@ function sortExpiryRows(

const BagPrintSearch: React.FC = () => {
const { data: session } = useSession() as { data: SessionWithTokens | null };
const canSeeOnPackAdmin =
loginUsernameFromSession(session).toLowerCase() === ONPACK_ADMIN_USERNAME;
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
const canSeeOnPackAdmin = hasAbility(abilities, AUTH.ADMIN);
const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD"));
const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]);
const [loading, setLoading] = useState(true);


+ 7
- 15
src/components/DoDetail/DoDetail.tsx Zobrazit soubor

@@ -26,6 +26,7 @@ type Props = {
workbenchRelease?: boolean;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */
const DoDetail: React.FC<Props> = ({
defaultValues,
id,
@@ -57,22 +58,13 @@ const DoDetail: React.FC<Props> = ({
setSuccessMessage("")
if (id) {
// Get current user ID from session
//const currentUserId = session?.id ? parseInt(session.id) : undefined;
//if (!currentUserId) {
// setServerError("User session not found. Please login again.");
// return;
//}
/*
const response = await releaseDo({
id: id,
//userId: currentUserId // Pass user ID from session
})
*/
if (!currentUserId) {
setServerError(t("User session not found"));
return;
}
const response = await startWorkbenchBatchReleaseAsyncSingleV2({
doId: id,
userId: currentUserId ?? 0
userId: currentUserId
})
if (response?.code === "STARTED") {
setSuccessMessage(t("DO released successfully! Pick orders created."));
@@ -91,7 +83,7 @@ const DoDetail: React.FC<Props> = ({
} finally {
setIsUploading(false)
}
}, [id, formProps, t, setIsUploading, session]) // Add session to dependencies
}, [id, formProps, t, setIsUploading, session, currentUserId, router])

// UPDATE STORE-BASED ASSIGNMENT HANDLERS
const handleAssignByStore = useCallback(async (storeId: string) => {


+ 13
- 2
src/components/DoSearch/DoSearch.tsx Zobrazit soubor

@@ -81,6 +81,7 @@ function isTruckLaneSearchMissingEta(truckLanceCode: string, estimatedArrivalDat
return truckLanceCode.trim() !== "" && estimatedArrivalDate.trim() === "";
}

/** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */
const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSearch }) => {
const apiRef = useGridApiRef();

@@ -509,6 +510,16 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea

const handleBatchRelease = useCallback(async (isWorkbench: boolean) => {
try {
if (!currentUserId) {
await Swal.fire({
icon: "error",
title: t("Error"),
text: t("User session not found"),
confirmButtonText: t("OK"),
didOpen: (popup) => applyMainContentAreaSwalOffset(popup),
});
return;
}
const tabFilter = resolveTabFilter(activeTab);
const tabTruckKeyword = tabFilter.forceTruckKeyword ?? "";
const effectiveTruckLanceCode = tabTruckKeyword || currentSearchParams.truckLanceCode || "";
@@ -659,12 +670,12 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
if(isWorkbench){
startRes = await startWorkbenchBatchReleaseAsyncV2({
ids: idsToRelease,
userId: currentUserId ?? 1,
userId: currentUserId,
mergeExtraIntoLaneTicket,
});
}
else{
startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 });
startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId });
}
//await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 });
const jobId = startRes?.entity?.jobId;


+ 11
- 1
src/components/DoSearchWorkbench/DoSearchWorkbench.tsx Zobrazit soubor

@@ -61,6 +61,7 @@ function isTruckLaneSearchMissingEta(truckLanceCode: string, estimatedArrivalDat
return truckLanceCode.trim() !== "" && estimatedArrivalDate.trim() === "";
}

/** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */
const DoSearchWorkbench: React.FC<Props> = ({
filterArgs,
searchQuery,
@@ -538,6 +539,15 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {

const handleBatchRelease = useCallback(async () => {
try {
if (!currentUserId) {
await Swal.fire({
icon: "error",
title: t("Error"),
text: t("User session not found"),
confirmButtonText: t("OK"),
});
return;
}
if (
isTruckLaneSearchMissingEta(
currentSearchParams.truckLanceCode ?? "",
@@ -657,7 +667,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {
(result.value as { mergeExtraIntoLaneTicket?: boolean } | undefined)?.mergeExtraIntoLaneTicket ?? false;
const startRes = await startWorkbenchBatchReleaseAsyncV2({
ids: idsToRelease,
userId: currentUserId ?? 1,
userId: currentUserId,
mergeExtraIntoLaneTicket,
});
const startEntity = startRes?.entity as { jobId?: string } | undefined;


+ 64
- 17
src/components/InventorySearch/InventoryLotLineTable.tsx Zobrazit soubor

@@ -32,9 +32,9 @@ 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";
import { submitStockAdjustment, StockAdjustmentLineRequest, fetchLatestAdjustmentRemarks } from "@/app/api/stockAdjustment/actions";
import { useSession } from "next-auth/react";
import { AUTH } from "@/authorities";
import { AUTH, hasAbility } from "@/authorities";

type AdjustmentEntry = InventoryLotLineResult & {
adjustedQty: number;
@@ -59,7 +59,7 @@ interface Props {
onStockAdjustmentSuccess?: () => void | Promise<void>;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.5 | 2026-08-05 */
/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */
const InventoryLotLineTable: React.FC<Props> = ({
inventoryLotLines, pagingController, setPagingController, totalCount, inventory,
filterLotNo,
@@ -68,8 +68,8 @@ const InventoryLotLineTable: React.FC<Props> = ({
}) => {
const { t } = useTranslation(["inventory"]);
const { data: session } = useSession();
const abilities = session?.user?.abilities ?? [];
const canStockAdjust = abilities.includes(AUTH.INVENTORY_ADJUST);
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
const canStockAdjust = hasAbility(abilities, AUTH.INVENTORY_ADJUST);
const PRINT_PRINTER_ID_KEY = 'inventoryLotLinePrintPrinterId';
const { setIsUploading } = useUploadContext();
const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false);
@@ -99,7 +99,11 @@ const InventoryLotLineTable: React.FC<Props> = ({
remarks: '',
});
const originalAdjustmentLinesRef = useRef<AdjustmentEntry[]>([]);
const adjustSaveInFlightRef = useRef(false);
const loadedRemarksByLotRef = useRef<Map<string, string>>(new Map());
const remarksFetchGenRef = useRef(0);
const [adjustmentEntries, setAdjustmentEntries] = useState<AdjustmentEntry[]>([]);
const [isAdjustSaving, setIsAdjustSaving] = useState(false);
useEffect(() => {
if (stockTransferModalOpen) {
fetchWarehouseListClient()
@@ -153,9 +157,34 @@ const prevAdjustmentModalOpenRef = useRef(false);
}));
setAdjustmentEntries(initial);
originalAdjustmentLinesRef.current = initial;
loadedRemarksByLotRef.current = new Map();
const fetchGen = ++remarksFetchGenRef.current;
const itemId = inventory.itemId;
fetchLatestAdjustmentRemarks(itemId)
.then((rows) => {
if (fetchGen !== remarksFetchGenRef.current) return;
const byLot = new Map<string, string>();
for (const row of rows ?? []) {
const lot = row.lotNo?.trim();
const remarks = row.remarks?.trim();
if (!lot || !remarks || byLot.has(lot)) continue;
byLot.set(lot, remarks);
}
loadedRemarksByLotRef.current = byLot;
const apply = (line: AdjustmentEntry): AdjustmentEntry => {
const lot = line.lotNo?.trim();
const remarks = (lot && byLot.get(lot)) || line.remarks || '';
return { ...line, remarks };
};
setAdjustmentEntries((prev) => prev.map(apply));
originalAdjustmentLinesRef.current = originalAdjustmentLinesRef.current.map(apply);
})
.catch(console.error);
}
setPendingRemovalLineId(null);
setRemovalReasons({});
} else if (!stockAdjustmentModalOpen) {
remarksFetchGenRef.current += 1;
}
}, [stockAdjustmentModalOpen, inventory, availableLotLines]);

@@ -164,12 +193,15 @@ const prevAdjustmentModalOpenRef = useRef(false);
setPendingRemovalLineId(null);
setRemovalReasons({});
setAdjustmentEntries(
(availableLotLines ?? []).map((line) => ({
...line,
adjustedQty: line.availableQty ?? 0,
originalQty: line.availableQty ?? 0,
remarks: '',
}))
(availableLotLines ?? []).map((line) => {
const lot = line.lotNo?.trim();
return {
...line,
adjustedQty: line.availableQty ?? 0,
originalQty: line.availableQty ?? 0,
remarks: (lot && loadedRemarksByLotRef.current.get(lot)) || '',
};
})
);
}, [availableLotLines]);

@@ -241,15 +273,26 @@ const prevAdjustmentModalOpenRef = useRef(false);
expiryDate,
warehouseId: line.warehouse?.id ?? 0,
uom: line.uom ?? null,
remarks: line.remarks?.trim() || 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));
if (adjustSaveInFlightRef.current) return;
adjustSaveInFlightRef.current = true;
setIsAdjustSaving(true);
try {
const itemCode = inventory.itemCode;
const currentIds = new Set(adjustmentEntries.map((line) => line.id));
const originalLines = originalAdjustmentLinesRef.current.map((line) => {
const api = toApiLine(line, itemCode);
if (!currentIds.has(line.id)) {
api.remarks = removalReasons[line.id]?.trim() || null;
}
return api;
});
const currentLines = adjustmentEntries.map((line) => toApiLine(line, itemCode));
setIsUploading(true);
await submitStockAdjustment({
itemId: inventory.itemId,
@@ -264,8 +307,10 @@ const prevAdjustmentModalOpenRef = useRef(false);
msgError(message || t("Save failed"));
} finally {
setIsUploading(false);
setIsAdjustSaving(false);
adjustSaveInFlightRef.current = false;
}
}, [adjustmentEntries, inventory, t, toApiLine, onStockAdjustmentSuccess]);
}, [adjustmentEntries, inventory, t, toApiLine, onStockAdjustmentSuccess, removalReasons]);

const handleOpenAddEntry = useCallback(() => {
setAddEntryForm({
@@ -857,7 +902,7 @@ const prevAdjustmentModalOpenRef = useRef(false);
color="primary"
startIcon={<SaveIcon />}
onClick={handleAdjustmentSave}
disabled={!hasAdjustmentChange}
disabled={!hasAdjustmentChange || isAdjustSaving}
>
{t("Save")}
</Button>
@@ -1004,7 +1049,9 @@ const prevAdjustmentModalOpenRef = useRef(false);
},
}}
/>
) : null}
) : (
line.remarks || null
)}
</TableCell>
<TableCell align="center">
{pendingRemovalLineId === line.id ? (


+ 111
- 285
src/components/InventorySearch/InventorySearch.tsx Zobrazit soubor

@@ -2,7 +2,7 @@
import { InventoryLotLineResult, InventoryResult } from '@/app/api/inventory';
import { useTranslation } from 'react-i18next';
import SearchBox, { Criterion } from '../SearchBox';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { uniq, uniqBy } from 'lodash';
import InventoryTable from './InventoryTable';
import { defaultPagingController } from '../SearchResults/SearchResults';
@@ -16,23 +16,10 @@ import {
fetchInventoryLotLines,
} from '@/app/api/inventory/actions';
import { PrinterCombo } from '@/app/api/settings/printer';
import { ItemCombo, fetchItemsWithDetails, ItemWithDetails } from '@/app/api/settings/item/actions';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
Box,
CircularProgress,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Radio,
} from '@mui/material';
import { fetchItemsByPage } from '@/app/api/settings/item/actions';
import { useSession } from 'next-auth/react';
import { AUTH, hasAbility } from '@/authorities';
import { Button, Box } from '@mui/material';

interface Props {
inventories: InventoryResult[];
@@ -56,42 +43,68 @@ type SearchQuery = Partial<
>;
type SearchParamNames = keyof SearchQuery;

/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.0 | 2026-07-17 */
type ItemLookupRow = {
id: number;
code: string;
name: string;
type?: string;
uom?: string;
uomDesc?: string;
purchaseUnit?: string;
};

const extractItemRecords = (res: unknown): ItemLookupRow[] => {
if (!res) return [];
if (Array.isArray(res)) return res as ItemLookupRow[];
if (typeof res === 'object' && Array.isArray((res as { records?: unknown }).records)) {
return (res as { records: ItemLookupRow[] }).records;
}
return [];
};

/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07 */
const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
const { t } = useTranslation(['inventory', 'common', 'item']);

const buildSyntheticInventory = useCallback(
(item: ItemWithDetails): InventoryResult => ({
const { data: session } = useSession();
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
const canStockAdjust = hasAbility(abilities, AUTH.INVENTORY_ADJUST);
const searchInFlightRef = useRef(false);

const buildSyntheticInventory = useCallback((item: ItemLookupRow): InventoryResult => {
const uom = item.uomDesc || item.uom || item.purchaseUnit || '';
return {
id: 0,
itemId: item.id,
itemId: Number(item.id),
itemCode: item.code,
itemName: item.name,
itemType: 'Material',
itemType: item.type || 'mat',
onHandQty: 0,
onHoldQty: 0,
unavailableQty: 0,
availableQty: 0,
uomCode: item.uom,
uomUdfudesc: item.uomDesc,
uomShortDesc: item.uom,
uomCode: item.uom || uom,
uomUdfudesc: uom,
uomShortDesc: item.uom || uom,
qtyPerSmallestUnit: 1,
baseUom: item.uom,
baseUom: uom,
price: 0,
currencyName: '',
status: 'active',
latestMarketUnitPrice: undefined,
latestMupUpdatedDate: undefined,
}),
[],
);

const getFirstItemRecord = useCallback((res: any): ItemWithDetails | null => {
if (!res) return null;
if (Array.isArray(res)) return (res[0] as ItemWithDetails) ?? null;
if (Array.isArray(res?.records)) return (res.records[0] as ItemWithDetails) ?? null;
return null;
};
}, []);

const lookupItemsByCodeOrName = useCallback(async (code?: string, name?: string) => {
const trimmedCode = code?.trim();
const trimmedName = name?.trim();
if (!trimmedCode && !trimmedName) return [];
const params: Record<string, string | number> = { pageSize: 50, pageNum: 1 };
if (trimmedCode) params.code = trimmedCode;
else params.name = trimmedName as string;
const itemRes = await fetchItemsByPage(params);
return extractItemRecords(itemRes);
}, []);

// Inventory
const [filteredInventories, setFilteredInventories] = useState<InventoryResult[]>([]);
@@ -104,6 +117,20 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
const [inventoryLotLinesPagingController, setInventoryLotLinesPagingController] = useState(defaultPagingController)
const [inventoryLotLinesTotalCount, setInventoryLotLinesTotalCount] = useState(0)

const applyItemsAsSyntheticInventories = useCallback(
(items: ItemLookupRow[]) => {
if (!items.length) return false;
const synthetics = items.map(buildSyntheticInventory);
setFilteredInventories(synthetics);
setInventoriesTotalCount(synthetics.length);
setSelectedInventory(synthetics[0]);
setFilteredInventoryLotLines([]);
setInventoryLotLinesPagingController(() => defaultPagingController);
return true;
},
[buildSyntheticInventory],
);

// Scan-mode UI (hardware QR scanner via QrCodeScannerProvider)
const qrScanner = useQrCodeScannerContext();
const [scanUiMode, setScanUiMode] = useState<'idle' | 'scanning'>('idle');
@@ -113,13 +140,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
const [lotNoFilter, setLotNoFilter] = useState('');
const [scannedItemId, setScannedItemId] = useState<number | null>(null);

// Opening inventory (pure opening stock for items without existing inventory)
const [openingItems, setOpeningItems] = useState<ItemCombo[]>([]);
const [openingModalOpen, setOpeningModalOpen] = useState(false);
const [openingSelectedItem, setOpeningSelectedItem] = useState<ItemCombo | null>(null);
const [openingLoading, setOpeningLoading] = useState(false);
const [openingSearchText, setOpeningSearchText] = useState('');

const defaultInputs = useMemo(
() => ({
itemId: '',
@@ -297,46 +317,55 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
// On Search
const onSearch = useCallback(
async (query: Record<SearchParamNames, string>) => {
setLotNoFilter('');
setScannedItemId(null);
setScanUiMode('idle');
setScanHoverCancel(false);
qrScanner.stopScan();
qrScanner.resetScan();
const invRes = await refetchInventoryData(query, 'search', defaultPagingController, '');
await refetchInventoryLotLineData(null, 'search', defaultPagingController);

setInputs(() => query);
setInventoriesPagingController(() => defaultPagingController);
setInventoryLotLinesPagingController(() => defaultPagingController);
if (searchInFlightRef.current) return;
searchInFlightRef.current = true;
try {
setLotNoFilter('');
setScannedItemId(null);
setScanUiMode('idle');
setScanHoverCancel(false);
qrScanner.stopScan();
qrScanner.resetScan();
const invRes = await refetchInventoryData(query, 'search', defaultPagingController, '');
await refetchInventoryLotLineData(null, 'search', defaultPagingController);

setInputs(() => query);
setInventoriesPagingController(() => defaultPagingController);
setInventoryLotLinesPagingController(() => defaultPagingController);

// If there are no inventory rows, render a synthetic inventory so the "Stock Adjustment" chip can be used.
if (invRes?.records?.length === 0) {
try {
const code = query.itemCode?.trim?.();
const name = query.itemName?.trim?.();
const lookupParams = code ? { code } : name ? { name } : null;

if (lookupParams) {
const itemRes = await fetchItemsWithDetails(lookupParams);
const firstItem = getFirstItemRecord(itemRes);
if (firstItem) {
setSelectedInventory(buildSyntheticInventory(firstItem));
setFilteredInventoryLotLines([]);
setInventoryLotLinesPagingController(() => defaultPagingController);
// No inventory rows: look up item master so 0-qty rows can be selected for stock adjustment.
if (canStockAdjust && invRes?.records?.length === 0) {
try {
const items = await lookupItemsByCodeOrName(query.itemCode, query.itemName);
const typeFilter = query.itemType?.trim();
let filtered =
typeFilter && typeFilter.toLowerCase() !== 'all'
? items.filter((it) => (it.type ?? '').toLowerCase() === typeFilter.toLowerCase())
: items;
const exactCode = query.itemCode?.trim().toLowerCase();
if (exactCode) {
filtered = [...filtered].sort((a, b) => {
const aExact = a.code?.toLowerCase() === exactCode ? 0 : 1;
const bExact = b.code?.toLowerCase() === exactCode ? 0 : 1;
return aExact - bExact;
});
}
applyItemsAsSyntheticInventories(filtered);
} catch (e) {
console.error('Failed to build synthetic inventory:', e);
}
} catch (e) {
console.error('Failed to build synthetic inventory:', e);
}
} finally {
searchInFlightRef.current = false;
}
},
[
qrScanner,
refetchInventoryData,
refetchInventoryLotLineData,
buildSyntheticInventory,
getFirstItemRecord,
lookupItemsByCodeOrName,
applyItemsAsSyntheticInventories,
canStockAdjust,
],
);

@@ -382,13 +411,11 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
onInventoryRowClick(target);
} else {
refetchInventoryLotLineData(null, 'search', defaultPagingController);
// No inventory rows for this scanned item => show synthetic inventory with the existing chip workflow.
const itemRes = await fetchItemsWithDetails({ code: res?.itemCode });
const firstItem = getFirstItemRecord(itemRes);
if (firstItem) {
setSelectedInventory(buildSyntheticInventory(firstItem));
setFilteredInventoryLotLines([]);
setInventoryLotLinesPagingController(() => defaultPagingController);
if (canStockAdjust) {
const items = await lookupItemsByCodeOrName(res?.itemCode);
if (!applyItemsAsSyntheticInventories(items)) {
setSelectedInventory(null);
}
} else {
setSelectedInventory(null);
}
@@ -410,112 +437,12 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
qrScanner.result,
refetchInventoryData,
refetchInventoryLotLineData,
buildSyntheticInventory,
getFirstItemRecord,
lookupItemsByCodeOrName,
applyItemsAsSyntheticInventories,
canStockAdjust,
scanUiMode,
]);

//console.log('', 'color: #666', inventoriesPagingController);

const handleOpenOpeningInventoryModal = useCallback(() => {
setOpeningSelectedItem(null);
setOpeningItems([]);
setOpeningSearchText('');
setOpeningModalOpen(true);
}, []);

const handleOpeningSearch = useCallback(async () => {
const trimmed = openingSearchText.trim();
if (!trimmed) {
setOpeningItems([]);
return;
}

setOpeningLoading(true);
try {
const searchParams: Record<string, any> = {
pageSize: 50,
pageNum: 1,
};

// Heuristic: if input contains space, treat as name; otherwise treat as code.
if (trimmed.includes(' ')) {
searchParams.name = trimmed;
} else {
searchParams.code = trimmed;
}

const response = await fetchItemsWithDetails(searchParams);

let records: any[] = [];
if (response && typeof response === 'object') {
const anyRes = response as any;
if (Array.isArray(anyRes.records)) {
records = anyRes.records;
} else if (Array.isArray(anyRes)) {
records = anyRes;
}
}

const combos: ItemCombo[] = records.map((item: any) => ({
id: item.id,
label: `${item.code} - ${item.name}`,
uomId: item.uomId,
uom: item.uom,
uomDesc: item.uomDesc,
group: item.group,
currentStockBalance: item.currentStockBalance,
}));

setOpeningItems(combos);
} catch (e) {
console.error('Failed to search items for opening inventory:', e);
setOpeningItems([]);
} finally {
setOpeningLoading(false);
}
}, [openingSearchText]);

const handleConfirmOpeningInventory = useCallback(() => {
if (!openingSelectedItem) {
setOpeningModalOpen(false);
return;
}

const rawLabel = openingSelectedItem.label ?? '';
const [codePart, ...nameParts] = rawLabel.split(' - ');
const itemCode = codePart?.trim() || rawLabel;
const itemName = nameParts.join(' - ').trim() || itemCode;

const syntheticInventory: InventoryResult = {
id: 0,
itemId: Number(openingSelectedItem.id),
itemCode,
itemName,
itemType: 'Material',
onHandQty: 0,
onHoldQty: 0,
unavailableQty: 0,
availableQty: 0,
uomCode: openingSelectedItem.uom,
uomUdfudesc: openingSelectedItem.uomDesc,
uomShortDesc: openingSelectedItem.uom,
qtyPerSmallestUnit: 1,
baseUom: openingSelectedItem.uom,
price: 0,
currencyName: '',
status: 'active',
latestMarketUnitPrice: undefined,
latestMupUpdatedDate: undefined,
};

// Use this synthetic inventory to drive the stock adjustment UI
setSelectedInventory(syntheticInventory);
setFilteredInventoryLotLines([]);
setInventoryLotLinesPagingController(() => defaultPagingController);
setOpeningModalOpen(false);
}, [openingSelectedItem]);

return (
<>
<SearchBox
@@ -540,15 +467,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
</Button>
</>
)}

<Button
variant="outlined"
color="secondary"
onClick={handleOpenOpeningInventoryModal}
sx={{ display: 'none' }}
>
{t('Add entry for items without inventory')}
</Button>
</Box>
}
/>
@@ -600,98 +518,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
}
}}
/>

<Dialog
open={openingModalOpen}
onClose={() => setOpeningModalOpen(false)}
fullWidth
maxWidth="md"
>
<DialogTitle>{t('Add entry for items without inventory')}</DialogTitle>
<DialogContent sx={{ pt: 2 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<TextField
label={t('Item')}
fullWidth
value={openingSearchText}
onChange={(e) => setOpeningSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleOpeningSearch();
}
}}
sx={{ flex: 2 }}
/>
<Button
variant="contained"
onClick={handleOpeningSearch}
disabled={openingLoading}
sx={{ flex: 1 }}
>
{openingLoading ? <CircularProgress size={20} /> : t('common:Search')}
</Button>
</Box>

{openingItems.length === 0 && !openingLoading ? (
<Box sx={{ py: 1, color: 'text.secondary', fontSize: 14 }}>
{openingSearchText
? t('No data')
: t('Enter item code or name to search')}
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell />
<TableCell>{t('Code')}</TableCell>
<TableCell>{t('Name')}</TableCell>
<TableCell>{t('UoM')}</TableCell>
<TableCell align="right">{t('Current Stock')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{openingItems.map((it) => {
const [code, ...nameParts] = (it.label ?? '').split(' - ');
const name = nameParts.join(' - ');
const selected = openingSelectedItem?.id === it.id;
return (
<TableRow
key={it.id}
hover
selected={selected}
onClick={() => setOpeningSelectedItem(it)}
sx={{ cursor: 'pointer' }}
>
<TableCell padding="checkbox">
<Radio checked={selected} />
</TableCell>
<TableCell>{code}</TableCell>
<TableCell>{name}</TableCell>
<TableCell>{it.uomDesc || it.uom}</TableCell>
<TableCell align="right">
{it.currentStockBalance != null ? it.currentStockBalance : '-'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setOpeningModalOpen(false)}>
{t('common:Cancel')}
</Button>
<Button
variant="contained"
onClick={handleConfirmOpeningInventory}
disabled={!openingSelectedItem}
>
{t('common:Confirm')}
</Button>
</DialogActions>
</Dialog>
</>
);
};


+ 12
- 14
src/components/PoDetail/PoDetail.tsx Zobrazit soubor

@@ -207,7 +207,7 @@ interface PolInputResult {
dnQty: string,
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */
const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
const cameras = useContext(CameraContext);
const { data: session } = useSession();
@@ -578,8 +578,6 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
// setStockInLine([])
// }, []);



const [tabIndex, setTabIndex] = useState(0);
const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
@@ -701,9 +699,9 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {

return (
<>
<Stack spacing={2}>
<Stack spacing={2} sx={{ width: "100%" }}>
{/* Area1: title */}
<Grid container xs={12} justifyContent="start">
<Grid container justifyContent="start">
<Grid item>
<Typography mb={2} variant="h4">
{purchaseOrder.code} -{" "}
@@ -713,7 +711,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
</Grid>

{/* area2: dn info */}
<Grid container spacing={3} sx={{ maxWidth: 'fit-content' }} alignItems="stretch">
<Grid container spacing={3} sx={{ width: "100%" }} alignItems="stretch">
{/* left side select po */}
<Grid item xs={4} sx={{ display: "flex" }}>
<Stack spacing={1} sx={{ flex: 1 }}>
@@ -727,8 +725,8 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
</Grid>

{/* right side po info */}
<Grid item xs={8}>
<Grid container spacing={3} sx={{ maxWidth: 'fit-content' }}>
<Grid item xs={8} sx={{ minWidth: 0 }}>
<Grid container spacing={3} sx={{ width: "100%" }}>
<Grid item xs={12}>
<FormProvider {...dnFormProps}>
<Card sx={{ display: "block" }}>
@@ -858,10 +856,10 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {


{/* Area4: Main Table */}
<Grid container xs={12} justifyContent="start">
<Grid item xs={12}>
<TableContainer component={Paper} sx={{ width: 'fit-content', overflow: 'auto' }}>
<Table aria-label="collapsible table" stickyHeader>
<Grid container justifyContent="start" sx={{ width: "100%" }}>
<Grid item xs={12} sx={{ width: "100%", minWidth: 0 }}>
<TableContainer component={Paper} sx={{ width: "100%", overflow: "auto" }}>
<Table aria-label="collapsible table" stickyHeader sx={{ width: "100%" }}>
<TableHead>
<TableRow>
<TableCell align="center" sx={{ width: '60px' }}></TableCell>
@@ -905,13 +903,13 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
</Grid>

{/* area5: selected item info */}
<Grid container xs={12} justifyContent="start">
<Grid container justifyContent="start" sx={{ width: "100%" }}>
<Grid item xs={12}>
<Typography variant="h6">
{selectedRow ? `已選擇貨品: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}` : "未選擇貨品"}
</Typography>
</Grid>
<Grid item xs={12} sx={{ minWidth: 0 }}>
<Grid item xs={12} sx={{ width: "100%", minWidth: 0 }}>
{selectedRow && (
<PoInputGrid
setRows={setRows}


+ 42
- 92
src/components/PoDetail/PoDetailRow.tsx Zobrazit soubor

@@ -5,11 +5,9 @@ import {
Box,
Button,
Radio,
Stack,
TableCell,
TableRow,
TextField,
Typography,
alpha,
} from "@mui/material";
import { memo, useCallback, useEffect, useRef, useState } from "react";
@@ -17,13 +15,7 @@ import { useTranslation } from "react-i18next";
import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil";
import { submitDialogWithWarning } from "../Swal/CustomAlerts";
import { createStockInLine } from "@/app/api/stockIn/actions";
import {
isNotIntegerQty,
previewPoBatchStockQty,
roundStockQty,
StockQtyRoundChoice,
StockQtyRoundMode,
} from "./stockQtyRound";
import { previewPoBatchStockQty } from "./stockQtyRound";

const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]);

@@ -76,6 +68,7 @@ type Props = {
onSubmitted: (row: PurchaseOrderLine) => void;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */
export const PoDetailRow = memo(function PoDetailRow({
row,
selected,
@@ -97,17 +90,17 @@ export const PoDetailRow = memo(function PoDetailRow({

useEffect(() => {
setLotNoInput(savedLotNo);
setDnQtyInput(savedDnQty);
setDnQtyInput(savedDnQty.replace(/[^\d]/g, ""));
}, [savedLotNo, savedDnQty]);

const handleStart = useCallback(
(roundMode?: StockQtyRoundMode) => {
() => {
if (submitInFlightRef.current || isStarting) return;
const orderQty = Number(row?.qty) ?? 0;
const acceptedQty = Number(dnQtyInput.trim());

if (isNaN(acceptedQty) || acceptedQty <= 0) {
alert("來貨數量必須大於0!");
if (!Number.isInteger(acceptedQty) || acceptedQty <= 0) {
alert("來貨數量必須大於0的整數!");
return;
}

@@ -116,18 +109,6 @@ export const PoDetailRow = memo(function PoDetailRow({
Number(row.stockUom?.stockQty ?? 0),
acceptedQty,
);
const needsRound = isNotIntegerQty(previewStockQty);
if (needsRound && roundMode !== "CEILING" && roundMode !== "FLOOR") {
return;
}
const round: StockQtyRoundChoice | undefined =
needsRound && roundMode
? {
mode: roundMode,
before: previewStockQty,
after: roundStockQty(previewStockQty, roundMode),
}
: undefined;

const doSubmit = () => {
if (submitInFlightRef.current) return;
@@ -145,12 +126,6 @@ export const PoDetailRow = memo(function PoDetailRow({
purchaseOrderLineId: row.id,
acceptedQty: acceptedQty,
productLotNo: lotNoInput || "",
...(round
? {
stockQtyRoundMode: round.mode,
stockQtyRoundSource: "CREATE" as const,
}
: {}),
};
const res = await createStockInLine(postData);
if (res) {
@@ -166,11 +141,25 @@ export const PoDetailRow = memo(function PoDetailRow({
})();
};

const exceedOrderBy10Percent = orderQty > 0 && acceptedQty > orderQty * 1.1;
if (exceedOrderBy10Percent) {
const sils = row.stockInLine ?? [];
const alreadyM18 = sils.reduce(
(acc, sil) => acc + Number(sil.purchaseAcceptedQty ?? 0),
0,
);
const alreadyStock = sils.reduce(
(acc, sil) => acc + Number(sil.acceptedQty ?? 0),
0,
);
const stockDemand = Number(row.stockUom?.stockQty ?? 0);
const thisBatchStock = previewStockQty;
const exceedByOrderUnit =
orderQty > 0 && alreadyM18 + acceptedQty > orderQty * 1.1;
const exceedByStockUnit =
stockDemand > 0 && alreadyStock + thisBatchStock > stockDemand * 1.1;
if (exceedByOrderUnit || exceedByStockUnit) {
submitDialogWithWarning(doSubmit, t, {
title: t("Confirm submit"),
html: t("This batch quantity exceeds order quantity. Do you still want to submit?"),
html: t("qtyExceedsOrderConfirm"),
confirmButtonText: t("Submit"),
});
} else {
@@ -189,19 +178,6 @@ export const PoDetailRow = memo(function PoDetailRow({
],
);

const batchPurchaseQty = Number(dnQtyInput.trim()) || 0;
const previewStockQty = previewPoBatchStockQty(
Number(row?.qty) ?? 0,
Number(row.stockUom?.stockQty ?? 0),
batchPurchaseQty,
);
const needsStockQtyRound = batchPurchaseQty > 0 && isNotIntegerQty(previewStockQty);
const roundUpQty = roundStockQty(previewStockQty, "CEILING");
const roundDownQty = roundStockQty(previewStockQty, "FLOOR");
const stockUomLabel = row.stockUom?.stockUomDesc?.trim()
? ` ${row.stockUom.stockUomDesc.trim()}`
: "";

const totalStockReceived = row.stockInLine
.filter((sil) => sil.purchaseOrderLineId === row.id)
.reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0);
@@ -299,61 +275,35 @@ export const PoDetailRow = memo(function PoDetailRow({
type="text"
variant="outlined"
value={dnQtyInput}
onChange={(e) => setDnQtyInput(e.target.value)}
onChange={(e) => setDnQtyInput(e.target.value.replace(/[^\d]/g, ""))}
onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)}
onClick={(e) => e.stopPropagation()}
InputProps={{
inputProps: {
min: 0,
step: "any",
inputMode: "decimal",
min: 1,
step: 1,
inputMode: "numeric",
pattern: "[0-9]*",
},
}}
/>
</TableCell>
) : null}
<TableCell align="center">
{needsStockQtyRound ? (
<Stack spacing={0.5} alignItems="stretch" onClick={(e) => e.stopPropagation()}>
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: "nowrap" }}>
{t("Converted stock qty is")} {decimalFormatter.format(previewStockQty)}
{row.stockUom?.stockUomDesc ? ` (${row.stockUom.stockUomDesc})` : ""}
</Typography>
<Button
variant="contained"
size="medium"
disabled={isStarting}
onClick={(e) => {
e.stopPropagation();
handleStart("CEILING");
}}
>
{t("Round ceiling")} {roundUpQty}{stockUomLabel}
</Button>
<Button
variant="outlined"
size="medium"
disabled={isStarting}
onClick={(e) => {
e.stopPropagation();
handleStart("FLOOR");
}}
>
{t("Round floor")} {roundDownQty}{stockUomLabel}
</Button>
</Stack>
) : (
<Button
variant="contained"
disabled={isStarting}
onClick={(e) => {
e.stopPropagation();
handleStart();
}}
>
{t("submit")}
</Button>
)}
<Button
variant="contained"
disabled={isStarting}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
handleStart();
}}
>
{t("submit")}
</Button>
</TableCell>
</TableRow>
);


+ 158
- 62
src/components/PoDetail/PoInputGrid.tsx Zobrazit soubor

@@ -35,7 +35,7 @@ import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import { PurchaseOrderLine } from "@/app/api/po";
import { StockInLine } from "@/app/api/stockIn";
import { createStockInLine, deleteStockInLine, QcResult } from "@/app/api/stockIn/actions";
import { createStockInLine, deleteStockInLine, updateStockInLine, QcResult } from "@/app/api/stockIn/actions";
import { usePathname, useSearchParams } from "next/navigation";
import {
returnWeightUnit,
@@ -69,39 +69,22 @@ import { SessionWithTokens } from "@/config/authConfig";
import { EscalationCombo } from "@/app/api/user";
import { deleteDialog } from "../Swal/CustomAlerts";
import StockInLineRowActions from "./StockInLineRowActions";
import { StockQtyRoundMode, needsPoQcStockQtyRound } from "./stockQtyRound";

/** Sum of fixed column widths (desktop) so the grid can scroll horizontally without squeezing cells. */
const STOCK_IN_GRID_MIN_WIDTH_DESKTOP = 1062;
const ACTIONS_COLUMN_WIDTH = 380;
const PURCHASE_QTY_COLUMN_WIDTH = 72;
const UOM_COLUMN_WIDTH = 124;
const STOCK_QTY_COLUMN_WIDTH = 110;
const STOCK_IN_ROW_HEIGHT = 58;

const ACTIONS_COLUMN_WIDTH = 168;

const ACTION_BUTTON_HEIGHT = 38;
const ACTION_BUTTON_GAP = 6;
/** Extra space for cell padding + outlined button borders */
const ACTION_ROW_EXTRA_PADDING = 36;

function getActionRowHeight(buttonCount: number): number {
return (
buttonCount * ACTION_BUTTON_HEIGHT +
Math.max(0, buttonCount - 1) * ACTION_BUTTON_GAP +
ACTION_ROW_EXTRA_PADDING
);
}

function countActionButtonsForRow(row: StockInLineRow): number {
let count = 1;
const status = (row.status ?? "").toLowerCase();
if (status === "rejected" || status === "partially_completed") {
count += 1;
}
if (status === "received") {
count += 1;
}
if (canDeleteStockInLine(row)) {
count += 1;
}
return count;
}
/** Extra table width is shared by text columns; qty / status / actions stay tight. */
const COLUMN_GROW: Record<string, number> = {
dnNo: 1,
productLotNo: 1,
uom: 1.5,
stockQty: 1,
stockUom: 1.5,
};

/** Tighter horizontal padding for narrow data columns (headers unchanged). */
const COMPACT_STOCK_IN_CELL_FIELDS = [
@@ -170,7 +153,7 @@ class ProcessRowUpdateError extends Error {
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */
function PoInputGrid({
// qc,
setRows,
@@ -188,8 +171,6 @@ function PoInputGrid({
const theme = useTheme();
/** Narrow phones: hide low-priority columns. */
const isCompact = useMediaQuery(theme.breakpoints.down("md"), { noSsr: true });
/** Tablet / sub-desktop (< xl): flex columns to fill available width. Desktop (≥ xl) keeps fixed widths. */
const isTablet = useMediaQuery(theme.breakpoints.down("xl"), { noSsr: true });
const apiRef = useGridApiRef();
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
const getRowId = useCallback<GridRowIdGetter<StockInLineRow>>(
@@ -215,6 +196,8 @@ function PoInputGrid({
const [btnIsLoading, setBtnIsLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const deleteInFlightRef = useRef(false);
const roundInFlightRef = useRef(false);
const [roundingSilId, setRoundingSilId] = useState<number | null>(null);
const [currQty, setCurrQty] = useState(() => {
const total = entries.reduce(
// remaining qty (M18 unit)
@@ -248,6 +231,16 @@ function PoInputGrid({
const handleSoftDelete = useCallback(
(row: StockInLineRow) => {
if (deleteInFlightRef.current || isDeleting) return;
if (
needsPoQcStockQtyRound(
row.purchaseOrderLineId,
row.status,
row.acceptedQty,
)
) {
alert("請先在換算庫存數量選擇向上或向下取整");
return;
}

const rowId = row.id as number;
const isDraft = row._isNew || row.status === "draft";
@@ -280,6 +273,50 @@ function PoInputGrid({
},
[fetchPoDetail, handleDelete, isDeleting, itemDetail.id, itemDetail.purchaseOrderId, t],
);

const handleRoundStockQty = useCallback(
(row: StockInLineRow, mode: StockQtyRoundMode) => {
if (roundInFlightRef.current) return;
const silId = row.id;
const itemId = row.itemId;
const purchaseQty = Number(row.purchaseAcceptedQty ?? 0);
if (!silId || !itemId || purchaseQty <= 0) return;

const doRound = async () => {
if (roundInFlightRef.current) return;
roundInFlightRef.current = true;
setRoundingSilId(silId);
try {
const res = await updateStockInLine({
id: silId,
itemId,
purchaseOrderLineId: row.purchaseOrderLineId,
acceptedQty: purchaseQty,
dnNo: row.dnNo,
productLotNo: row.productLotNo,
stockQtyRoundMode: mode,
stockQtyRoundSource: "CREATE",
});
if (res) {
await fetchPoDetail(
String(itemDetail.purchaseOrderId),
true,
itemDetail.id,
);
}
} catch (error) {
console.error("Failed to round stock qty:", error);
alert(t("Please choose a rounding method"));
} finally {
setRoundingSilId(null);
roundInFlightRef.current = false;
}
};

void doRound();
},
[fetchPoDetail, itemDetail.id, itemDetail.purchaseOrderId, t],
);
const closeQcModal = useCallback(() => {
setQcOpen(false);
@@ -434,6 +471,16 @@ function PoInputGrid({
const handleNewQC = useCallback(
(id: GridRowId, params: any) => async() => {
if (!params?.row) return;
if (
needsPoQcStockQtyRound(
params.row.purchaseOrderLineId,
params.row.status,
params.row.acceptedQty,
)
) {
alert("請先在換算庫存數量選擇向上或向下取整");
return;
}
setRowModesModel((prev) => ({
...prev,
[id]: { mode: GridRowModes.View },
@@ -645,7 +692,8 @@ function PoInputGrid({
{
field: "dnNo",
headerName: t("dnNo"),
width: 92,
width: 100,
minWidth: 100,
},
{
field: "receiptDate",
@@ -656,12 +704,15 @@ function PoInputGrid({
{
field: "productLotNo",
headerName: t("productLotNo"),
width: 100,
width: 110,
minWidth: 110,
},
{
field: "purchaseAcceptedQty",
headerName: t("acceptedQty"),
width: 84,
width: PURCHASE_QTY_COLUMN_WIDTH,
minWidth: PURCHASE_QTY_COLUMN_WIDTH,
flex: 0,
align: "right",
headerAlign: "right",
type: "number",
@@ -673,7 +724,9 @@ function PoInputGrid({
{
field: "uom",
headerName: t("uom"),
width: 156,
width: UOM_COLUMN_WIDTH,
minWidth: UOM_COLUMN_WIDTH,
flex: 0,
renderCell: () => {
const text = itemDetail.uom?.udfudesc ?? "-";
return (
@@ -694,17 +747,23 @@ function PoInputGrid({
{
field: "stockQty",
headerName: t("Stock In Qty"),
width: 125,
width: STOCK_QTY_COLUMN_WIDTH,
minWidth: STOCK_QTY_COLUMN_WIDTH,
flex: 0,
type: "number",
align: "left",
headerAlign: "left",
renderCell: (params) => {
const stockQty = params.row.acceptedQty ?? 0;
const stockQty = Number(params.row.acceptedQty ?? 0);
return decimalFormatter.format(stockQty);
},
},
{
field: "stockUom",
headerName: t("Stock UoM"),
width: 124,
width: UOM_COLUMN_WIDTH,
minWidth: UOM_COLUMN_WIDTH,
flex: 0,
renderCell: () => {
const text = itemDetail.stockUom.stockUomDesc ?? "-";
return (
@@ -748,6 +807,8 @@ function PoInputGrid({
field: "actions",
headerName: "操作",
width: ACTIONS_COLUMN_WIDTH,
minWidth: ACTIONS_COLUMN_WIDTH,
flex: 0,
sortable: false,
filterable: false,
disableColumnMenu: true,
@@ -760,6 +821,11 @@ function PoInputGrid({
status === "rejected" || status === "partially_completed";
const canPrint = status === "received";
const canDelete = canDeleteStockInLine(data);
const needsStockQtyRound = needsPoQcStockQtyRound(
data.purchaseOrderLineId,
data.status,
data.acceptedQty,
);

return (
<StockInLineRowActions
@@ -777,31 +843,42 @@ function PoInputGrid({
onDelete={() => handleSoftDelete(data)}
btnIsLoading={btnIsLoading}
isDeleting={isDeleting}
needsStockQtyRound={needsStockQtyRound}
stockQty={Number(data.acceptedQty ?? 0)}
isRounding={roundingSilId === data.id}
onRound={(mode) => handleRoundStockQty(data, mode)}
/>
);
},
},
];

if (!isTablet) {
return baseColumns;
}

return baseColumns.map((col) => {
if (col.field === "actions") {
return { ...col, flex: 0, width: ACTIONS_COLUMN_WIDTH };
const grow = COLUMN_GROW[col.field] ?? 0;
if (grow > 0) {
return {
...col,
flex: grow,
minWidth: col.minWidth ?? col.width,
width: undefined,
};
}
const minWidth = col.width ?? 80;
return { ...col, flex: 1, minWidth, width: undefined };
return {
...col,
flex: 0,
width: col.width,
minWidth: col.minWidth ?? col.width,
};
});
}, [
t,
isTablet,
itemDetail,
handleNewQC,
handleMailTemplateForStockInLine,
printQrcode,
handleSoftDelete,
handleRoundStockQty,
roundingSilId,
btnIsLoading,
isDeleting,
sessionToken?.id,
@@ -917,13 +994,7 @@ function PoInputGrid({
</>
);

const getRowHeight = useCallback(
(params: { model: StockInLineRow }) => {
const count = countActionButtonsForRow(params.model);
return getActionRowHeight(count);
},
[],
);
const getRowHeight = useCallback(() => STOCK_IN_ROW_HEIGHT, []);
return (
<>
@@ -945,7 +1016,7 @@ function PoInputGrid({
columnVisibilityModel={columnVisibilityModel}
sx={{
width: "100%",
minWidth: isTablet ? undefined : STOCK_IN_GRID_MIN_WIDTH_DESKTOP,
minWidth: 0,
"--DataGrid-overlayHeight": "100px",
".MuiDataGrid-row .MuiDataGrid-cell.hasError": {
border: "1px solid",
@@ -957,14 +1028,39 @@ function PoInputGrid({
},
"& .MuiDataGrid-cell.actions": {
overflow: "visible",
alignItems: "flex-start",
py: 0.75,
alignItems: "center",
py: 0.5,
lineHeight: "normal",
},
"& .MuiDataGrid-cell[data-field='stockQty']": {
overflow: "visible",
alignItems: "center",
justifyContent: "flex-start",
py: 0.5,
px: 0.75,
},
"& .MuiDataGrid-cell[data-field='actions']": {
py: 0.75,
py: 0.5,
px: 1,
},
"& .MuiDataGrid-columnHeader[data-field='purchaseAcceptedQty']": {
whiteSpace: "normal",
lineHeight: 1.2,
px: 0.5,
},
"& .MuiDataGrid-cell[data-field='purchaseAcceptedQty']": {
px: 0.5,
},
"& .MuiDataGrid-columnHeader[data-field='uom']": {
whiteSpace: "nowrap",
px: 0.75,
},
"& .MuiDataGrid-cell[data-field='uom']": {
px: 0.75,
},
"& .MuiDataGrid-columnHeader[data-field='stockQty']": {
px: 0.75,
},
...Object.fromEntries(
COMPACT_STOCK_IN_CELL_FIELDS.flatMap((field) => [
[


+ 1
- 1
src/components/PoDetail/QcStockInModal.tsx Zobrazit soubor

@@ -71,7 +71,7 @@ interface CommonProps extends Omit<ModalProps, "children"> {
interface Props extends CommonProps {
// itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] };
}
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */
const PoQcStockInModalVer2: React.FC<Props> = ({
open,
onClose,


+ 100
- 58
src/components/PoDetail/StockInLineRowActions.tsx Zobrazit soubor

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

import { Box, Button } from "@mui/material";
import { useTranslation } from "react-i18next";
import { roundStockQty, StockQtyRoundMode } from "./stockQtyRound";

export type StockInLineActionStyle = {
label: string;
@@ -19,8 +20,13 @@ type Props = {
onDelete: () => void;
btnIsLoading: boolean;
isDeleting: boolean;
needsStockQtyRound?: boolean;
stockQty?: number;
isRounding?: boolean;
onRound?: (mode: StockQtyRoundMode) => void;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */
export default function StockInLineRowActions({
btnSx,
onPrimaryClick,
@@ -32,83 +38,119 @@ export default function StockInLineRowActions({
onDelete,
btnIsLoading,
isDeleting,
needsStockQtyRound = false,
stockQty = 0,
isRounding = false,
onRound,
}: Props) {
const { t } = useTranslation("purchaseOrder");

const buttonSx = {
whiteSpace: "nowrap" as const,
fontSize: 14,
flexShrink: 0,
width: 176,
minWidth: 176,
maxWidth: 176,
px: 1.5,
py: 0.75,
minHeight: 34,
width: "100%",
py: 1,
minHeight: 44,
height: 44,
fontSize: 16,
fontWeight: 700,
justifyContent: "center",
boxSizing: "border-box" as const,
};

return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "stretch",
flexDirection: "row",
flexWrap: "nowrap",
alignItems: "center",
gap: 0.75,
width: "100%",
py: 0.5,
py: 0.25,
boxSizing: "border-box",
}}
onClick={(e) => e.stopPropagation()}
>
<Button
variant="contained"
size="small"
sx={{
...buttonSx,
backgroundColor: btnSx.color,
}}
onClick={onPrimaryClick}
>
{btnSx.label}
</Button>
{canEmail && (
<Button
id="emailSupplier"
type="button"
variant="contained"
color="primary"
size="small"
sx={buttonSx}
onClick={onEmail}
>
{t("email supplier")}
</Button>
)}
{canPrint && (
<Button
id="printQrCode"
type="button"
variant="contained"
size="small"
sx={{
...buttonSx,
backgroundColor: "#7f434a",
}}
disabled={btnIsLoading}
onClick={onPrint}
>
{t("printQrCode")}
</Button>
)}
{canDelete && (
<Button
variant="outlined"
color="error"
size="small"
sx={buttonSx}
disabled={isDeleting || btnIsLoading}
onClick={onDelete}
>
{t("delete")}
</Button>
{needsStockQtyRound ? (
<>
<Button
variant="contained"
size="medium"
disabled={isRounding}
onClick={() => onRound?.("CEILING")}
sx={buttonSx}
>
{t("Round ceiling")} {roundStockQty(stockQty, "CEILING")}
</Button>
<Button
variant="outlined"
size="medium"
disabled={isRounding}
onClick={() => onRound?.("FLOOR")}
sx={buttonSx}
>
{t("Round floor")} {roundStockQty(stockQty, "FLOOR")}
</Button>
</>
) : (
<>
<Button
variant="contained"
size="medium"
sx={{
...buttonSx,
backgroundColor: btnSx.color,
}}
onClick={onPrimaryClick}
>
{btnSx.label}
</Button>
{canEmail && (
<Button
id="emailSupplier"
type="button"
variant="contained"
color="primary"
size="medium"
sx={buttonSx}
onClick={onEmail}
>
{t("email supplier")}
</Button>
)}
{canPrint && (
<Button
id="printQrCode"
type="button"
variant="contained"
size="medium"
sx={{
...buttonSx,
backgroundColor: "#7f434a",
}}
disabled={btnIsLoading}
onClick={onPrint}
>
{t("printQrCode")}
</Button>
)}
{canDelete && (
<Button
variant="outlined"
color="error"
size="medium"
sx={buttonSx}
disabled={isDeleting || btnIsLoading}
onClick={onDelete}
>
{t("delete")}
</Button>
)}
</>
)}
</Box>
);


+ 6
- 1
src/components/PoDetail/stockQtyRound.ts Zobrazit soubor

@@ -7,6 +7,7 @@ export type StockQtyRoundChoice = {
after: number;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */
export function previewPoBatchStockQty(
orderM18Qty: number,
orderStockQty: number,
@@ -23,7 +24,10 @@ export function isNotIntegerQty(qty: number): boolean {
return Math.abs(qty - Math.round(qty)) > 1e-9;
}

/** PO 收貨/品檢:pending 或 escalated 且庫存數量非整數時需選擇 CEILING / FLOOR */
/**
* FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10
* PO 收貨/品檢:pending 或 escalated 且庫存數量非整數時需選擇 CEILING / FLOOR
*/
export function needsPoQcStockQtyRound(
purchaseOrderLineId: number | null | undefined,
status: string | null | undefined,
@@ -35,6 +39,7 @@ export function needsPoQcStockQtyRound(
return isNotIntegerQty(Number(acceptedQty ?? 0));
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */
export function roundStockQty(before: number, mode: StockQtyRoundMode): number {
if (mode === "CEILING") return Math.ceil(before);
return Math.floor(before);


+ 6
- 5
src/components/PoSearch/PoSearch.tsx Zobrazit soubor

@@ -29,6 +29,7 @@ type SearchParamNames = keyof SearchQuery;

// cal offset (pageSize)
// cal limit (pageSize)
/** FP-MTMS Version Checklist | Functions Ref. No. 76 | v1.0.0 | 2026-09-07 */
const PoSearch: React.FC<Props> = ({
po,
totalCount: initTotalCount,
@@ -150,7 +151,7 @@ const PoSearch: React.FC<Props> = ({
return <Grid>"N/A"</Grid>
}
const items = value.split(",")
return items.map((item) => <Grid key={item}>{item}</Grid>)
return items.map((item, index) => <Grid key={`${index}-${item}`}>{item}</Grid>)
}, [])

const columns = useMemo<Column<PoResult>[]>(
@@ -279,8 +280,8 @@ const PoSearch: React.FC<Props> = ({
pagingController: Record<string, number>,
filterArgs: Record<string, number>,
) => {
console.log(pagingController);
console.log(filterArgs);
// console.log(pagingController);
//console.log(filterArgs);
const params = {
...pagingController,
...filterArgs,
@@ -390,7 +391,7 @@ const PoSearch: React.FC<Props> = ({
);

useEffect(() => {
console.log(filteredPo)
//console.log(filteredPo)
}, [filteredPo])

useEffect(() => {
@@ -415,7 +416,7 @@ const PoSearch: React.FC<Props> = ({
disabled={isM18LookupLoading}
onSearch={(query) => {
if (isM18LookupLoading) return;
console.log(query);
//console.log(query);
const code = typeof query.code === "string" ? query.code.trim() : "";
if (code) {
// When PO code is provided, ignore other search criteria (especially date ranges).


+ 5
- 27
src/components/Qc/QcStockInModal.tsx Zobrazit soubor

@@ -23,7 +23,7 @@ import QcComponent from "./QcComponent";
import PutAwayForm from "../PoDetail/PutAwayForm";
import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid";
import {msg, submitDialogWithWarning} from "../Swal/CustomAlerts";
import { needsPoQcStockQtyRound, roundStockQty } from "../PoDetail/stockQtyRound";
import { needsPoQcStockQtyRound } from "../PoDetail/stockQtyRound";
import { INPUT_DATE_FORMAT, arrayToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil";
import dayjs from "dayjs";
import { fetchPoQrcode } from "@/app/api/pdf/actions";
@@ -73,7 +73,7 @@ interface Props extends CommonProps {
// itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] };
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.3 | 2026-09-10 */
const QcStockInModal: React.FC<Props> = ({
open,
onClose,
@@ -243,7 +243,6 @@ const QcStockInModal: React.FC<Props> = ({
...defaultNewValue,
},
});
const stockQtyRoundMode = formProps.watch("stockQtyRoundMode");
const qcDecision = formProps.watch("qcDecision");
const roundChoiceRequired = useMemo(() => {
const needsRound = needsPoQcStockQtyRound(
@@ -252,19 +251,13 @@ const QcStockInModal: React.FC<Props> = ({
stockInLineInfo?.acceptedQty,
);
const willAcceptStock = Boolean(skipQc) || qcDecision == 1;
return (
needsRound &&
willAcceptStock &&
stockQtyRoundMode !== "CEILING" &&
stockQtyRoundMode !== "FLOOR"
);
return needsRound && willAcceptStock;
}, [
stockInLineInfo?.purchaseOrderLineId,
stockInLineInfo?.status,
stockInLineInfo?.acceptedQty,
skipQc,
qcDecision,
stockQtyRoundMode,
]);

const closeWithResult = useCallback(
@@ -444,23 +437,9 @@ const QcStockInModal: React.FC<Props> = ({
storedStockQty,
);
const willAcceptStock = Boolean(skipQc) || qcAcceptLocal;
let roundPayload:
| {
stockQtyRoundMode: "CEILING" | "FLOOR";
stockQtyRoundSource: "QC";
}
| undefined;
if (willAcceptStock && needsStockQtyRound) {
const selectedMode = data.stockQtyRoundMode;
if (selectedMode !== "CEILING" && selectedMode !== "FLOOR") {
alert(t("Please choose a rounding method"));
return;
}
acceptQtyLocal = roundStockQty(storedStockQty, selectedMode);
roundPayload = {
stockQtyRoundMode: selectedMode,
stockQtyRoundSource: "QC",
};
alert("請先在換算庫存數量選擇向上或向下取整");
return;
}

const isJobOrderSource = Boolean(stockInLineInfo?.jobOrderId) || printSource === "productionProcess";
@@ -476,7 +455,6 @@ const QcStockInModal: React.FC<Props> = ({
// For Job Order QC, allow updating received qty beyond demand/accepted.
// Backend uses request.acceptedQty in QC flow, so we must send it explicitly.
acceptedQty: (qcAcceptLocal && isJobOrderSource) ? (acceptQtyLocal ? acceptQtyLocal : 0) : stockInLineInfo?.acceptedQty,
...roundPayload,
// qcResult: itemDetail.status != "escalated" ? qcResults.map(item => ({
qcResult: qcResultsLocal.map(item => ({
// id: item.id,


+ 1
- 63
src/components/StockIn/StockInForm.tsx Zobrazit soubor

@@ -10,24 +10,17 @@ import {
CardContent,
Grid,
InputAdornment,
Stack,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import StyledDataGrid from "../StyledDataGrid";
import { useCallback, useEffect, useState, useMemo } from "react";
import { useCallback, useEffect, useState } from "react";
import {
useGridApiRef,
} from "@mui/x-data-grid";
import { StockInLine } from "@/app/api/stockIn";
import {
needsPoQcStockQtyRound,
roundStockQty,
StockQtyRoundMode,
} from "@/components/PoDetail/stockQtyRound";
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { dayjsToDateString, INPUT_DATE_FORMAT, OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
@@ -119,36 +112,8 @@ const StockInForm: React.FC<Props> = ({
const productionDate = watch("productionDate");
const expiryDate = watch("expiryDate");
const uom = watch("uom");
const stockQtyRoundMode = watch("stockQtyRoundMode");
const displayedAcceptedQty = watch("acceptedQty");
const originalAcceptedQty = Number(itemDetail.acceptedQty ?? 0);
const showStockQtyRoundButtons =
!putawayMode &&
!disabled &&
needsPoQcStockQtyRound(
itemDetail.purchaseOrderLineId,
itemDetail.status,
originalAcceptedQty,
);
const stockUomLabel = itemDetail.stockUomDesc ? ` ${itemDetail.stockUomDesc}` : "";
const roundUpQty = roundStockQty(originalAcceptedQty, "CEILING");
const roundDownQty = roundStockQty(originalAcceptedQty, "FLOOR");

const selectStockQtyRound = useCallback(
(mode: StockQtyRoundMode) => {
const after = roundStockQty(Number(itemDetail.acceptedQty ?? 0), mode);
setValue("stockQtyRoundMode", mode, { shouldDirty: true, shouldTouch: true });
setValue("acceptedQty", after, { shouldDirty: true, shouldTouch: true });
(setValue as (name: string, value: unknown, options?: object) => void)(
"acceptQty",
after,
{ shouldDirty: true, shouldTouch: true },
);
const accInput = document.getElementById("accQty") as HTMLInputElement | null;
if (accInput) accInput.value = String(after);
},
[itemDetail.acceptedQty, setValue],
);

const [openModal, setOpenModal] = useState<boolean>(false);
const [openExpDatePicker, setOpenExpDatePicker] = useState<boolean>(false);
@@ -449,33 +414,6 @@ const StockInForm: React.FC<Props> = ({
})}
/>
</Grid>
{showStockQtyRoundButtons && (
<Grid item xs={6}>
<Stack spacing={0.75} justifyContent="flex-end" sx={{ height: "100%" }}>
<Typography variant="caption" color="text.secondary">
{t("Please choose a rounding method")}
</Typography>
<Stack direction="row" spacing={1} alignItems="stretch">
<Button
variant={stockQtyRoundMode === "CEILING" ? "contained" : "outlined"}
size={compactFields ? "medium" : "large"}
onClick={() => selectStockQtyRound("CEILING")}
sx={{ flex: 1, fontWeight: 700 }}
>
{t("Round ceiling")} {roundUpQty}{stockUomLabel}
</Button>
<Button
variant={stockQtyRoundMode === "FLOOR" ? "contained" : "outlined"}
size={compactFields ? "medium" : "large"}
onClick={() => selectStockQtyRound("FLOOR")}
sx={{ flex: 1, fontWeight: 700 }}
>
{t("Round floor")} {roundDownQty}{stockUomLabel}
</Button>
</Stack>
</Stack>
</Grid>
)}
</>
)}
{/* <Grid item xs={4}>


+ 372
- 65
src/components/StockIssue/ExpiryHandleTab.tsx Zobrazit soubor

@@ -11,42 +11,168 @@ import SearchResults, { Column } from "@/components/SearchResults/index";
import { SessionWithTokens } from "@/config/authConfig";
import {
batchSubmitExpiryItem,
ExpiryItemFilter,
ExpiryItemResult,
fetchExpiryItemList,
submitExpiryItem,
} from "@/app/api/stockIssue/actions";
import { Box, Button } from "@mui/material";
import { exportExpiryItemExcel } from "@/app/api/stockIssue/client";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
InputLabel,
MenuItem,
Select,
SelectChangeEvent,
Tab,
Tabs,
Tooltip,
Typography,
} from "@mui/material";
import FileDownload from "@mui/icons-material/FileDownload";
import { useSession } from "next-auth/react";

type SearchQuery = {
itemCode: string;
itemName: string;
expiryDate: string;
lotNo: string;
};
type SearchParamNames = keyof SearchQuery;
type ResultBucket = "expired" | "today" | "upcoming";

const DEFAULT_DAYS_AHEAD = 7;
const MIN_DAYS_AHEAD = 1;
const MAX_DAYS_AHEAD = 14;
const DAYS_AHEAD_OPTIONS = Array.from(
{ length: MAX_DAYS_AHEAD - MIN_DAYS_AHEAD + 1 },
(_, i) => MIN_DAYS_AHEAD + i,
);

function parseDaysAhead(raw: string | number | undefined): number {
const n = typeof raw === "number" ? raw : Number.parseInt(String(raw ?? "").trim(), 10);
if (!Number.isFinite(n) || n < MIN_DAYS_AHEAD) return DEFAULT_DAYS_AHEAD;
return Math.min(Math.floor(n), MAX_DAYS_AHEAD);
}

function parseExpiryDayjs(rawValue: unknown): dayjs.Dayjs | null {
const raw = String(rawValue ?? "").trim();
if (!raw) return null;
let d: dayjs.Dayjs;
if (raw.includes(",")) {
const parts = raw.split(",").map((s) => parseInt(s.trim(), 10));
const [y, m, d_] = parts;
if (
parts.length >= 3 &&
y != null &&
m != null &&
d_ != null &&
!Number.isNaN(y) &&
!Number.isNaN(m) &&
!Number.isNaN(d_)
) {
d = dayjs(new Date(y, m - 1, d_));
} else {
d = dayjs("");
}
} else if (/^\d{4}-\d{2}-\d{2}/.test(raw)) {
d = dayjs(raw.slice(0, 10));
} else {
let normalized = raw;
if (raw.length === 7) {
normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7);
} else if (raw.length === 6) {
normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6);
}
d = dayjs(normalized, "YYYYMMDD", true);
}
return d.isValid() ? d : null;
}

function getExpiryBucket(
item: ExpiryItemResult,
daysAhead: number,
): ResultBucket | null {
const d = parseExpiryDayjs(item.expiryDate);
if (!d) return null;
const today = dayjs().startOf("day");
if (d.isBefore(today, "day")) return "expired";
if (d.isSame(today, "day")) return "today";
if (daysAhead > 0 && !d.isAfter(today.add(daysAhead, "day"), "day")) {
return "upcoming";
}
return null;
}

function canHandleExpiryItem(item: ExpiryItemResult): boolean {
if (typeof item.canHandle === "boolean") return item.canHandle;
const d = parseExpiryDayjs(item.expiryDate);
return d != null && !d.isAfter(dayjs(), "day");
}

/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.3 | 2026-09-08 */
const ExpiryHandleTab: React.FC = () => {
const BATCH_CHUNK_SIZE = 20;
const { t } = useTranslation("stockIssue");
const { t: tCommon } = useTranslation("common");
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;

const [expiryItems, setExpiryItems] = useState<ExpiryItemResult[]>([]);
const [lastFilters, setLastFilters] = useState<ExpiryItemFilter>({
daysAhead: DEFAULT_DAYS_AHEAD,
});
const [hasSearched, setHasSearched] = useState(false);
const [resultTab, setResultTab] = useState<ResultBucket>("expired");
const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set());
const [batchSubmitting, setBatchSubmitting] = useState(false);
const [batchConfirmOpen, setBatchConfirmOpen] = useState(false);
const [batchProgress, setBatchProgress] = useState<{
done: number;
total: number;
} | null>(null);
const expirySubmitInFlightRef = useRef<Set<number>>(new Set());
const batchSubmitInFlightRef = useRef(false);
const exportInFlightRef = useRef(false);
const searchInFlightRef = useRef(false);
const [exporting, setExporting] = useState<"filtered" | "all" | null>(null);
const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 });
const [daysAheadDraft, setDaysAheadDraft] = useState(String(DEFAULT_DAYS_AHEAD));

const daysAhead = lastFilters.daysAhead ?? DEFAULT_DAYS_AHEAD;

const itemsByBucket = useMemo(() => {
const expired: ExpiryItemResult[] = [];
const today: ExpiryItemResult[] = [];
const upcoming: ExpiryItemResult[] = [];
for (const item of expiryItems) {
const bucket = getExpiryBucket(item, daysAhead);
if (bucket === "expired") expired.push(item);
else if (bucket === "today") today.push(item);
else if (bucket === "upcoming") upcoming.push(item);
}
return {
expired,
today,
upcoming,
};
}, [expiryItems, daysAhead]);

const tabItems = itemsByBucket[resultTab];
const handleableIds = useMemo(
() => tabItems.filter(canHandleExpiryItem).map((item) => item.id),
[tabItems],
);

const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo(
() => [
{ name: "itemCode", label: t("Item Code"), type: "text" },
{ name: "itemName", label: t("Item"), type: "text" },
{ name: "expiryDate", label: t("Expiry Date"), type: "date" },
{ name: "lotNo", label: t("Lot No."), type: "text" },
],
[t],
);
@@ -62,6 +188,10 @@ const ExpiryHandleTab: React.FC = () => {
alert(t("Item not found"));
return;
}
if (!canHandleExpiryItem(item)) {
alert(t("Not yet due; cannot dispose until the expiry date"));
return;
}
if (expirySubmitInFlightRef.current.has(id)) return;

try {
@@ -88,7 +218,7 @@ const ExpiryHandleTab: React.FC = () => {
const handleSubmitAll = useCallback(async () => {
if (!currentUserId) return;
if (batchSubmitInFlightRef.current) return;
const allIds = expiryItems.map((item) => item.id);
const allIds = tabItems.filter(canHandleExpiryItem).map((item) => item.id);
if (allIds.length === 0) return;

batchSubmitInFlightRef.current = true;
@@ -114,7 +244,7 @@ const ExpiryHandleTab: React.FC = () => {
setBatchProgress(null);
batchSubmitInFlightRef.current = false;
}
}, [currentUserId, expiryItems, t]);
}, [currentUserId, tabItems, t]);

const expiryColumns = useMemo<Column<ExpiryItemResult>[]>(
() => [
@@ -126,52 +256,40 @@ const ExpiryHandleTab: React.FC = () => {
name: "expiryDate",
label: t("Expiry Date"),
renderCell: (item) => {
const raw = String(item.expiryDate ?? "").trim();
if (!raw) return "—";
let d;
if (raw.includes(",")) {
const parts = raw.split(",").map((s) => parseInt(s.trim(), 10));
const [y, m, d_] = parts;
if (
parts.length >= 3 &&
y != null &&
m != null &&
d_ != null &&
!Number.isNaN(y) &&
!Number.isNaN(m) &&
!Number.isNaN(d_)
) {
d = dayjs(new Date(y, m - 1, d_));
} else {
d = dayjs("");
}
} else {
let normalized = raw;
if (raw.length === 7) {
normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7);
} else if (raw.length === 6) {
normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6);
}
d = dayjs(normalized, "YYYYMMDD", true);
}
return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : raw;
const d = parseExpiryDayjs(item.expiryDate);
return d ? d.format(OUTPUT_DATE_FORMAT) : String(item.expiryDate ?? "").trim() || "—";
},
},
{ name: "remainingQty", label: t("Remaining Qty") },
{
name: "uomDesc",
label: t("UoM"),
renderCell: (item) => item.uomDesc?.trim() || "—",
},
{
name: "id",
label: t("Action"),
renderCell: (item) => (
<Button
size="small"
variant="contained"
color="primary"
onClick={() => handleSubmitSingle(item.id)}
disabled={submittingIds.has(item.id) || !currentUserId}
>
{submittingIds.has(item.id) ? t("Disposing...") : t("Disposed")}
</Button>
),
renderCell: (item) => {
const canHandle = canHandleExpiryItem(item);
const disposing = submittingIds.has(item.id);
const button = (
<Button
size="small"
variant="contained"
color="primary"
onClick={() => handleSubmitSingle(item.id)}
disabled={disposing || !currentUserId || !canHandle}
>
{disposing ? t("Disposing...") : t("Disposed")}
</Button>
);
if (canHandle) return button;
return (
<Tooltip title={t("Not yet due; cannot dispose until the expiry date")}>
<span>{button}</span>
</Tooltip>
);
},
},
],
[t, handleSubmitSingle, submittingIds, currentUserId],
@@ -179,49 +297,238 @@ const ExpiryHandleTab: React.FC = () => {

const handleSearch = useCallback(
async (query: Record<SearchParamNames, string>) => {
if (searchInFlightRef.current) return;
const parsedDays = parseDaysAhead(daysAheadDraft);
setDaysAheadDraft(String(parsedDays));
setPaging((prev) => ({ ...prev, pageNum: 1 }));
const filters: ExpiryItemFilter = {
itemCode: query.itemCode?.trim() || undefined,
itemName: query.itemName?.trim() || undefined,
lotNo: query.lotNo?.trim() || undefined,
daysAhead: parsedDays,
};
searchInFlightRef.current = true;
try {
const result = await fetchExpiryItemList({
itemCode: query.itemCode?.trim() || undefined,
itemName: query.itemName?.trim() || undefined,
expiryDate: query.expiryDate || undefined,
});
const result = await fetchExpiryItemList(filters);
setLastFilters(filters);
setHasSearched(true);
setExpiryItems(result);
} catch (error) {
console.error("Failed to search expiry items:", error);
alert(t("Failed to load expiry items"));
} finally {
searchInFlightRef.current = false;
}
},
[t],
[t, daysAheadDraft],
);

const pagedItems = useMemo(() => {
const start = (paging.pageNum - 1) * paging.pageSize;
return expiryItems.slice(start, start + paging.pageSize);
}, [expiryItems, paging]);
const applyDaysAhead = useCallback(
async (nextDays: number) => {
const parsedDays = parseDaysAhead(nextDays);
setDaysAheadDraft(String(parsedDays));
if (parsedDays === daysAhead) return;
if (!hasSearched) {
setLastFilters((prev) => ({ ...prev, daysAhead: parsedDays }));
return;
}
if (searchInFlightRef.current) return;
searchInFlightRef.current = true;
try {
const filters: ExpiryItemFilter = {
...lastFilters,
daysAhead: parsedDays,
};
const result = await fetchExpiryItemList(filters);
setLastFilters(filters);
setExpiryItems(result);
setPaging((prev) => ({ ...prev, pageNum: 1 }));
} catch (error) {
console.error("Failed to search expiry items:", error);
alert(t("Failed to load expiry items"));
} finally {
searchInFlightRef.current = false;
}
},
[daysAhead, hasSearched, lastFilters, t],
);

const handleDaysAheadChange = useCallback(
(event: SelectChangeEvent<string>) => {
void applyDaysAhead(parseDaysAhead(event.target.value));
},
[applyDaysAhead],
);

const handleExportExcel = useCallback(
async (mode: "filtered" | "all") => {
if (!hasSearched) return;
if (exportInFlightRef.current) return;
exportInFlightRef.current = true;
setExporting(mode);
try {
await exportExpiryItemExcel(
mode === "all"
? {
daysAhead,
}
: {
...lastFilters,
bucket: resultTab,
},
);
} catch (error) {
console.error("Failed to export expiry items:", error);
alert(t("Failed to export Excel"));
} finally {
setExporting(null);
exportInFlightRef.current = false;
}
},
[hasSearched, lastFilters, resultTab, daysAhead, t],
);

const handleResultTabChange = useCallback(
(_: React.SyntheticEvent, value: string) => {
setResultTab(value as ResultBucket);
setPaging((prev) => ({ ...prev, pageNum: 1 }));
},
[],
);

return (
<Box>
<StockIssueSearchPanel fields={searchFields} onSearch={handleSearch} />
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: 1.5,
mb: 2,
flexWrap: "wrap",
}}
>
<Tabs
value={resultTab}
onChange={handleResultTabChange}
sx={{
minHeight: 48,
"& .MuiTab-root": { minHeight: 48, minWidth: 0, px: 2 },
}}
>
<Tab
value="expired"
label={`${t("Already expired")} (${itemsByBucket.expired.length})`}
/>
<Tab
value="today"
label={`${t("Expires today")} (${itemsByBucket.today.length})`}
/>
<Tab
value="upcoming"
label={`${t("Expires within X days")} (${itemsByBucket.upcoming.length})`}
/>
</Tabs>
<Button
variant="contained"
color="primary"
onClick={handleSubmitAll}
disabled={batchSubmitting || !currentUserId || expiryItems.length === 0}
variant="outlined"
startIcon={<FileDownload />}
onClick={() => handleExportExcel("all")}
disabled={!hasSearched || exporting != null}
>
{batchSubmitting
? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}`
: t("Batch Disposed All")}
{exporting === "all" ? t("Exporting...") : t("Export all in tab")}
</Button>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mb: 1,
flexWrap: "wrap",
}}
>
{resultTab === "upcoming" && (
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel id="expiry-days-ahead-label">{t("Days ahead")}</InputLabel>
<Select
labelId="expiry-days-ahead-label"
label={t("Days ahead")}
value={String(parseDaysAhead(daysAheadDraft))}
onChange={handleDaysAheadChange}
>
{DAYS_AHEAD_OPTIONS.map((days) => (
<MenuItem key={days} value={String(days)}>
{days}
</MenuItem>
))}
</Select>
</FormControl>
)}
<Box sx={{ display: "flex", gap: 1, ml: "auto" }}>
<Button
variant="outlined"
startIcon={<FileDownload />}
onClick={() => handleExportExcel("filtered")}
disabled={!hasSearched || exporting != null || tabItems.length === 0}
>
{exporting === "filtered" ? t("Exporting...") : t("Export Excel")}
</Button>
<Button
variant="contained"
color="primary"
onClick={() => setBatchConfirmOpen(true)}
disabled={
batchSubmitting || !currentUserId || handleableIds.length === 0
}
>
{batchSubmitting
? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}`
: t("Batch Disposed All")}
</Button>
</Box>
</Box>
<SearchResults<ExpiryItemResult>
items={pagedItems}
items={tabItems}
columns={expiryColumns}
pagingController={paging}
setPagingController={setPaging}
totalCount={expiryItems.length}
totalCount={tabItems.length}
/>
<Dialog
open={batchConfirmOpen}
onClose={() => {
if (!batchSubmitting) setBatchConfirmOpen(false);
}}
fullWidth
maxWidth="xs"
>
<DialogTitle>{t("Confirm batch dispose")}</DialogTitle>
<DialogContent>
<Typography>
{t("Confirm batch dispose message", { count: handleableIds.length })}
</Typography>
</DialogContent>
<DialogActions>
<Button
onClick={() => setBatchConfirmOpen(false)}
disabled={batchSubmitting}
>
{t("Cancel")}
</Button>
<Button
variant="contained"
color="primary"
disabled={batchSubmitting || handleableIds.length === 0}
onClick={async () => {
setBatchConfirmOpen(false);
await handleSubmitAll();
}}
>
{tCommon("Confirm")}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};


+ 45
- 18
src/components/StockIssue/StockIssueRecordTab.tsx Zobrazit soubor

@@ -22,6 +22,8 @@ type SearchQuery = {
lotNo: string;
startDate: string;
endDate: string;
handledStartDate: string;
handledEndDate: string;
};
type SearchParamNames = keyof SearchQuery;

@@ -29,6 +31,7 @@ interface Props {
kind: RecordKind;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */
const StockIssueRecordTab: React.FC<Props> = ({ kind }) => {
const { t } = useTranslation("stockIssue");
const [items, setItems] = useState<StockIssueHandleRecord[]>([]);
@@ -40,27 +43,47 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => {
lotNo: "",
startDate: "",
endDate: "",
handledStartDate: "",
handledEndDate: "",
});
const hasSearchedRef = useRef(false);
const prevPagingRef = useRef(paging);

const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo(
() => [
{ name: "itemCode", label: t("Item Code"), type: "text" },
{ name: "itemName", label: t("Item"), type: "text" },
{ name: "lotNo", label: t("Lot No."), type: "text" },
{
name: "startDate",
label: kind === "expiry" ? t("Expiry Start Date") : t("Start Date"),
type: "date",
mirrorTo: "endDate",
},
{
name: "endDate",
label: kind === "expiry" ? t("Expiry End Date") : t("End Date"),
type: "date",
},
],
() => {
const fields: StockIssueSearchField<SearchParamNames>[] = [
{ name: "itemCode", label: t("Item Code"), type: "text" },
{ name: "itemName", label: t("Item"), type: "text" },
{ name: "lotNo", label: t("Lot No."), type: "text" },
{
name: "startDate",
label: kind === "expiry" ? t("Expiry Start Date") : t("Start Date"),
type: "date",
mirrorTo: "endDate",
},
{
name: "endDate",
label: kind === "expiry" ? t("Expiry End Date") : t("End Date"),
type: "date",
},
];
if (kind === "expiry") {
fields.push(
{
name: "handledStartDate",
label: t("Handled Start Date"),
type: "date",
mirrorTo: "handledEndDate",
},
{
name: "handledEndDate",
label: t("Handled End Date"),
type: "date",
},
);
}
return fields;
},
[t, kind],
);

@@ -73,6 +96,8 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => {
lotNo: query.lotNo?.trim() || undefined,
startDate: query.startDate || undefined,
endDate: query.endDate || undefined,
handledStartDate: query.handledStartDate || undefined,
handledEndDate: query.handledEndDate || undefined,
pageNum: page.pageNum - 1,
pageSize: page.pageSize,
};
@@ -166,7 +191,7 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => {
},
{
name: "uomDesc",
label: t("UOM"),
label: t("UoM"),
renderCell: (row) => (
<>
{row.uomDesc ?? ""}
@@ -179,8 +204,10 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => {
renderCell: (row) =>
row.handlerName ?? (row.handlerId != null ? String(row.handlerId) : "—"),
},
{ name: "remarks", label: t("Remarks") },
);
if (kind !== "expiry") {
base.push({ name: "remarks", label: t("Remarks") });
}
return base;
}, [t, kind]);



+ 22
- 2
src/components/StockIssue/StockIssueSearchPanel.tsx Zobrazit soubor

@@ -25,7 +25,7 @@ import "dayjs/locale/zh-hk";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";

export type StockIssueSearchFieldType = "text" | "select" | "date";
export type StockIssueSearchFieldType = "text" | "select" | "date" | "number";

export interface StockIssueSearchField<K extends string> {
name: K;
@@ -36,6 +36,9 @@ export interface StockIssueSearchField<K extends string> {
getOptionLabel?: (value: string) => string;
/** When this date is picked, copy the same value to `mirrorTo`. */
mirrorTo?: K;
defaultValue?: string;
min?: number;
max?: number;
}

interface Props<K extends string> {
@@ -46,6 +49,7 @@ interface Props<K extends string> {
disabled?: boolean;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */
function StockIssueSearchPanel<K extends string>({
fields,
onSearch,
@@ -60,7 +64,8 @@ function StockIssueSearchPanel<K extends string>({
return fields.reduce(
(acc, field) => {
acc[field.name] =
field.type === "select" ? "All" : "";
field.defaultValue ??
(field.type === "select" ? "All" : "");
return acc;
},
{} as Record<K, string>,
@@ -127,6 +132,21 @@ function StockIssueSearchPanel<K extends string>({
disabled={disabled}
/>
)}
{field.type === "number" && (
<TextField
label={field.label}
type="number"
fullWidth
value={values[field.name] ?? ""}
onChange={handleTextChange(field.name)}
disabled={disabled}
inputProps={{
min: field.min,
max: field.max,
step: 1,
}}
/>
)}
{field.type === "select" && (
<FormControl fullWidth disabled={disabled}>
<InputLabel>{field.label}</InputLabel>


+ 3
- 0
src/i18n/en/do.json Zobrazit soubor

@@ -166,6 +166,9 @@
"Truck X": "Truck X",
"Truck lane search requires date message": "Truck lane search requires date message",
"Truck lane search requires date title": "Truck lane search requires date title",
"User session not found": "User session not found. Please login again.",
"Error": "Error",
"OK": "OK",
"Warning: Some delivery orders do not have matching trucks for the target date.": "Warning: Some delivery orders do not have matching trucks for the target date.",
"Workbench Batch Release": "Workbench Batch Release",
"code": "code",


+ 1
- 0
src/i18n/en/purchaseOrder.json Zobrazit soubor

@@ -53,6 +53,7 @@
"putawayQty": "Put Away Qty",
"Confirm submit": "Confirm Submit",
"This batch quantity exceeds order quantity. Do you still want to submit?": "This batch quantity exceeds order quantity. Do you still want to submit?",
"qtyExceedsOrderConfirm": "Cumulative received quantity exceeds order quantity. Do you still want to submit?",
"Stock qty is not an integer": "Stock quantity is not an integer",
"Converted stock qty is": "Converted stock qty is",
"Choose rounding method": ". Please choose a rounding method:",


+ 18
- 1
src/i18n/en/stockIssue.json Zobrazit soubor

@@ -6,15 +6,29 @@
"Bad Item Qty": "Bad Item Qty",
"Bad Item Records": "Bad Item Records",
"Batch Disposed All": "Batch Disposed All",
"Export Excel": "Export this category",
"Exporting...": "Exporting...",
"Failed to export Excel": "Failed to export Excel",
"Book Qty": "Book Qty",
"Cancel": "Cancel",
"Code": "Code",
"Defective Qty": "Defective Qty",
"Disposed": "Disposed",
"Disposed": "Expiry handle",
"Disposing...": "Disposing...",
"DO Order Code": "DO Order Code",
"End Date": "End Date",
"Expiry Date": "Expiry Date",
"Expiry on or before": "Expiry on or before",
"Already expired": "Expiry not yet handle",
"Expires today": "Expires today",
"Expires within 7 days": "Expires within X days",
"Expires within n days": "Expires within X days",
"Expires within X days": "Expires within X days",
"All expiry items": "All",
"Export all in tab": "Export all",
"Days ahead": "Days ahead",
"Confirm batch dispose": "Confirm batch dispose",
"Confirm batch dispose message": "Dispose {{count}} lot(s)? Remaining quantity will be fully stocked out.",
"Expiry End Date": "Expiry End Date",
"Expiry Item": "Expiry Item",
"Expiry Item Handle": "Expiry Item Handle",
@@ -24,7 +38,10 @@
"Failed to load expiry items": "Failed to load expiry items",
"Failed to submit": "Failed to submit",
"Failed to submit expiry item": "Failed to submit expiry item",
"Not yet due; cannot dispose until the expiry date": "Not yet due; cannot dispose until the expiry date",
"Handled Date": "Handled Date",
"Handled Start Date": "Handled Start Date",
"Handled End Date": "Handled End Date",
"Handler": "Handler",
"Issue Qty": "Issue Qty",
"Item": "Item",


+ 2
- 1
src/i18n/zh/do.json Zobrazit soubor

@@ -221,5 +221,6 @@
"Replenishment demo note": "此為前端假資料回應;正式環境將呼叫後端 API。",
"Search Delivery Order": "搜尋送貨單",
"DO Replenishment": "送貨單補貨",
"Error": "錯誤"
"Error": "錯誤",
"User session not found": "找不到登入使用者,請重新登入。"
}

+ 1
- 0
src/i18n/zh/purchaseOrder.json Zobrazit soubor

@@ -53,6 +53,7 @@
"putawayQty": "上架數量",
"Confirm submit": "確定提交",
"This batch quantity exceeds order quantity. Do you still want to submit?": "本批收貨數量超出訂單數量。仍要提交嗎?",
"qtyExceedsOrderConfirm": "累計收貨數量超出訂單數量。仍要提交嗎?",
"Stock qty is not an integer": "換算庫存數量不是整數",
"Converted stock qty is": "換算庫存數量為",
"Choose rounding method": "。請選擇進位方式:",


+ 21
- 4
src/i18n/zh/stockIssue.json Zobrazit soubor

@@ -6,15 +6,29 @@
"Bad Item Qty": "不良品數量",
"Bad Item Records": "不良品處理紀錄",
"Batch Disposed All": "批量處理完成",
"Export Excel": "匯出目前分類",
"Exporting...": "匯出中...",
"Failed to export Excel": "匯出 Excel 失敗",
"Book Qty": "帳面庫存",
"Cancel": "取消",
"Code": "編號",
"Defective Qty": "不良數量",
"Disposed": "已處置",
"Disposed": "過期處理",
"Disposing...": "處理中...",
"DO Order Code": "送貨單編號",
"End Date": "結束日期",
"Expiry Date": "到期日",
"Expiry on or before": "到期日(含當日及以前)",
"Already expired": "過期尚未處理",
"Expires today": "今日到期",
"Expires within 7 days": "未來 X 日到期",
"Expires within n days": "未來 X 日到期",
"Expires within X days": "未來 X 日到期",
"All expiry items": "全部",
"Export all in tab": "匯出全部",
"Days ahead": "未來天數",
"Confirm batch dispose": "確認批量處置",
"Confirm batch dispose message": "將處置 {{count}} 筆批號,數量會全部出倉。確定?",
"Expiry End Date": "到期日(結束)",
"Expiry Item": "過期",
"Expiry Item Handle": "過期品處理",
@@ -24,10 +38,13 @@
"Failed to load expiry items": "載入過期品失敗",
"Failed to submit": "提交失敗",
"Failed to submit expiry item": "提交過期品失敗",
"Not yet due; cannot dispose until the expiry date": "尚未到期,到期日當日才可處置",
"Handled Date": "處理日期",
"Handled Start Date": "處理日期(開始)",
"Handled End Date": "處理日期(結束)",
"Handler": "處理人",
"Issue Qty": "問題數量",
"Item": "貨品",
"Item": "貨品名稱",
"Item Code": "貨品編號",
"Item not found": "找不到貨品",
"Item selected": "已選擇貨品",
@@ -50,7 +67,7 @@
"Processing...": "處理中...",
"Quantity exceeds available quantity": "數量超過可用數量",
"Remain available Quantity": "剩餘可用數量",
"Remaining Qty": "剩餘數量",
"Remaining Qty": "數量",
"Remark": "備註",
"Remarks": "備註",
"Reset": "重置",
@@ -69,7 +86,7 @@
"Submitting...": "提交中...",
"Type": "類型",
"Unknown error": "未知錯誤",
"UoM": "單位",
"UoM": "庫存單位",
"User ID is required": "需要用戶ID",
"Warehouse": "倉庫",
"available": "可用",


Načítá se…
Zrušit
Uložit