Selaa lähdekoodia

item averageUnitPrice fix

同 UOM 不轉換

lot expiry 邏輯

PoSearch fix the console.og
PoDetail 累計超過 PO 也要 warn
undefined
CANCERYS\kw093 1 viikko sitten
vanhempi
commit
e631f2fae7
23 muutettua tiedostoa jossa 623 lisäystä ja 243 poistoa
  1. +2
    -2
      src/app/(main)/po/edit/page.tsx
  2. +1
    -1
      src/app/api/inventory/actions.ts
  3. +27
    -0
      src/app/api/settings/item/actions.ts
  4. +5
    -0
      src/app/api/settings/item/index.ts
  5. +9
    -2
      src/app/api/stockIssue/actions.ts
  6. +66
    -0
      src/app/api/stockIssue/client.ts
  7. +11
    -3
      src/components/CreateItem/CreateItem.tsx
  8. +6
    -2
      src/components/CreateItem/CreateItemWrapper.tsx
  9. +17
    -2
      src/components/CreateItem/ProductDetails.tsx
  10. +137
    -135
      src/components/InventorySearch/InventorySearch.tsx
  11. +30
    -7
      src/components/PoDetail/PoDetail.tsx
  12. +1
    -1
      src/components/PoDetail/PoInputGrid.tsx
  13. +1
    -1
      src/components/PoDetail/QcStockInModal.tsx
  14. +6
    -5
      src/components/PoSearch/PoSearch.tsx
  15. +1
    -1
      src/components/Qc/QcStockInModal.tsx
  16. +226
    -59
      src/components/StockIssue/ExpiryHandleTab.tsx
  17. +45
    -18
      src/components/StockIssue/StockIssueRecordTab.tsx
  18. +2
    -0
      src/i18n/en/items.json
  19. +1
    -1
      src/i18n/en/purchaseOrder.json
  20. +13
    -1
      src/i18n/en/stockIssue.json
  21. +2
    -0
      src/i18n/zh/items.json
  22. +1
    -1
      src/i18n/zh/purchaseOrder.json
  23. +13
    -1
      src/i18n/zh/stockIssue.json

+ 2
- 2
src/app/(main)/po/edit/page.tsx Näytä tiedosto

@@ -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 Näytä tiedosto

@@ -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.1 | 2026-09-07
* Inventory search page: latest inventory row per item (no baseUnit/uomId filter).
*/
export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl);


+ 27
- 0
src/app/api/settings/item/actions.ts Näytä tiedosto

@@ -46,6 +46,16 @@ export type CreateItemInputs = {
isFee?: boolean | undefined;
isBag?: boolean | undefined;
qcType?: string | undefined;
averageUnitPrice?: string | undefined;
averageUnitPriceEditable?: boolean;
stockUnitLabel?: string | undefined;
};

export const recalculateAverageUnitPrices = async () => {
return serverFetchJson<{ updatedItemCount: number }>(
`${BASE_API_URL}/items/averageUnitPrice/recalculate`,
{ method: "POST" },
);
};

export const saveItem = async (data: CreateItemInputs) => {
@@ -138,6 +148,23 @@ export const fetchItemsWithDetails = cache(async (searchParams?: Record<string,
}
});

/** Item master page search: items table only (no inventory / lot join). */
export const fetchItemsByPage = cache(async (searchParams?: Record<string, string | number>) => {
const params = new URLSearchParams();
if (searchParams) {
Object.entries(searchParams).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value) !== "") {
params.append(key, String(value));
}
});
}
const qs = params.toString();
return serverFetchJson<RecordsRes<ItemsResult>>(
qs ? `${BASE_API_URL}/items/getRecordByPage?${qs}` : `${BASE_API_URL}/items/getRecordByPage`,
{ next: { tags: ["items"] } },
);
});

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


+ 5
- 0
src/app/api/settings/item/index.ts Näytä tiedosto

@@ -66,12 +66,17 @@ export type ItemsResult = {
latestMarketUnitPrice?: number;
latestMupUpdatedDate?: string;
purchaseUnit?: string;
purchaseCurrencyId?: number;
purchaseUnitPrice?: number;
purchaseFxRate?: number;
};

export type Result = {
item: ItemsResult;
qcChecks: ItemQc[];
qcType?: string;
averageUnitPriceEditable?: boolean;
stockUnitLabel?: string;
};
export const fetchAllItems = cache(async () => {
return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, {


+ 9
- 2
src/app/api/stockIssue/actions.ts Näytä tiedosto

@@ -17,12 +17,15 @@ 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;
}

export interface HandleBadItemRequest {
@@ -54,6 +57,8 @@ export interface StockIssueHandleRecord {
export interface SearchStockIssueRecordParams {
startDate?: string;
endDate?: string;
handledStartDate?: string;
handledEndDate?: string;
itemCode?: string;
itemName?: string;
lotNo?: string;
@@ -63,9 +68,9 @@ export interface SearchStockIssueRecordParams {

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);
const queryString = params.toString();
const url = `${BASE_API_URL}/pickExecution/issues/expiryItem${queryString ? `?${queryString}` : ""}`;
return serverFetchJson<ExpiryItemResult[]>(url, {
@@ -107,6 +112,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);


+ 66
- 0
src/app/api/stockIssue/client.ts Näytä tiedosto

@@ -0,0 +1,66 @@
"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;
}

/**
* Partner backend contract (not implemented here):
* GET /pickExecution/issues/expiryItem/excel
* Query: itemCode, itemName, lotNo, bucket (expired|today|upcoming)
* 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.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);
}

+ 11
- 3
src/components/CreateItem/CreateItem.tsx Näytä tiedosto

@@ -1,6 +1,6 @@
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { CreateItemInputs, saveItem } from "@/app/api/settings/item/actions";
@@ -42,6 +42,7 @@ type Props = {
warehouses: WarehouseResult[];
};

/** FP-MTMS Version Checklist | Functions Ref. No. 71 | v1.0.0 | 2026-09-07 */
const CreateItem: React.FC<Props> = ({
isEditMode,
// type,
@@ -56,6 +57,8 @@ const CreateItem: React.FC<Props> = ({
console.log(params.get("id"));
const [serverError, setServerError] = useState("");
const [tabIndex, setTabIndex] = useState(0);
const [isSaving, setIsSaving] = useState(false);
const inFlightRef = useRef(false);
const { t } = useTranslation("items");
const router = useRouter();
const title = "Product / Material";
@@ -104,10 +107,11 @@ const CreateItem: React.FC<Props> = ({
};
const onSubmit = useCallback<SubmitHandler<CreateItemInputs & {}>>(
async (data, event) => {
if (inFlightRef.current) return;
inFlightRef.current = true;
setIsSaving(true);
const hasErrors = false;
console.log(errors);
// console.log(apiRef.current.getCellValue(2, "lowerLimit"))
// apiRef.current.
try {
if (hasErrors) {
setServerError(t("An error has occurred. Please try again later."));
@@ -191,6 +195,9 @@ const CreateItem: React.FC<Props> = ({
// backend error
setServerError(t("An error has occurred. Please try again later."));
console.log(e);
} finally {
setIsSaving(false);
inFlightRef.current = false;
}
},
[apiRef, router, t],
@@ -244,6 +251,7 @@ const CreateItem: React.FC<Props> = ({
qcCategoryCombo={qcCategoryCombo}
warehouses={warehouses}
defaultValues={defaultValues}
isSaving={isSaving}
/>
)}
{tabIndex === 1 && <QcDetails apiRef={apiRef} />}


+ 6
- 2
src/components/CreateItem/CreateItemWrapper.tsx Näytä tiedosto

@@ -16,6 +16,7 @@ type Props = {
// type: TypeEnum;
};

/** FP-MTMS Version Checklist | Functions Ref. No. 71 | v1.0.0 | 2026-09-07 */
const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => {
let result;
let defaultValues: Partial<CreateItemInputs> | undefined;
@@ -29,7 +30,7 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => {
// Normalize LocationCode field (handle case sensitivity from MySQL)
const locationCode = item?.LocationCode || item?.locationCode;
/*
console.log("Fetched item data for edit:", {
id: item?.id,
code: item?.code,
@@ -37,7 +38,7 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => {
LocationCode: locationCode,
rawItem: item
});
*/
defaultValues = {
type: item?.type,
id: item?.id,
@@ -60,6 +61,9 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => {
isEgg: item?.isEgg,
isFee: item?.isFee,
isBag: item?.isBag,
averageUnitPrice: item?.averageUnitPrice != null ? String(item.averageUnitPrice) : undefined,
averageUnitPriceEditable: result.averageUnitPriceEditable ?? true,
stockUnitLabel: result.stockUnitLabel,
};
}



+ 17
- 2
src/components/CreateItem/ProductDetails.tsx Näytä tiedosto

@@ -42,9 +42,11 @@ type Props = {
qcChecks?: ItemQc[];
qcCategoryCombo: QcCategoryCombo[];
warehouses: WarehouseResult[];
isSaving?: boolean;
};

const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehouses, defaultValues: initialDefaultValues }) => {
/** FP-MTMS Version Checklist | Functions Ref. No. 71 | v1.0.0 | 2026-09-07 */
const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehouses, defaultValues: initialDefaultValues, isSaving }) => {
const [qcItems, setQcItems] = useState<QcItemInfo[]>([]);
const [qcItemsLoading, setQcItemsLoading] = useState(false);

@@ -315,6 +317,19 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous
)}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={
initialDefaultValues?.stockUnitLabel
? `${t("Average unit cost")} (HKD / ${initialDefaultValues.stockUnitLabel})`
: `${t("Average unit cost")} (HKD)`
}
fullWidth
disabled={!isEditMode}
{...register("averageUnitPrice")}
helperText={t("Average unit cost hint")}
/>
</Grid>
<Grid item xs={12}>
<FormControl component="fieldset">
<FormLabel component="legend">{t("Special Type")}</FormLabel>
@@ -359,7 +374,7 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous
variant="contained"
startIcon={<Check />}
type="submit"
// disabled={submitDisabled}
disabled={isSaving}
>
{isEditMode ? t("Save") : t("Confirm")}
</Button>


+ 137
- 135
src/components/InventorySearch/InventorySearch.tsx Näytä tiedosto

@@ -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,7 +16,7 @@ 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 { ItemCombo, fetchItemsByPage } from '@/app/api/settings/item/actions';
import {
Button,
Dialog,
@@ -56,42 +56,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;
};

type OpeningItemRow = ItemCombo & { code: string; name: string; type?: 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.1 | 2026-09-07 */
const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
const { t } = useTranslation(['inventory', 'common', 'item']);
const searchInFlightRef = useRef(false);
const openingSearchInFlightRef = useRef(false);

const buildSyntheticInventory = useCallback(
(item: ItemWithDetails): InventoryResult => ({
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 +130,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');
@@ -114,9 +154,9 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
const [scannedItemId, setScannedItemId] = useState<number | null>(null);

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

@@ -297,46 +337,54 @@ 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 (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,
],
);

@@ -382,14 +430,8 @@ 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);
} else {
const items = await lookupItemsByCodeOrName(res?.itemCode);
if (!applyItemsAsSyntheticInventories(items)) {
setSelectedInventory(null);
}
}
@@ -410,8 +452,8 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
qrScanner.result,
refetchInventoryData,
refetchInventoryLotLineData,
buildSyntheticInventory,
getFirstItemRecord,
lookupItemsByCodeOrName,
applyItemsAsSyntheticInventories,
scanUiMode,
]);

@@ -430,51 +472,35 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
setOpeningItems([]);
return;
}

if (openingSearchInFlightRef.current) return;
openingSearchInFlightRef.current = true;
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,
}));

const items = trimmed.includes(' ')
? await lookupItemsByCodeOrName(undefined, trimmed)
: await lookupItemsByCodeOrName(trimmed);
const combos: OpeningItemRow[] = items.map((item) => {
const uom = item.uomDesc || item.uom || item.purchaseUnit || '';
return {
id: item.id,
label: `${item.code} - ${item.name}`,
uomId: 0,
uom,
uomDesc: uom,
code: item.code,
name: item.name,
type: item.type,
};
});
setOpeningItems(combos);
} catch (e) {
console.error('Failed to search items for opening inventory:', e);
setOpeningItems([]);
} finally {
setOpeningLoading(false);
openingSearchInFlightRef.current = false;
}
}, [openingSearchText]);
}, [openingSearchText, lookupItemsByCodeOrName]);

const handleConfirmOpeningInventory = useCallback(() => {
if (!openingSelectedItem) {
@@ -482,39 +508,18 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
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);
applyItemsAsSyntheticInventories([
{
id: Number(openingSelectedItem.id),
code: openingSelectedItem.code,
name: openingSelectedItem.name,
type: openingSelectedItem.type,
uom: openingSelectedItem.uom,
uomDesc: openingSelectedItem.uomDesc,
},
]);
setOpeningModalOpen(false);
}, [openingSelectedItem]);
}, [openingSelectedItem, applyItemsAsSyntheticInventories]);

return (
<>
@@ -545,7 +550,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
variant="outlined"
color="secondary"
onClick={handleOpenOpeningInventoryModal}
sx={{ display: 'none' }}
>
{t('Add entry for items without inventory')}
</Button>
@@ -652,8 +656,6 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
</TableHead>
<TableBody>
{openingItems.map((it) => {
const [code, ...nameParts] = (it.label ?? '').split(' - ');
const name = nameParts.join(' - ');
const selected = openingSelectedItem?.id === it.id;
return (
<TableRow
@@ -666,8 +668,8 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => {
<TableCell padding="checkbox">
<Radio checked={selected} />
</TableCell>
<TableCell>{code}</TableCell>
<TableCell>{name}</TableCell>
<TableCell>{it.code}</TableCell>
<TableCell>{it.name}</TableCell>
<TableCell>{it.uomDesc || it.uom}</TableCell>
<TableCell align="right">
{it.currentStockBalance != null ? it.currentStockBalance : '-'}


+ 30
- 7
src/components/PoDetail/PoDetail.tsx Näytä tiedosto

@@ -253,7 +253,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.2 | 2026-09-07 */
const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
const cameras = useContext(CameraContext);
const { data: session } = useSession();
@@ -678,11 +678,29 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
}, 200);
};
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 =
orderQty > 0 && stockDemand > 0
? acceptedQty * (stockDemand / orderQty)
: acceptedQty;
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 {
@@ -834,6 +852,11 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
<TableCell align="center">
<Button
variant="contained"
onMouseDown={(e) => {
// Keep input focused so onBlur does not remount this row and swallow the click.
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
handleStart();
@@ -998,7 +1021,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
<>
<Stack spacing={2}>
{/* Area1: title */}
<Grid container xs={12} justifyContent="start">
<Grid container justifyContent="start">
<Grid item>
<Typography mb={2} variant="h4">
{purchaseOrder.code} -{" "}
@@ -1153,7 +1176,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {


{/* Area4: Main Table */}
<Grid container xs={12} justifyContent="start">
<Grid container justifyContent="start">
<Grid item xs={12}>
<TableContainer component={Paper} sx={{ width: 'fit-content', overflow: 'auto' }}>
<Table aria-label="collapsible table" stickyHeader>
@@ -1187,7 +1210,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
</Grid>

{/* area5: selected item info */}
<Grid container xs={12} justifyContent="start">
<Grid container justifyContent="start">
<Grid item xs={12}>
<Typography variant="h6">
{selectedRow ? `已選擇貨品: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}` : "未選擇貨品"}


+ 1
- 1
src/components/PoDetail/PoInputGrid.tsx Näytä tiedosto

@@ -170,7 +170,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.2 | 2026-09-07 */
function PoInputGrid({
// qc,
setRows,


+ 1
- 1
src/components/PoDetail/QcStockInModal.tsx Näytä tiedosto

@@ -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.2 | 2026-09-07 */
const PoQcStockInModalVer2: React.FC<Props> = ({
open,
onClose,


+ 6
- 5
src/components/PoSearch/PoSearch.tsx Näytä tiedosto

@@ -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>[]>(
@@ -272,8 +273,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,
@@ -379,7 +380,7 @@ const PoSearch: React.FC<Props> = ({
);

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

useEffect(() => {
@@ -404,7 +405,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).


+ 1
- 1
src/components/Qc/QcStockInModal.tsx Näytä tiedosto

@@ -72,7 +72,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.2 | 2026-09-07 */
const QcStockInModal: React.FC<Props> = ({
open,
onClose,


+ 226
- 59
src/components/StockIssue/ExpiryHandleTab.tsx Näytä tiedosto

@@ -11,42 +11,134 @@ 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,
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";

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): 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 (!d.isAfter(today.add(7, "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.0 | 2026-09-07 */
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>({});
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 [exporting, setExporting] = useState(false);
const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 });

const itemsByBucket = useMemo(() => {
const expired: ExpiryItemResult[] = [];
const today: ExpiryItemResult[] = [];
const upcoming: ExpiryItemResult[] = [];
for (const item of expiryItems) {
const bucket = getExpiryBucket(item);
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]);

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 +154,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 +184,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 +210,7 @@ const ExpiryHandleTab: React.FC = () => {
setBatchProgress(null);
batchSubmitInFlightRef.current = false;
}
}, [currentUserId, expiryItems, t]);
}, [currentUserId, tabItems, t]);

const expiryColumns = useMemo<Column<ExpiryItemResult>[]>(
() => [
@@ -126,52 +222,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],
@@ -180,12 +264,15 @@ const ExpiryHandleTab: React.FC = () => {
const handleSearch = useCallback(
async (query: Record<SearchParamNames, string>) => {
setPaging((prev) => ({ ...prev, pageNum: 1 }));
const filters: ExpiryItemFilter = {
itemCode: query.itemCode?.trim() || undefined,
itemName: query.itemName?.trim() || undefined,
lotNo: query.lotNo?.trim() || undefined,
};
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);
@@ -195,20 +282,66 @@ const ExpiryHandleTab: React.FC = () => {
[t],
);

const pagedItems = useMemo(() => {
const start = (paging.pageNum - 1) * paging.pageSize;
return expiryItems.slice(start, start + paging.pageSize);
}, [expiryItems, paging]);
const handleExportExcel = useCallback(async () => {
if (!hasSearched) return;
if (exportInFlightRef.current) return;
exportInFlightRef.current = true;
setExporting(true);
try {
await exportExpiryItemExcel({
...lastFilters,
bucket: resultTab,
});
} catch (error) {
console.error("Failed to export expiry items:", error);
alert(t("Failed to export Excel"));
} finally {
setExporting(false);
exportInFlightRef.current = false;
}
}, [hasSearched, lastFilters, resultTab, 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 }}>
<Tabs value={resultTab} onChange={handleResultTabChange} sx={{ mb: 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 7 days")} (${itemsByBucket.upcoming.length})`}
/>
</Tabs>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mb: 1 }}>
<Button
variant="outlined"
startIcon={<FileDownload />}
onClick={handleExportExcel}
disabled={!hasSearched || exporting || tabItems.length === 0}
>
{exporting ? t("Exporting...") : t("Export Excel")}
</Button>
<Button
variant="contained"
color="primary"
onClick={handleSubmitAll}
disabled={batchSubmitting || !currentUserId || expiryItems.length === 0}
onClick={() => setBatchConfirmOpen(true)}
disabled={
batchSubmitting || !currentUserId || handleableIds.length === 0
}
>
{batchSubmitting
? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}`
@@ -216,12 +349,46 @@ const ExpiryHandleTab: React.FC = () => {
</Button>
</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 Näytä tiedosto

@@ -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.0 | 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]);



+ 2
- 0
src/i18n/en/items.json Näytä tiedosto

@@ -66,5 +66,7 @@
"Sales UOM": "Sales UOM",
"Stock Qty": "Stock Qty",
"Uom": "Uom",
"Average unit cost": "Average unit cost",
"Average unit cost hint": "HKD per stock unit. Recalculate from POs overwrites this value.",
"Cost (HKD)": "Cost (HKD)"
}

+ 1
- 1
src/i18n/en/purchaseOrder.json Näytä tiedosto

@@ -51,7 +51,7 @@
"acceptedPutawayQty": "Put Away Qty (This Batch)",
"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?",
"acceptQty": "Accept Qty",
"printQty": "Print Qty",
"qcResult": "QC Result",


+ 13
- 1
src/i18n/en/stockIssue.json Näytä tiedosto

@@ -6,15 +6,24 @@
"Bad Item Qty": "Bad Item Qty",
"Bad Item Records": "Bad Item Records",
"Batch Disposed All": "Batch Disposed All",
"Export Excel": "Export Excel",
"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 7 days",
"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 +33,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
- 0
src/i18n/zh/items.json Näytä tiedosto

@@ -66,5 +66,7 @@
"Sales UOM": "銷售單位",
"Stock Qty": "庫存數量",
"Uom": "單位",
"Average unit cost": "平均單位成本",
"Average unit cost hint": "HKD/庫存單位。從採購單重算會覆寫此值。",
"Cost (HKD)": "費用 (HKD)"
}

+ 1
- 1
src/i18n/zh/purchaseOrder.json Näytä tiedosto

@@ -51,7 +51,7 @@
"acceptedPutawayQty": "本批上架數量",
"putawayQty": "上架數量",
"Confirm submit": "確定提交",
"This batch quantity exceeds order quantity. Do you still want to submit?": "本批收貨數量超出訂單數量。仍要提交嗎?",
"qtyExceedsOrderConfirm": "累計收貨數量超出訂單數量。仍要提交嗎?",
"acceptQty": "揀收數量",
"printQty": "列印數量",
"qcResult": "品檢結果",


+ 13
- 1
src/i18n/zh/stockIssue.json Näytä tiedosto

@@ -6,15 +6,24 @@
"Bad Item Qty": "不良品數量",
"Bad Item Records": "不良品處理紀錄",
"Batch Disposed All": "批量處理完成",
"Export Excel": "匯出 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": "未來 7 日到期",
"Confirm batch dispose": "確認批量處置",
"Confirm batch dispose message": "將處置 {{count}} 筆批號,剩餘數量會全部出倉。確定?",
"Expiry End Date": "到期日(結束)",
"Expiry Item": "過期",
"Expiry Item Handle": "過期品處理",
@@ -24,7 +33,10 @@
"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": "貨品",


Ladataan…
Peruuta
Tallenna