diff --git a/src/app/(main)/ps/page.tsx b/src/app/(main)/ps/page.tsx
index 0d7e3c4..a8f872d 100644
--- a/src/app/(main)/ps/page.tsx
+++ b/src/app/(main)/ps/page.tsx
@@ -687,7 +687,7 @@ export default function ProductionSchedulePage() {
className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600"
>
- 搜尋
+ 搜索
diff --git a/src/app/(main)/settings/masterDataIssues/page.tsx b/src/app/(main)/settings/masterDataIssues/page.tsx
new file mode 100644
index 0000000..ebd698f
--- /dev/null
+++ b/src/app/(main)/settings/masterDataIssues/page.tsx
@@ -0,0 +1,23 @@
+import { Metadata } from "next";
+import { getServerI18n, I18nProvider } from "@/i18n";
+import PageTitleBar from "@/components/PageTitleBar";
+import { MasterDataIssuesTabs } from "@/components/MasterDataIssues";
+
+export const metadata: Metadata = {
+ title: "Master Data Issues",
+};
+
+const MasterDataIssuesPage: React.FC = async () => {
+ const { t } = await getServerI18n("masterDataIssue");
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export default MasterDataIssuesPage;
diff --git a/src/app/(main)/stockIssue/page.tsx b/src/app/(main)/stockIssue/page.tsx
index 973ff8c..11b49be 100644
--- a/src/app/(main)/stockIssue/page.tsx
+++ b/src/app/(main)/stockIssue/page.tsx
@@ -1,8 +1,6 @@
import SearchPage from "@/components/StockIssue/index";
-import { PreloadList } from "@/components/StockIssue/action";
import { getServerI18n } from "@/i18n";
import { I18nProvider } from "@/i18n";
-import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";
import { Suspense } from "react";
@@ -11,10 +9,6 @@ export const metadata: Metadata = {
};
const SearchView: React.FC = async () => {
- const { t } = await getServerI18n("inventory");
-
- PreloadList();
-
return (
<>
diff --git a/src/app/api/bom/client.ts b/src/app/api/bom/client.ts
index 722030b..2ec626a 100644
--- a/src/app/api/bom/client.ts
+++ b/src/app/api/bom/client.ts
@@ -81,6 +81,7 @@ export async function fetchBomComboClient(): Promise {
);
return response.data;
}
+
export async function fetchBomDetailClient(id: number): Promise {
const response = await axiosInstance.get(
diff --git a/src/app/api/bom/index.ts b/src/app/api/bom/index.ts
index 4e63c2d..e6aef7b 100644
--- a/src/app/api/bom/index.ts
+++ b/src/app/api/bom/index.ts
@@ -11,6 +11,24 @@ export interface BomCombo {
description: string;
}
+export type BomComboIssueCode =
+ | "MISSING_BOM_CODE"
+ | "MISSING_BOM_NAME"
+ | "MISSING_ITEM"
+ | "MISSING_SALES_UOM"
+ | "MISSING_UOM_CONVERSION"
+ | "MISSING_STOCK_UOM"
+ | "MISSING_STOCK_UOM_CONVERSION";
+
+export interface BomComboIssue {
+ bomId: number;
+ bomCode: string | null;
+ bomName: string | null;
+ itemId: number | null;
+ description: string | null;
+ issueCode: BomComboIssueCode;
+}
+
export interface BomFormatFileGroup {
fileName: string;
problems: string[];
diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts
index 8624175..0f10e48 100644
--- a/src/app/api/inventory/actions.ts
+++ b/src/app/api/inventory/actions.ts
@@ -8,6 +8,11 @@ import { QcItemResult } from "../settings/qcItem";
import { RecordsRes } from "../utils";
import { convertObjToURLSearchParams } from "@/app/utils/commonUtil";
import { InventoryLotLineResult, InventoryResult } from ".";
+
+export type InventoryListRow = Pick<
+ InventoryResult,
+ "itemId" | "itemCode" | "itemName" | "itemType"
+>;
// import { BASE_API_URL } from "@/config/api";
export interface LotLineInfo {
@@ -22,6 +27,15 @@ export interface LotLineInfo {
export interface SearchInventoryLotLine extends Pageable {
itemId: number;
+ /** Non-expired lots with in > out; includes available and unavailable. */
+ stockIssueBadItem?: boolean;
+}
+
+export interface SearchStockIssueBadItemLotLine extends Pageable {
+ itemCode?: string;
+ itemName?: string;
+ itemType?: string;
+ lotNo?: string;
}
export interface SearchInventory extends Pageable {
@@ -114,38 +128,69 @@ export const fetchLotDetail = cache(async (stockInLineId: number) => {
export const updateInventoryLotLineStatus = async (data: {
inventoryLotLineId: number;
status: string;
- //qty: number;
- //operation?: string;
}) => {
- // return await serverFetchJson(`${BASE_API_URL}/inventoryLotLine/updateStatus`, {
- // next: { tags: ["inventoryLotLine"] },
- // method: 'POST',
- // body: JSON.stringify(data)
- // });
- return await serverFetchJson>(`${BASE_API_URL}/inventoryLotLine/updateStatus`, {
- next: { tags: ["inventoryLotLine"] },
- method: 'POST',
- body: JSON.stringify(data),
- headers: { "Content-Type": "application/json" },
- });
- // revalidateTag("po");
+ const res = await serverFetchJson>(
+ `${BASE_API_URL}/inventoryLotLine/updateStatus`,
+ {
+ next: { tags: ["inventoryLotLine"] },
+ method: "POST",
+ body: JSON.stringify(data),
+ headers: { "Content-Type": "application/json" },
+ },
+ );
+ if (res?.code === "SUCCESS") {
+ revalidateTag("inventoryLotLines");
+ revalidateTag("inventories");
+ }
+ return res;
};
-export const fetchInventories = cache(async (data: SearchInventory) => {
- const queryStr = convertObjToURLSearchParams(data)
+/** Full inventory list (for item-type filter options). Not wrapped in cache(). */
+export async function fetchInventoryListFresh() {
+ return serverFetchJson(`${BASE_API_URL}/inventory/list`, {
+ next: { tags: ["inventories"] },
+ });
+}
+
+async function fetchInventoriesImpl(data: SearchInventory) {
+ const queryStr = convertObjToURLSearchParams(data);
+ return serverFetchJson(
+ `${BASE_API_URL}/inventory/getRecordByPage?${queryStr}`,
+ { next: { tags: ["inventories"] } },
+ );
+}
- return serverFetchJson(`${BASE_API_URL}/inventory/getRecordByPage?${queryStr}`,
- { next: { tags: ["inventories"] } }
- )
-})
+export const fetchInventories = cache(fetchInventoriesImpl);
+/** Bypass React cache() after mutations so lists show fresh qty. */
+export async function fetchInventoriesFresh(data: SearchInventory) {
+ return fetchInventoriesImpl(data);
+}
-export const fetchInventoryLotLines = cache(async (data: SearchInventoryLotLine) => {
- const queryStr = convertObjToURLSearchParams(data)
- return serverFetchJson(`${BASE_API_URL}/inventoryLotLine/getRecordByPage?${queryStr}`, {
- next: { tags: ["inventoryLotLines"] },
- });
-});
+async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) {
+ const queryStr = convertObjToURLSearchParams(data);
+ return serverFetchJson(
+ `${BASE_API_URL}/inventoryLotLine/getRecordByPage?${queryStr}`,
+ { next: { tags: ["inventoryLotLines"] } },
+ );
+}
+
+export const fetchInventoryLotLines = cache(fetchInventoryLotLinesImpl);
+
+/** Bypass React cache() after mutations so lists show fresh qty. */
+export async function fetchInventoryLotLinesFresh(data: SearchInventoryLotLine) {
+ return fetchInventoryLotLinesImpl(data);
+}
+
+export async function fetchStockIssueBadItemLotLinesFresh(
+ data: SearchStockIssueBadItemLotLine,
+) {
+ const queryStr = convertObjToURLSearchParams(data);
+ return serverFetchJson(
+ `${BASE_API_URL}/inventoryLotLine/stockIssueBadItemSearch?${queryStr}`,
+ { next: { tags: ["inventoryLotLines"] } },
+ );
+}
export const updateInventoryLotLineQuantities = async (data: {
inventoryLotLineId: number;
qty: number;
diff --git a/src/app/api/masterDataIssues/client.ts b/src/app/api/masterDataIssues/client.ts
new file mode 100644
index 0000000..c9a589b
--- /dev/null
+++ b/src/app/api/masterDataIssues/client.ts
@@ -0,0 +1,29 @@
+"use client";
+
+import axiosInstance from "@/app/(main)/axios/axiosInstance";
+import { NEXT_PUBLIC_API_URL } from "@/config/api";
+import type { MasterDataIssue, MasterDataIssueSummary } from "./index";
+
+export async function fetchBomMasterDataIssuesClient(): Promise {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/bom/master-data/issues`,
+ );
+ return response.data;
+}
+
+export async function fetchItemMasterDataIssuesClient(): Promise {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/items/master-data/issues`,
+ );
+ return response.data;
+}
+
+export async function fetchMasterDataIssuesSummaryClient(
+ options?: { includeTiming?: boolean },
+): Promise {
+ const response = await axiosInstance.get(
+ `${NEXT_PUBLIC_API_URL}/master-data/issues/summary`,
+ { params: options?.includeTiming ? { includeTiming: true } : undefined },
+ );
+ return response.data;
+}
diff --git a/src/app/api/masterDataIssues/index.ts b/src/app/api/masterDataIssues/index.ts
new file mode 100644
index 0000000..9eddc48
--- /dev/null
+++ b/src/app/api/masterDataIssues/index.ts
@@ -0,0 +1,87 @@
+export type MasterDataIssueScope = "BOM" | "BOM_MATERIAL" | "ITEM";
+
+export type MasterDataIssueCode =
+ | "MISSING_BOM_CODE"
+ | "MISSING_BOM_NAME"
+ | "MISSING_ITEM"
+ | "MISSING_BASE_UOM"
+ | "MISSING_SALES_UOM"
+ | "MISSING_STOCK_UOM"
+ | "MISSING_PICKING_UOM"
+ | "MISSING_PURCHASE_UOM"
+ | "DELETED_BASE_UOM"
+ | "DELETED_SALES_UOM"
+ | "DELETED_STOCK_UOM"
+ | "DELETED_PURCHASE_UOM"
+ | "MISSING_BASE_UOM_CONVERSION"
+ | "MISSING_SALES_UOM_CONVERSION"
+ | "MISSING_STOCK_UOM_CONVERSION"
+ | "MISSING_PICKING_UOM_CONVERSION"
+ | "MISSING_PURCHASE_UOM_CONVERSION"
+ | "MISSING_UOM_CONVERSION"
+ | "MISSING_STOCK_UOM_CONVERSION"
+ | "MULTIPLE_BASE_UOM"
+ | "MULTIPLE_SALES_UOM"
+ | "MULTIPLE_STOCK_UOM"
+ | "MULTIPLE_PICKING_UOM"
+ | "MULTIPLE_PURCHASE_UOM"
+ | "BOM_OUTPUT_UOM_MISMATCH_SALES"
+ | "BOM_OUTPUT_UOM_TEXT_DRIFT"
+ | "BOM_MATERIAL_MISSING_ITEM"
+ | "BOM_MATERIAL_SALES_UOM_MISMATCH"
+ | "BOM_MATERIAL_BASE_UOM_MISMATCH"
+ | "BOM_MATERIAL_STOCK_UOM_MISMATCH"
+ | "BOM_MATERIAL_UOM_FK_INVALID";
+
+export interface MasterDataIssue {
+ scope: MasterDataIssueScope;
+ bomId: number | null;
+ bomCode: string | null;
+ bomName: string | null;
+ bomMaterialId: number | null;
+ itemId: number | null;
+ itemCode: string | null;
+ itemName: string | null;
+ description: string | null;
+ issueCode: MasterDataIssueCode | string;
+ expectedValue: string | null;
+ actualValue: string | null;
+ baseUnitValue?: string | null;
+ stockUnitValue?: string | null;
+ purchaseUnitValue?: string | null;
+ salesUnitValue?: string | null;
+ baseUnitCorrect?: boolean | null;
+ stockUnitCorrect?: boolean | null;
+ purchaseUnitCorrect?: boolean | null;
+ salesUnitCorrect?: boolean | null;
+ baseUnitModifiedAt?: string | null;
+ stockUnitModifiedAt?: string | null;
+ purchaseUnitModifiedAt?: string | null;
+ salesUnitModifiedAt?: string | null;
+ baseUnitStatus?: "OK" | "MISSING" | "DELETED" | string | null;
+ stockUnitStatus?: "OK" | "MISSING" | "DELETED" | string | null;
+ purchaseUnitStatus?: "OK" | "MISSING" | "DELETED" | string | null;
+ salesUnitStatus?: "OK" | "MISSING" | "DELETED" | string | null;
+}
+
+export type ItemUnitStatus = "OK" | "MISSING" | "DELETED";
+
+export interface MasterDataIssueSummaryTiming {
+ totalMs: number;
+ loadActiveUomsMs: number;
+ loadDeletedUomsMs: number;
+ loadMaterialsMs: number;
+ buildUomCacheMs: number;
+ loadBomsMs: number;
+ scanBomTabMs: number;
+ loadItemsMs: number;
+ scanItemTabMs: number;
+}
+
+export interface MasterDataIssueSummary {
+ bomGroupCount: number;
+ itemGroupCount: number;
+ totalGroupCount: number;
+ /** Set when requesting summary with {@code includeTiming=true}. */
+ timing?: MasterDataIssueSummaryTiming | null;
+}
diff --git a/src/app/api/stockIssue/actions.ts b/src/app/api/stockIssue/actions.ts
index f48f2cd..5461e1e 100644
--- a/src/app/api/stockIssue/actions.ts
+++ b/src/app/api/stockIssue/actions.ts
@@ -2,31 +2,11 @@
import { BASE_API_URL } from "@/config/api";
import { serverFetchJson } from "@/app/utils/fetchUtil";
+import { revalidateTag } from "next/cache";
import { cache } from "react";
import type { MessageResponse } from "@/app/api/shop/actions";
+import { RecordsRes } from "@/app/api/utils";
-// Export types/interfaces (these are safe to import in client components)
-export interface StockIssueResult {
- id: number;
- itemId: number;
- itemCode: string;
- itemDescription: string;
- lotId: number;
- lotNo: string;
- storeLocation: string | null;
- requiredQty: number | null;
- actualPickQty: number | null;
- missQty: number;
- badItemQty: number;
- bookQty: number;
- issueQty: number;
- issueRemark: string | null;
- pickerName: string | null;
- handleStatus: string;
- handleDate: string | null;
- handledBy: number | null;
- uomDesc: string | null;
-}
export interface ExpiryItemResult {
id: number;
itemId: number;
@@ -39,40 +19,46 @@ export interface ExpiryItemResult {
remainingQty: number;
}
-export interface StockIssueLists {
- missItems: StockIssueResult[];
- badItems: StockIssueResult[];
- expiryItems: ExpiryItemResult[];
+export interface ExpiryItemFilter {
+ expiryDate?: string;
+ itemCode?: string;
+ itemName?: string;
}
-// Server actions (these work from both server and client components)
-export const PreloadList = () => {
- fetchList();
-};
-
-export const fetchMissItemList = cache(async (issueCategory: string = "lot_issue") => {
- return serverFetchJson(
- `${BASE_API_URL}/pickExecution/issues/missItem?issueCategory=${issueCategory}`,
- {
- next: { tags: ["Miss Item List"] },
- },
- );
-});
-
-export const fetchBadItemList = cache(async (issueCategory: string = "lot_issue") => {
- return serverFetchJson(
- `${BASE_API_URL}/pickExecution/issues/badItem?issueCategory=${issueCategory}`,
- {
- next: { tags: ["Bad Item List"] },
- },
- );
-});
+export interface HandleBadItemRequest {
+ inventoryLotLineId: number;
+ qty: number;
+ remarks?: string;
+ handler?: number;
+}
+export interface StockIssueHandleRecord {
+ id: number;
+ stockOutLineId: number | null;
+ stockOutId: number | null;
+ handledAt: string | null;
+ itemId: number | null;
+ itemCode: string | null;
+ itemName: string | null;
+ lotNo: string | null;
+ storeLocation: string | null;
+ expiryDate: string | null;
+ qty: number;
+ uomDesc: string | null;
+ handlerId: number | null;
+ handlerName: string | null;
+ remarks: string | null;
+ type: string | null;
+}
-export interface ExpiryItemFilter {
- expiryDate?: string;
+export interface SearchStockIssueRecordParams {
+ startDate?: string;
+ endDate?: string;
itemCode?: string;
itemName?: string;
+ lotNo?: string;
+ pageNum?: number;
+ pageSize?: number;
}
export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => {
@@ -82,160 +68,73 @@ export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => {
if (filters?.itemName) params.set("itemName", filters.itemName);
const queryString = params.toString();
const url = `${BASE_API_URL}/pickExecution/issues/expiryItem${queryString ? `?${queryString}` : ""}`;
- return serverFetchJson(
- url,
+ return serverFetchJson(url, {
+ next: { tags: ["Expiry Item List"] },
+ });
+});
+
+export async function handleBadItem(request: HandleBadItemRequest) {
+ const res = await serverFetchJson(
+ `${BASE_API_URL}/stockIssue/handleBadItem`,
{
- next: { tags: ["Expiry Item List"] },
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(request),
},
);
-});
-
-export const fetchList = cache(async (issueCategory: string = "lot_issue"): Promise => {
- const [missItems, badItems, expiryItems] = await Promise.all([
- fetchMissItemList(issueCategory),
- fetchBadItemList(issueCategory),
- fetchExpiryItemList(),
- ]);
+ if (!res?.code || res.code === "SUCCESS") {
+ revalidateTag("inventoryLotLines");
+ revalidateTag("inventories");
+ }
+ return res;
+}
- return {
- missItems,
- badItems,
- expiryItems,
- };
-});
+export async function fetchBadItemRecords(params: SearchStockIssueRecordParams) {
+ const qs = new URLSearchParams();
+ if (params.startDate) qs.set("startDate", params.startDate);
+ if (params.endDate) qs.set("endDate", params.endDate);
+ if (params.itemCode) qs.set("itemCode", params.itemCode);
+ if (params.itemName) qs.set("itemName", params.itemName);
+ if (params.lotNo) qs.set("lotNo", params.lotNo);
+ qs.set("pageNum", String(params.pageNum ?? 0));
+ qs.set("pageSize", String(params.pageSize ?? 20));
+ return serverFetchJson>(
+ `${BASE_API_URL}/stockIssue/badItemRecords?${qs.toString()}`,
+ );
+}
-export async function submitMissItem(issueId: number, handler: number) {
- return serverFetchJson(
- `${BASE_API_URL}/pickExecution/submitMissItem`,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ issueId, handler }),
- },
- );
- }
-
- export async function batchSubmitMissItem(issueIds: number[], handler: number) {
- return serverFetchJson(
- `${BASE_API_URL}/pickExecution/batchSubmitMissItem`,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ issueIds, handler }),
- },
- );
- }
-
- export async function submitBadItem(issueId: number, handler: number) {
- return serverFetchJson(
- `${BASE_API_URL}/pickExecution/submitBadItem`,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ issueId, handler }),
- },
- );
- }
-
- export async function batchSubmitBadItem(issueIds: number[], handler: number) {
- return serverFetchJson(
- `${BASE_API_URL}/pickExecution/batchSubmitBadItem`,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ issueIds, handler }),
- },
- );
- }
-
- export async function submitExpiryItem(lotLineId: number, handler: number) {
- return serverFetchJson(
- `${BASE_API_URL}/pickExecution/submitExpiryItem`,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ lotLineId, handler }),
- },
- );
- }
-
- export async function batchSubmitExpiryItem(lotLineIds: number[], handler: number) {
- return serverFetchJson(
- `${BASE_API_URL}/pickExecution/batchSubmitExpiryItem`,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ lotLineIds, handler }),
- },
- );
- }
+export async function fetchExpiryItemRecords(params: SearchStockIssueRecordParams) {
+ const qs = new URLSearchParams();
+ if (params.startDate) qs.set("startDate", params.startDate);
+ if (params.endDate) qs.set("endDate", params.endDate);
+ if (params.itemCode) qs.set("itemCode", params.itemCode);
+ if (params.itemName) qs.set("itemName", params.itemName);
+ if (params.lotNo) qs.set("lotNo", params.lotNo);
+ qs.set("pageNum", String(params.pageNum ?? 0));
+ qs.set("pageSize", String(params.pageSize ?? 20));
+ return serverFetchJson>(
+ `${BASE_API_URL}/stockIssue/expiryItemRecords?${qs.toString()}`,
+ );
+}
+export async function submitExpiryItem(lotLineId: number, handler: number) {
+ return serverFetchJson(
+ `${BASE_API_URL}/pickExecution/submitExpiryItem`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ lotLineId, handler }),
+ },
+ );
+}
- export interface LotIssueDetailResponse {
- lotId: number | null;
- lotNo: string | null;
- itemId: number;
- itemCode: string | null;
- itemDescription: string | null;
- storeLocation: string | null;
- issues: IssueDetailItem[];
- bookQty: number;
- uomDesc: string | null;
- }
-
- export interface IssueDetailItem {
- issueId: number;
- pickerName: string | null;
- missQty: number | null;
- issueQty: number | null;
- pickOrderCode: string;
- doOrderCode: string | null;
- joOrderCode: string | null;
- issueRemark: string | null;
- }
-
- export async function getLotIssueDetails(
- lotId: number,
- itemId: number,
- issueType: "miss" | "bad"
- ) {
- return serverFetchJson(
- `${BASE_API_URL}/pickExecution/lotIssueDetails?lotId=${lotId}&itemId=${itemId}&issueType=${issueType}`,
- {
- method: "GET",
- headers: {
- "Content-Type": "application/json",
- },
- }
- );
- }
-
- export async function submitIssueWithQty(
- lotId: number,
- itemId: number,
- issueType: "miss" | "bad",
- submitQty: number,
- handler: number
- ){return serverFetchJson(
- `${BASE_API_URL}/pickExecution/submitIssueWithQty`,
+export async function batchSubmitExpiryItem(lotLineIds: number[], handler: number) {
+ return serverFetchJson(
+ `${BASE_API_URL}/pickExecution/batchSubmitExpiryItem`,
{
method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ lotId, itemId, issueType, submitQty, handler }),
- }
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ lotLineIds, handler }),
+ },
);
- }
\ No newline at end of file
+}
diff --git a/src/components/DoSearch/DoSearch.tsx b/src/components/DoSearch/DoSearch.tsx
index aec1273..35218db 100644
--- a/src/components/DoSearch/DoSearch.tsx
+++ b/src/components/DoSearch/DoSearch.tsx
@@ -79,7 +79,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea
//console.log("🔍 DoSearch - session:", session);
//console.log("🔍 DoSearch - currentUserId:", currentUserId);
const [searchTimeout, setSearchTimeout] = useState(null);
- /** 使用者明確取消勾選的送貨單 id;未在此集合中的搜尋結果視為「已選」以便跨頁記憶 */
+ /** 使用者明確取消勾選的送貨單 id;未在此集合中的搜索結果視為「已選」以便跨頁記憶 */
const [excludedRowIds, setExcludedRowIds] = useState([]);
const [searchAllDos, setSearchAllDos] = useState([]);
@@ -716,7 +716,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea
setSearchBoxResetKey((prev) => prev + 1);
setPagingController((prev) => ({ ...prev, pageNum: 1 }));
setExcludedRowIds([]);
- // 切換 tab 僅重置搜尋條件與結果;由使用者再次按「搜尋」後才查詢。
+ // 切換 tab 僅重置搜索條件與結果;由使用者再次按「搜索」後才查詢。
setSearchAllDos([]);
setTotalCount(0);
setHasSearched(false);
diff --git a/src/components/ImportBom/ImportBomResultForm.tsx b/src/components/ImportBom/ImportBomResultForm.tsx
index 223db70..12a94c0 100644
--- a/src/components/ImportBom/ImportBomResultForm.tsx
+++ b/src/components/ImportBom/ImportBomResultForm.tsx
@@ -156,7 +156,7 @@ type Props = {
setSearch(e.target.value)}
InputProps={{
diff --git a/src/components/JoSearch/JoSearch.tsx b/src/components/JoSearch/JoSearch.tsx
index 34dc8f9..854f8c2 100644
--- a/src/components/JoSearch/JoSearch.tsx
+++ b/src/components/JoSearch/JoSearch.tsx
@@ -666,7 +666,6 @@ const JoSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobT
const { data: session } = useSession();
const sessionToken = session as SessionWithTokens | null;
-
const [openModal, setOpenModal] = useState(false);
const [modalInfo, setModalInfo] = useState();
diff --git a/src/components/JoWorkbench/JoWorkbenchSearch.tsx b/src/components/JoWorkbench/JoWorkbenchSearch.tsx
index f5ca4cb..7f8c1eb 100644
--- a/src/components/JoWorkbench/JoWorkbenchSearch.tsx
+++ b/src/components/JoWorkbench/JoWorkbenchSearch.tsx
@@ -667,7 +667,6 @@ const JoWorkbenchSearch: React.FC = ({ defaultInputs, bomCombo, printerCo
const { data: session } = useSession();
const sessionToken = session as SessionWithTokens | null;
-
const [openModal, setOpenModal] = useState(false);
const [modalInfo, setModalInfo] = useState();
diff --git a/src/components/MasterDataIssues/MasterDataIssueDetailDialog.tsx b/src/components/MasterDataIssues/MasterDataIssueDetailDialog.tsx
new file mode 100644
index 0000000..91eed40
--- /dev/null
+++ b/src/components/MasterDataIssues/MasterDataIssueDetailDialog.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import React, { useMemo } from "react";
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ IconButton,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Typography,
+ Stack,
+} from "@mui/material";
+import { X } from "lucide-react";
+import { useTranslation } from "react-i18next";
+import type { MasterDataIssueGroup } from "./groupMasterDataIssues";
+import { buildDetailRows, primaryTitle } from "./buildDisplayLines";
+
+interface Props {
+ open: boolean;
+ group: MasterDataIssueGroup | null;
+ onClose: () => void;
+}
+
+const MasterDataIssueDetailDialog: React.FC = ({
+ open,
+ group,
+ onClose,
+}) => {
+ const { t } = useTranslation("masterDataIssue");
+
+ const issueMessage = (code: string) =>
+ t(`masterDataIssue_${code}`, { defaultValue: code });
+
+ const detailRows = useMemo(() => {
+ if (!group) return [];
+ return buildDetailRows(group, t, issueMessage);
+ }, [group, t]);
+
+ if (!group) return null;
+
+ const showBomCol = group.groupType !== "item";
+ const showUomCols = detailRows.some(
+ (r) => r.expected != null || r.actual != null,
+ );
+
+ return (
+
+ );
+};
+
+export default MasterDataIssueDetailDialog;
diff --git a/src/components/MasterDataIssues/MasterDataIssuesPanel.tsx b/src/components/MasterDataIssues/MasterDataIssuesPanel.tsx
new file mode 100644
index 0000000..5f409cb
--- /dev/null
+++ b/src/components/MasterDataIssues/MasterDataIssuesPanel.tsx
@@ -0,0 +1,611 @@
+"use client";
+
+import React, { useCallback, useMemo, useRef, useState } from "react";
+import {
+ Alert,
+ Box,
+ Button,
+ Chip,
+ CircularProgress,
+ MenuItem,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ Typography,
+ Paper,
+} from "@mui/material";
+import { useTranslation } from "react-i18next";
+import { Check, Clock, X } from "lucide-react";
+import type {
+ ItemUnitStatus,
+ MasterDataIssue,
+ MasterDataIssueCode,
+} from "@/app/api/masterDataIssues";
+import MasterDataIssueDetailDialog from "./MasterDataIssueDetailDialog";
+import {
+ buildDisplayLines,
+ materialBomSubtitle,
+ needsDetailButton,
+ primaryTitle,
+} from "./buildDisplayLines";
+import {
+ formatItemLabel,
+ groupBomTabIssues,
+ groupItemTabIssues,
+ type MasterDataIssueGroup,
+} from "./groupMasterDataIssues";
+
+export type MasterDataIssuesPanelMode = "item" | "bom";
+
+interface ItemUnitCellProps {
+ status: ItemUnitStatus;
+ value: string;
+ modifiedAt: string;
+ activeLabel: string;
+ inactiveLabel: string;
+ missingLabel: string;
+}
+
+function splitUomDisplay(value: string): { code: string; desc: string } {
+ const trimmed = value?.trim() ?? "";
+ if (!trimmed || trimmed === "-") return { code: "-", desc: "-" };
+ const sep = trimmed.indexOf(" / ");
+ if (sep >= 0) {
+ return {
+ code: trimmed.slice(0, sep).trim() || "-",
+ desc: trimmed.slice(sep + 3).trim() || "-",
+ };
+ }
+ return { code: trimmed, desc: "-" };
+}
+
+const ItemUnitStatusCell: React.FC = ({
+ status,
+ value,
+ modifiedAt,
+ activeLabel,
+ inactiveLabel,
+ missingLabel,
+}) => {
+ const isActive = status === "OK";
+ const isMissing = status === "MISSING";
+ const isDeleted = status === "DELETED";
+ const { code, desc } = splitUomDisplay(value);
+
+ const badgeBg = isActive ? "success.50" : isMissing ? "grey.100" : "error.50";
+ const badgeColor = isActive ? "success.dark" : isMissing ? "text.secondary" : "error.dark";
+ const badgeText = isActive ? activeLabel : isMissing ? missingLabel : inactiveLabel;
+
+ return (
+
+
+
+ {desc}
+
+
+
+
+ {code}
+
+
+
+
+ {isActive ? : null}
+ {isDeleted ? : null}
+
+ {badgeText}
+
+
+
+
+ {!isMissing ? (
+ <>
+
+
+ {modifiedAt}
+
+ >
+ ) : (
+
+ -
+
+ )}
+
+
+ );
+};
+
+const itemUnitCellSx = {
+ verticalAlign: "top" as const,
+ width: "18%",
+ minWidth: 155,
+};
+
+interface Props {
+ mode: MasterDataIssuesPanelMode;
+ issues: MasterDataIssue[];
+ loading: boolean;
+ loadError: string | null;
+ onRefresh: () => void;
+}
+
+const MasterDataIssuesPanel: React.FC = ({
+ mode,
+ issues,
+ loading,
+ loadError,
+ onRefresh,
+}) => {
+ const { t } = useTranslation("masterDataIssue");
+ const inFlightRef = useRef(false);
+ const [scopeFilter, setScopeFilter] = useState("ALL");
+ const [search, setSearch] = useState("");
+ const [detailGroup, setDetailGroup] = useState(
+ null,
+ );
+
+ const issueMessage = useCallback(
+ (code: MasterDataIssueCode | string) =>
+ t(`masterDataIssue_${code}`, { defaultValue: code }),
+ [t],
+ );
+
+ const scopeLabel = useCallback(
+ (scope: string) =>
+ t(`masterDataIssue_scope_${scope}`, { defaultValue: scope }),
+ [t],
+ );
+
+ const isPickingOnlyIssue = useCallback((issueCode: string) => {
+ return issueCode.includes("PICKING");
+ }, []);
+
+ const filteredIssues = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ return issues.filter((row) => {
+ // Item tab only shows 4 units (base/stock/purchase/sales), so exclude picking-only issues.
+ if (mode === "item" && isPickingOnlyIssue(row.issueCode)) {
+ return false;
+ }
+ if (mode === "bom" && scopeFilter !== "ALL" && row.scope !== scopeFilter) {
+ return false;
+ }
+ if (!q) return true;
+ const hay = [
+ row.bomCode,
+ row.bomName,
+ row.itemCode,
+ row.itemName,
+ row.issueCode,
+ row.expectedValue,
+ row.actualValue,
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+ return hay.includes(q);
+ });
+ }, [issues, mode, scopeFilter, search, isPickingOnlyIssue]);
+
+ const groups = useMemo(() => {
+ return mode === "item"
+ ? groupItemTabIssues(filteredIssues)
+ : groupBomTabIssues(filteredIssues);
+ }, [filteredIssues, mode]);
+
+ const groupsWithLines = useMemo(
+ () =>
+ groups.map((g) => ({
+ group: g,
+ lines: buildDisplayLines(g, t, issueMessage),
+ showDetail: false as boolean,
+ })).map((entry) => ({
+ ...entry,
+ showDetail: needsDetailButton(entry.group, entry.lines),
+ })),
+ [groups, t, issueMessage],
+ );
+
+ const formatModifiedAt = useCallback((value?: string | null) => {
+ if (!value) return "-";
+ const d = new Date(value);
+ if (Number.isNaN(d.getTime())) return value;
+ return d.toLocaleString();
+ }, []);
+
+ const buildBomCompareRows = useCallback((group: MasterDataIssueGroup) => {
+ const rows = group.issues
+ .filter((r) =>
+ [
+ "BOM_OUTPUT_UOM_MISMATCH_SALES",
+ "BOM_MATERIAL_SALES_UOM_MISMATCH",
+ "BOM_MATERIAL_STOCK_UOM_MISMATCH",
+ "BOM_MATERIAL_BASE_UOM_MISMATCH",
+ ].includes(r.issueCode),
+ )
+ .map((r) => {
+ const unitLabel =
+ r.issueCode === "BOM_OUTPUT_UOM_MISMATCH_SALES"
+ ? t("masterDataIssue_unit_output", { defaultValue: "产出单位" })
+ : r.issueCode === "BOM_MATERIAL_SALES_UOM_MISMATCH"
+ ? t("masterDataIssue_unit_sales")
+ : r.issueCode === "BOM_MATERIAL_STOCK_UOM_MISMATCH"
+ ? t("masterDataIssue_unit_stock")
+ : t("masterDataIssue_unit_base");
+ return {
+ key: `${r.issueCode}-${r.bomMaterialId ?? "x"}`,
+ unitLabel,
+ bomValue: r.actualValue ?? "-",
+ itemValue: r.expectedValue ?? "-",
+ };
+ });
+ if (rows.length > 0) return rows;
+ return [
+ {
+ key: "fallback",
+ unitLabel: t("masterDataIssue_unit_output", { defaultValue: "产出单位" }),
+ bomValue: "-",
+ itemValue: "-",
+ },
+ ];
+ }, [t]);
+
+interface BomCompareRow {
+ key: string;
+ unitLabel: string;
+ bomValue: string;
+ itemValue: string;
+}
+
+interface BomCompareColumnProps {
+ rows: BomCompareRow[];
+ valueKey: "bomValue" | "itemValue";
+ labelColor: string;
+ valueColor: string;
+}
+
+const BomCompareColumn: React.FC = ({
+ rows,
+ valueKey,
+ labelColor,
+ valueColor,
+}) => (
+
+ {rows.map((row) => (
+
+
+ {row.unitLabel}
+
+
+ {row[valueKey]}
+
+
+ ))}
+
+);
+
+ const parseUnitStatus = useCallback((raw?: string | null): ItemUnitStatus => {
+ if (raw === "OK" || raw === "DELETED" || raw === "MISSING") return raw;
+ return "MISSING";
+ }, []);
+
+ const unitSnapshotFromGroup = useCallback((group: MasterDataIssueGroup) => {
+ const row = group.issues[0];
+ const cell = (
+ value: string | null | undefined,
+ status: string | null | undefined,
+ modifiedAt: string | null | undefined,
+ ) => {
+ const unitStatus = parseUnitStatus(status);
+ return {
+ status: unitStatus,
+ value: value?.trim() ? value : "-",
+ modifiedAt: formatModifiedAt(modifiedAt),
+ };
+ };
+ return {
+ base: cell(row?.baseUnitValue, row?.baseUnitStatus, row?.baseUnitModifiedAt),
+ stock: cell(row?.stockUnitValue, row?.stockUnitStatus, row?.stockUnitModifiedAt),
+ purchase: cell(row?.purchaseUnitValue, row?.purchaseUnitStatus, row?.purchaseUnitModifiedAt),
+ sales: cell(row?.salesUnitValue, row?.salesUnitStatus, row?.salesUnitModifiedAt),
+ };
+ }, [formatModifiedAt, parseUnitStatus]);
+
+ const clipboardText = useMemo(() => {
+ return groupsWithLines
+ .map(({ group, lines }) => {
+ const title = primaryTitle(group);
+ return `${title}\n${lines.map((l) => ` ${l}`).join("\n")}`;
+ })
+ .join("\n\n");
+ }, [groupsWithLines]);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(clipboardText);
+ } catch {
+ console.warn("clipboard write failed");
+ }
+ };
+
+ const handleRefreshClick = () => {
+ if (inFlightRef.current) return;
+ inFlightRef.current = true;
+ Promise.resolve(onRefresh()).finally(() => {
+ inFlightRef.current = false;
+ });
+ };
+
+ return (
+
+
+
+ {t("masterDataIssue_group_count", {
+ groups: groups.length,
+ issues: filteredIssues.length,
+ })}
+
+
+ setSearch(e.target.value)}
+ sx={{ minWidth: 180 }}
+ />
+ {mode === "bom" ? (
+ setScopeFilter(e.target.value)}
+ sx={{ minWidth: 140 }}
+ >
+
+
+
+
+ ) : null}
+
+
+
+
+ {loadError && (
+
+ {loadError}
+
+ )}
+
+ {loading && issues.length === 0 && !loadError ? (
+
+
+
+ ) : null}
+
+ {!loading && !loadError && groupsWithLines.length === 0 ? (
+
+ {t("masterDataIssue_empty")}
+
+ ) : null}
+
+ {groupsWithLines.length > 0 ? (
+
+
+
+
+
+ {mode === "item"
+ ? t("masterDataIssue_col_item")
+ : t("masterDataIssue_col_subject")}
+
+ {mode === "bom" ? (
+ <>
+ {t("masterDataIssue_col_bom_uom", { defaultValue: "BOM UOM" })}
+ {t("masterDataIssue_col_item_uom", { defaultValue: "Item 正确 UOM" })}
+ >
+ ) : (
+ <>
+ {t("masterDataIssue_unit_base")}
+ {t("masterDataIssue_unit_stock")}
+ {t("masterDataIssue_unit_purchase")}
+ {t("masterDataIssue_unit_sales")}
+ >
+ )}
+
+
+
+
+ {groupsWithLines.map(({ group, showDetail }) => {
+ const matSubtitle = materialBomSubtitle(group, t);
+ const unitSnapshot = unitSnapshotFromGroup(group);
+ const bomCompareRows = buildBomCompareRows(group);
+ return (
+
+
+ {mode === "item" ? (
+
+ {group.itemCode?.trim() ? (
+
+ {group.itemCode.trim()}
+
+ ) : null}
+ {group.itemName?.trim() ? (
+
+ {group.itemName.trim()}
+
+ ) : !group.itemCode?.trim() ? (
+
+ {formatItemLabel(group)}
+
+ ) : null}
+
+ ) : (
+
+ {primaryTitle(group)}
+
+ )}
+ {mode === "bom" && group.groupType === "bom_header" ? (
+
+
+
+
+ {group.itemCode?.trim() ? (
+
+
+ {[group.itemCode.trim(), group.itemName?.trim()].filter(Boolean).join(" · ")}
+
+ ) : null}
+
+ ) : null}
+ {mode === "bom" && group.groupType === "bom_material" ? (
+
+
+
+
+ {matSubtitle ? (
+
+ {matSubtitle}
+
+ ) : null}
+
+ ) : null}
+
+ {mode === "bom" ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+ {(
+ [
+ ["base", unitSnapshot.base],
+ ["stock", unitSnapshot.stock],
+ ["purchase", unitSnapshot.purchase],
+ ["sales", unitSnapshot.sales],
+ ] as const
+ ).map(([key, unit]) => (
+
+
+
+ ))}
+ >
+ )}
+
+
+ );
+ })}
+
+
+
+ ) : null}
+
+ setDetailGroup(null)}
+ />
+
+ );
+};
+
+export default MasterDataIssuesPanel;
diff --git a/src/components/MasterDataIssues/MasterDataIssuesTabs.tsx b/src/components/MasterDataIssues/MasterDataIssuesTabs.tsx
new file mode 100644
index 0000000..adc6602
--- /dev/null
+++ b/src/components/MasterDataIssues/MasterDataIssuesTabs.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import React, { useCallback, useRef, useState } from "react";
+import { Box, Tab, Tabs } from "@mui/material";
+import { useTranslation } from "react-i18next";
+import type { MasterDataIssue } from "@/app/api/masterDataIssues";
+import {
+ fetchBomMasterDataIssuesClient,
+ fetchItemMasterDataIssuesClient,
+} from "@/app/api/masterDataIssues/client";
+import MasterDataIssuesPanel from "./MasterDataIssuesPanel";
+
+const MasterDataIssuesTabs: React.FC = () => {
+ const { t } = useTranslation("masterDataIssue");
+ const [tab, setTab] = useState(0);
+ const [bomIssues, setBomIssues] = useState([]);
+ const [itemIssues, setItemIssues] = useState([]);
+ const [bomLoading, setBomLoading] = useState(false);
+ const [itemLoading, setItemLoading] = useState(false);
+ const [bomError, setBomError] = useState(null);
+ const [itemError, setItemError] = useState(null);
+ const bomLoadedRef = useRef(false);
+ const itemLoadedRef = useRef(false);
+ const bomInFlightRef = useRef(false);
+ const itemInFlightRef = useRef(false);
+
+ const loadBomIssues = useCallback(async () => {
+ if (bomInFlightRef.current) return;
+ bomInFlightRef.current = true;
+ setBomLoading(true);
+ setBomError(null);
+ try {
+ const data = await fetchBomMasterDataIssuesClient();
+ setBomIssues(Array.isArray(data) ? data : []);
+ bomLoadedRef.current = true;
+ } catch (e) {
+ console.error(e);
+ setBomError(t("masterDataIssue_loadFailed"));
+ setBomIssues([]);
+ } finally {
+ setBomLoading(false);
+ bomInFlightRef.current = false;
+ }
+ }, [t]);
+
+ const loadItemIssues = useCallback(async () => {
+ if (itemInFlightRef.current) return;
+ itemInFlightRef.current = true;
+ setItemLoading(true);
+ setItemError(null);
+ try {
+ const data = await fetchItemMasterDataIssuesClient();
+ setItemIssues(Array.isArray(data) ? data : []);
+ itemLoadedRef.current = true;
+ } catch (e) {
+ console.error(e);
+ setItemError(t("masterDataIssue_loadFailed"));
+ setItemIssues([]);
+ } finally {
+ setItemLoading(false);
+ itemInFlightRef.current = false;
+ }
+ }, [t]);
+
+ React.useEffect(() => {
+ void loadBomIssues();
+ }, [loadBomIssues]);
+
+ React.useEffect(() => {
+ if (tab === 1 && !itemLoadedRef.current) {
+ void loadItemIssues();
+ }
+ }, [tab, loadItemIssues]);
+
+ return (
+
+ setTab(v)}
+ sx={{ mb: 2, borderBottom: 1, borderColor: "divider" }}
+ >
+
+
+
+
+ {tab === 0 ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default MasterDataIssuesTabs;
diff --git a/src/components/MasterDataIssues/buildDisplayLines.ts b/src/components/MasterDataIssues/buildDisplayLines.ts
new file mode 100644
index 0000000..40b30d0
--- /dev/null
+++ b/src/components/MasterDataIssues/buildDisplayLines.ts
@@ -0,0 +1,382 @@
+import type { TFunction } from "i18next";
+import type { MasterDataIssue } from "@/app/api/masterDataIssues";
+import {
+ bomLabelForCode,
+ formatBomLabel,
+ formatItemLabel,
+ type MasterDataIssueGroup,
+} from "./groupMasterDataIssues";
+
+export interface MasterDataDetailRow {
+ key: string;
+ bomLabel: string | null;
+ problem: string;
+ expected: string | null;
+ actual: string | null;
+}
+
+const MISSING_UNIT_CODES = [
+ "MISSING_BASE_UOM",
+ "MISSING_SALES_UOM",
+ "MISSING_STOCK_UOM",
+ "MISSING_PICKING_UOM",
+ "MISSING_PURCHASE_UOM",
+] as const;
+
+function unitShortName(issueCode: string, t: TFunction): string {
+ const map: Record = {
+ MISSING_BASE_UOM: t("masterDataIssue_unit_base"),
+ MISSING_SALES_UOM: t("masterDataIssue_unit_sales"),
+ MISSING_STOCK_UOM: t("masterDataIssue_unit_stock"),
+ MISSING_PICKING_UOM: t("masterDataIssue_unit_picking"),
+ MISSING_PURCHASE_UOM: t("masterDataIssue_unit_purchase"),
+ MISSING_BASE_UOM_CONVERSION: t("masterDataIssue_unit_base"),
+ MISSING_SALES_UOM_CONVERSION: t("masterDataIssue_unit_sales"),
+ MISSING_STOCK_UOM_CONVERSION: t("masterDataIssue_unit_stock"),
+ MISSING_PICKING_UOM_CONVERSION: t("masterDataIssue_unit_picking"),
+ MISSING_PURCHASE_UOM_CONVERSION: t("masterDataIssue_unit_purchase"),
+ MULTIPLE_BASE_UOM: t("masterDataIssue_unit_base"),
+ MULTIPLE_SALES_UOM: t("masterDataIssue_unit_sales"),
+ MULTIPLE_STOCK_UOM: t("masterDataIssue_unit_stock"),
+ MULTIPLE_PICKING_UOM: t("masterDataIssue_unit_picking"),
+ MULTIPLE_PURCHASE_UOM: t("masterDataIssue_unit_purchase"),
+ };
+ return map[issueCode] ?? issueCode;
+}
+
+function groupIssuesByBom(issues: MasterDataIssue[]): Map {
+ const map = new Map();
+ for (const row of issues) {
+ const bom = row.bomCode?.trim() || (row.bomId != null ? `BOM#${row.bomId}` : "-");
+ const list = map.get(bom) ?? [];
+ list.push(row);
+ map.set(bom, list);
+ }
+ return map;
+}
+
+/** UOM mismatch lines without BOM prefix (for material groups). */
+function uomPairLines(
+ rows: MasterDataIssue[],
+ t: TFunction,
+): string[] {
+ const lines: string[] = [];
+ const sales = rows.find((r) => r.issueCode === "BOM_MATERIAL_SALES_UOM_MISMATCH");
+ const stock = rows.find((r) => r.issueCode === "BOM_MATERIAL_STOCK_UOM_MISMATCH");
+ const base = rows.find((r) => r.issueCode === "BOM_MATERIAL_BASE_UOM_MISMATCH");
+
+ if (
+ sales &&
+ stock &&
+ sales.expectedValue === stock.expectedValue &&
+ sales.actualValue === stock.actualValue
+ ) {
+ lines.push(
+ t("masterDataIssue_line_pairBoth", {
+ expected: sales.expectedValue ?? "-",
+ actual: sales.actualValue ?? "-",
+ }),
+ );
+ } else {
+ if (sales) {
+ lines.push(
+ t("masterDataIssue_line_pairSales", {
+ expected: sales.expectedValue ?? "-",
+ actual: sales.actualValue ?? "-",
+ }),
+ );
+ }
+ if (stock) {
+ lines.push(
+ t("masterDataIssue_line_pairStock", {
+ expected: stock.expectedValue ?? "-",
+ actual: stock.actualValue ?? "-",
+ }),
+ );
+ }
+ }
+
+ if (base) {
+ lines.push(
+ t("masterDataIssue_line_pairBase", {
+ expected: base.expectedValue ?? "-",
+ actual: base.actualValue ?? "-",
+ }),
+ );
+ }
+
+ return lines;
+}
+
+function linesForBomHeaderRows(
+ bomCode: string,
+ rows: MasterDataIssue[],
+ t: TFunction,
+ issueMessage: (code: string) => string,
+): string[] {
+ const lines: string[] = [];
+ const used = new Set();
+
+ const output = rows.find((r) => r.issueCode === "BOM_OUTPUT_UOM_MISMATCH_SALES");
+ if (output) {
+ lines.push(
+ t("masterDataIssue_line_outputUom", {
+ bom: bomCode,
+ expected: output.expectedValue ?? "-",
+ actual: output.actualValue ?? "-",
+ }),
+ );
+ used.add(output);
+ }
+
+ const drift = rows.find((r) => r.issueCode === "BOM_OUTPUT_UOM_TEXT_DRIFT");
+ if (drift) {
+ lines.push(
+ t("masterDataIssue_line_outputText", {
+ bom: bomCode,
+ expected: drift.expectedValue ?? "-",
+ actual: drift.actualValue ?? "-",
+ }),
+ );
+ used.add(drift);
+ }
+
+ const missing = rows.filter((r) =>
+ MISSING_UNIT_CODES.includes(r.issueCode as (typeof MISSING_UNIT_CODES)[number]),
+ );
+ if (missing.length > 0) {
+ const names = Array.from(new Set(missing.map((r) => unitShortName(r.issueCode, t))));
+ lines.push(t("masterDataIssue_line_missingUnits", { bom: bomCode, units: names.join("、") }));
+ missing.forEach((r) => used.add(r));
+ }
+
+ for (const row of rows) {
+ if (used.has(row)) continue;
+ const short = issueMessage(row.issueCode);
+ if (row.expectedValue != null || row.actualValue != null) {
+ lines.push(
+ t("masterDataIssue_line_generic", {
+ bom: bomCode,
+ problem: short,
+ expected: row.expectedValue ?? "-",
+ actual: row.actualValue ?? "-",
+ }),
+ );
+ } else {
+ lines.push(t("masterDataIssue_line_problemOnly", { bom: bomCode, problem: short }));
+ }
+ used.add(row);
+ }
+
+ return lines;
+}
+
+/** One or more human-readable lines for the main list. */
+export function buildDisplayLines(
+ group: MasterDataIssueGroup,
+ t: TFunction,
+ issueMessage: (code: string) => string,
+): string[] {
+ if (group.groupType === "item") {
+ const missing = group.issues.filter((r) =>
+ MISSING_UNIT_CODES.includes(r.issueCode as (typeof MISSING_UNIT_CODES)[number]),
+ );
+ const lines: string[] = [];
+ if (missing.length > 0) {
+ const names = Array.from(
+ new Set(missing.map((r) => unitShortName(r.issueCode, t))),
+ );
+ lines.push(t("masterDataIssue_line_itemMissing", { units: names.join("、") }));
+ }
+ const rest = group.issues.filter((r) => !missing.includes(r));
+ for (const row of rest) {
+ const short = issueMessage(row.issueCode);
+ if (row.expectedValue != null || row.actualValue != null) {
+ lines.push(
+ t("masterDataIssue_line_itemGeneric", {
+ problem: short,
+ expected: row.expectedValue ?? "-",
+ actual: row.actualValue ?? "-",
+ }),
+ );
+ } else {
+ lines.push(short);
+ }
+ }
+ return lines.length > 0 ? lines : [issueMessage(group.issues[0]?.issueCode ?? "")];
+ }
+
+ if (group.groupType === "bom_header") {
+ const bom = group.bomCode?.trim() || formatBomLabel(group);
+ return linesForBomHeaderRows(bom, group.issues, t, issueMessage);
+ }
+
+ // BOM material: one row per (material + 应为/实际); right side = pair only, BOM list on left
+ const lines = uomPairLines(group.issues, t);
+ const used = new Set(
+ group.issues.filter((r) =>
+ [
+ "BOM_MATERIAL_SALES_UOM_MISMATCH",
+ "BOM_MATERIAL_STOCK_UOM_MISMATCH",
+ "BOM_MATERIAL_BASE_UOM_MISMATCH",
+ ].includes(r.issueCode),
+ ),
+ );
+
+ for (const row of group.issues) {
+ if (used.has(row)) continue;
+ lines.push(issueMessage(row.issueCode));
+ }
+
+ return lines.length > 0 ? lines : [issueMessage(group.issues[0]?.issueCode ?? "")];
+}
+
+/** Subtitle under material title: BOM codes sharing this problem. */
+export function materialBomSubtitle(
+ group: MasterDataIssueGroup,
+ t: TFunction,
+): string | null {
+ if (group.groupType !== "bom_material" || group.relatedBomLabels.length === 0) {
+ return null;
+ }
+ return t("masterDataIssue_materialUsedInBom", {
+ codes: group.relatedBomLabels.join("、"),
+ });
+}
+
+/** Merged rows for the detail dialog (4 columns). */
+export function buildDetailRows(
+ group: MasterDataIssueGroup,
+ t: TFunction,
+ issueMessage: (code: string) => string,
+): MasterDataDetailRow[] {
+ if (group.groupType === "item") {
+ const lines = buildDisplayLines(group, t, issueMessage);
+ return lines.map((text, i) => ({
+ key: `item-${i}`,
+ bomLabel: null,
+ problem: text,
+ expected: null,
+ actual: null,
+ }));
+ }
+
+ const rows: MasterDataDetailRow[] = [];
+ const byBom = groupIssuesByBom(group.issues);
+
+ for (const [bomCode, bomIssues] of Array.from(byBom.entries())) {
+ const bomLabel = bomLabelForCode(group.issues, bomCode);
+ const used = new Set();
+ const sales = bomIssues.find((r: MasterDataIssue) => r.issueCode === "BOM_MATERIAL_SALES_UOM_MISMATCH");
+ const stock = bomIssues.find((r: MasterDataIssue) => r.issueCode === "BOM_MATERIAL_STOCK_UOM_MISMATCH");
+ const base = bomIssues.find((r: MasterDataIssue) => r.issueCode === "BOM_MATERIAL_BASE_UOM_MISMATCH");
+
+ if (
+ sales &&
+ stock &&
+ sales.expectedValue === stock.expectedValue &&
+ sales.actualValue === stock.actualValue
+ ) {
+ rows.push({
+ key: `${bomCode}-uom-both`,
+ bomLabel,
+ problem: t("masterDataIssue_detail_uomBoth"),
+ expected: sales.expectedValue,
+ actual: sales.actualValue,
+ });
+ used.add(sales);
+ used.add(stock);
+ } else {
+ if (sales) {
+ rows.push({
+ key: `${bomCode}-sales`,
+ bomLabel,
+ problem: t("masterDataIssue_detail_uomSales"),
+ expected: sales.expectedValue,
+ actual: sales.actualValue,
+ });
+ used.add(sales);
+ }
+ if (stock) {
+ rows.push({
+ key: `${bomCode}-stock`,
+ bomLabel,
+ problem: t("masterDataIssue_detail_uomStock"),
+ expected: stock.expectedValue,
+ actual: stock.actualValue,
+ });
+ used.add(stock);
+ }
+ }
+
+ if (base) {
+ rows.push({
+ key: `${bomCode}-base`,
+ bomLabel,
+ problem: t("masterDataIssue_detail_uomBase"),
+ expected: base.expectedValue,
+ actual: base.actualValue,
+ });
+ used.add(base);
+ }
+
+ const output = bomIssues.find((r) => r.issueCode === "BOM_OUTPUT_UOM_MISMATCH_SALES");
+ if (output) {
+ rows.push({
+ key: `${bomCode}-output`,
+ bomLabel,
+ problem: issueMessage(output.issueCode),
+ expected: output.expectedValue,
+ actual: output.actualValue,
+ });
+ used.add(output);
+ }
+
+ for (const row of bomIssues) {
+ if (used.has(row)) continue;
+ rows.push({
+ key: `${bomCode}-${row.issueCode}-${row.bomMaterialId}`,
+ bomLabel,
+ problem: issueMessage(row.issueCode),
+ expected: row.expectedValue,
+ actual: row.actualValue,
+ });
+ }
+ }
+
+ if (rows.length > 0) return rows;
+
+ const lines = buildDisplayLines(group, t, issueMessage);
+ return lines.map((text, i) => ({
+ key: `fallback-${i}`,
+ bomLabel: null,
+ problem: text,
+ expected: null,
+ actual: null,
+ }));
+}
+
+export function needsDetailButton(
+ group: MasterDataIssueGroup,
+ displayLines: string[],
+): boolean {
+ if (group.groupType === "bom_material") {
+ return true;
+ }
+ if (group.groupType === "bom_header") {
+ if (displayLines.length > 1) return true;
+ if (group.issues.length > 2) return true;
+ return false;
+ }
+ if (displayLines.length > 1) return true;
+ if (group.issues.length > 2) return true;
+ return false;
+}
+
+export function primaryTitle(group: MasterDataIssueGroup): string {
+ if (group.groupType === "bom_header") return formatBomLabel(group);
+ const code = group.itemCode?.trim();
+ const name = group.itemName?.trim();
+ if (code && name) return `${code} · ${name}`;
+ return formatItemLabel(group);
+}
diff --git a/src/components/MasterDataIssues/groupMasterDataIssues.ts b/src/components/MasterDataIssues/groupMasterDataIssues.ts
new file mode 100644
index 0000000..fe182d6
--- /dev/null
+++ b/src/components/MasterDataIssues/groupMasterDataIssues.ts
@@ -0,0 +1,236 @@
+import type { MasterDataIssue } from "@/app/api/masterDataIssues";
+
+export type MasterDataIssueGroupType = "item" | "bom_header" | "bom_material";
+
+export interface MasterDataIssueGroup {
+ groupKey: string;
+ groupType: MasterDataIssueGroupType;
+ itemId: number | null;
+ itemCode: string | null;
+ itemName: string | null;
+ bomId: number | null;
+ bomCode: string | null;
+ bomName: string | null;
+ /** BOM codes that reference this material (bom_material groups only). */
+ relatedBomCodes: string[];
+ /** Formatted BOM labels (code · name) for bom_material groups. */
+ relatedBomLabels: string[];
+ issues: MasterDataIssue[];
+}
+
+export function formatItemLabel(row: {
+ itemCode?: string | null;
+ itemName?: string | null;
+ itemId?: number | null;
+}): string {
+ const code = row.itemCode?.trim();
+ const name = row.itemName?.trim();
+ if (code && name) return `${code} · ${name}`;
+ if (code) return code;
+ if (name) return name;
+ if (row.itemId != null) return `item#${row.itemId}`;
+ return "-";
+}
+
+export function formatBomLabel(row: {
+ bomCode?: string | null;
+ bomName?: string | null;
+ bomId?: number | null;
+}): string {
+ const code = row.bomCode?.trim();
+ const name = row.bomName?.trim();
+ if (code && name) return `${code} · ${name}`;
+ if (code) return code;
+ if (name) return name;
+ if (row.bomId != null) return `BOM#${row.bomId}`;
+ return "-";
+}
+
+function pickItemFields(issues: MasterDataIssue[]) {
+ const first = issues[0];
+ let itemCode: string | null = null;
+ let itemName: string | null = null;
+ for (const row of issues) {
+ const code = row.itemCode?.trim();
+ const name = row.itemName?.trim();
+ if (code && !itemCode) itemCode = code;
+ if (name) {
+ if (!itemName || name.length > itemName.length) itemName = name;
+ }
+ }
+ return {
+ itemId: first.itemId,
+ itemCode: itemCode ?? first.itemCode,
+ itemName: itemName ?? first.itemName,
+ };
+}
+
+function uniqueBomCodes(issues: MasterDataIssue[]): string[] {
+ const codes = new Set();
+ for (const row of issues) {
+ const c = row.bomCode?.trim();
+ if (c) codes.add(c);
+ }
+ return Array.from(codes).sort((a, b) => a.localeCompare(b));
+}
+
+/** Best bomName per bomCode from issue rows. */
+function bomNameByCode(issues: MasterDataIssue[]): Map {
+ const map = new Map();
+ for (const row of issues) {
+ const code = row.bomCode?.trim();
+ if (!code) continue;
+ const name = row.bomName?.trim() || null;
+ if (!map.has(code)) {
+ map.set(code, name);
+ continue;
+ }
+ const prev = map.get(code);
+ if (name && (!prev || name.length > prev.length)) {
+ map.set(code, name);
+ }
+ }
+ return map;
+}
+
+function uniqueBomLabels(issues: MasterDataIssue[]): string[] {
+ const names = bomNameByCode(issues);
+ return uniqueBomCodes(issues).map((code) =>
+ formatBomLabel({ bomCode: code, bomName: names.get(code) }),
+ );
+}
+
+/** Label for one BOM code within a group's issues. */
+export function bomLabelForCode(
+ issues: MasterDataIssue[],
+ bomCode: string,
+): string {
+ const code = bomCode.trim();
+ const names = bomNameByCode(issues);
+ return formatBomLabel({ bomCode: code, bomName: names.get(code) });
+}
+
+/** Group BOM material rows by item + same 应为/实际 (sales+stock with same pair share one key). */
+function materialPatternKey(row: MasterDataIssue): string {
+ const exp = row.expectedValue ?? "";
+ const act = row.actualValue ?? "";
+ if (
+ row.issueCode === "BOM_MATERIAL_SALES_UOM_MISMATCH" ||
+ row.issueCode === "BOM_MATERIAL_STOCK_UOM_MISMATCH"
+ ) {
+ return `uom:${exp}|${act}`;
+ }
+ if (row.issueCode === "BOM_MATERIAL_BASE_UOM_MISMATCH") {
+ return `base:${exp}|${act}`;
+ }
+ return `${row.issueCode}|${exp}|${act}`;
+}
+
+function materialItemKey(row: MasterDataIssue): string {
+ if (row.itemId != null) return `i:${row.itemId}`;
+ return `ic:${row.itemCode?.trim() || row.bomMaterialId || "unknown"}`;
+}
+
+export function groupItemTabIssues(issues: MasterDataIssue[]): MasterDataIssueGroup[] {
+ const map = new Map();
+ for (const row of issues) {
+ const key =
+ row.itemId != null
+ ? `item:${row.itemId}`
+ : `code:${row.itemCode?.trim() || "unknown"}`;
+ const list = map.get(key) ?? [];
+ list.push(row);
+ map.set(key, list);
+ }
+ return Array.from(map.entries())
+ .map(([, rows]) => {
+ const item = pickItemFields(rows);
+ return {
+ groupKey: `item:${item.itemId ?? rows[0].itemCode}`,
+ groupType: "item" as const,
+ ...item,
+ bomId: null,
+ bomCode: null,
+ bomName: null,
+ relatedBomCodes: [],
+ relatedBomLabels: [],
+ issues: rows,
+ };
+ })
+ .sort((a, b) =>
+ formatItemLabel(a).localeCompare(formatItemLabel(b), undefined, {
+ sensitivity: "base",
+ }),
+ );
+}
+
+export function groupBomTabIssues(issues: MasterDataIssue[]): MasterDataIssueGroup[] {
+ const headerMap = new Map();
+ const materialMap = new Map();
+
+ for (const row of issues) {
+ if (row.scope === "BOM_MATERIAL") {
+ const key = `${materialItemKey(row)}|${materialPatternKey(row)}`;
+ const list = materialMap.get(key) ?? [];
+ list.push(row);
+ materialMap.set(key, list);
+ } else {
+ const key = row.bomId != null ? `bom:${row.bomId}` : `bomcode:${row.bomCode}`;
+ const list = headerMap.get(key) ?? [];
+ list.push(row);
+ headerMap.set(key, list);
+ }
+ }
+
+ const headers: MasterDataIssueGroup[] = Array.from(headerMap.entries()).map(
+ ([, rows]) => {
+ const first = rows[0];
+ const item = pickItemFields(rows);
+ return {
+ groupKey: `bom:${first.bomId}`,
+ groupType: "bom_header" as const,
+ ...item,
+ bomId: first.bomId,
+ bomCode: first.bomCode,
+ bomName: first.bomName,
+ relatedBomCodes: [],
+ relatedBomLabels: [],
+ issues: rows,
+ };
+ },
+ );
+
+ const materials: MasterDataIssueGroup[] = Array.from(materialMap.entries()).map(
+ ([key, rows]) => {
+ const item = pickItemFields(rows);
+ return {
+ groupKey: key,
+ groupType: "bom_material" as const,
+ ...item,
+ bomId: null,
+ bomCode: null,
+ bomName: null,
+ relatedBomCodes: uniqueBomCodes(rows),
+ relatedBomLabels: uniqueBomLabels(rows),
+ issues: rows,
+ };
+ },
+ );
+
+ return [...headers, ...materials].sort((a, b) => {
+ if (a.groupType !== b.groupType) {
+ return a.groupType === "bom_header" ? -1 : 1;
+ }
+ if (a.groupType === "bom_material" && b.groupType === "bom_material") {
+ const itemCmp = formatItemLabel(a).localeCompare(formatItemLabel(b), undefined, {
+ sensitivity: "base",
+ });
+ if (itemCmp !== 0) return itemCmp;
+ return a.groupKey.localeCompare(b.groupKey);
+ }
+ const labelA = formatBomLabel(a);
+ const labelB = formatBomLabel(b);
+ return labelA.localeCompare(labelB, undefined, { sensitivity: "base" });
+ });
+}
+
diff --git a/src/components/MasterDataIssues/index.ts b/src/components/MasterDataIssues/index.ts
new file mode 100644
index 0000000..572571a
--- /dev/null
+++ b/src/components/MasterDataIssues/index.ts
@@ -0,0 +1 @@
+export { default as MasterDataIssuesTabs } from "./MasterDataIssuesTabs";
diff --git a/src/components/NavigationContent/MasterDataIssuesNavBadge.tsx b/src/components/NavigationContent/MasterDataIssuesNavBadge.tsx
new file mode 100644
index 0000000..03646f1
--- /dev/null
+++ b/src/components/NavigationContent/MasterDataIssuesNavBadge.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import Box from "@mui/material/Box";
+import Tooltip from "@mui/material/Tooltip";
+import React from "react";
+import { useMasterDataIssueNavCount } from "@/hooks/useMasterDataIssueNavCount";
+
+type Props = {
+ enabled: boolean;
+};
+
+/** Sidebar red count pill (fixed size, no pulse). */
+const MasterDataIssuesNavBadge: React.FC = ({ enabled }) => {
+ const { bomGroupCount, itemGroupCount, totalGroupCount } =
+ useMasterDataIssueNavCount(enabled);
+
+ if (!enabled || totalGroupCount <= 0) return null;
+
+ const label = `BOM ${bomGroupCount} 筆 · 貨品 ${itemGroupCount} 筆(共 ${totalGroupCount} 筆)`;
+ const display = totalGroupCount > 99 ? "99+" : String(totalGroupCount);
+
+ return (
+
+
+ {display}
+
+
+ );
+};
+
+export default MasterDataIssuesNavBadge;
diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx
index 06b6f65..e42ef5e 100644
--- a/src/components/NavigationContent/NavigationContent.tsx
+++ b/src/components/NavigationContent/NavigationContent.tsx
@@ -49,6 +49,7 @@ import { AUTH } from "../../authorities";
import { isMonitoringEnabled } from "@/config/monitoring";
import PurchaseStockInNavAlerts from "./PurchaseStockInNavAlerts";
import JobOrderFgStockInNavAlerts from "./JobOrderFgStockInNavAlerts";
+import MasterDataIssuesNavBadge from "./MasterDataIssuesNavBadge";
interface NavigationItem {
icon: React.ReactNode;
@@ -363,6 +364,12 @@ const NavigationContent: React.FC = () => {
label: "BOM Weighting Score List",
path: "/settings/bomWeighting",
},
+ {
+ icon: ,
+ label: "masterDataIssue_nav",
+ path: "/settings/masterDataIssues",
+ requiredAbility: [AUTH.ADMIN],
+ },
{
icon: ,
label: "QR Code Handle",
@@ -394,6 +401,7 @@ const NavigationContent: React.FC = () => {
abilitySet.has(AUTH.TESTING) || abilitySet.has(AUTH.ADMIN) || abilitySet.has(AUTH.STOCK);
/** 工單 QC/上架紅點:仍僅 TESTING */
const canSeeJoFgAlerts = abilitySet.has(AUTH.TESTING);
+ const canSeeMasterDataIssueBadge = abilitySet.has(AUTH.ADMIN);
const [openItems, setOpenItems] = React.useState([]);
/** Keep parent sections expanded on deep links (e.g. /po/edit from nav red spot) so alerts stay visible. */
@@ -556,6 +564,44 @@ const NavigationContent: React.FC = () => {
+ ) : child.path === "/settings/masterDataIssues" ? (
+
+
+ {child.icon}
+
+
+
+
) : child.path === "/productionProcess" ? (
{searchTerm.trim()
- ? t("No purchase orders match your search", { defaultValue: "沒有符合搜尋的採購單" })
+ ? t("No purchase orders match your search", { defaultValue: "沒有符合搜索的採購單" })
: t("No purchase orders to show", { defaultValue: "沒有可顯示的採購單" })}
)}
diff --git a/src/components/StockIssue/BadItemHandleForm.tsx b/src/components/StockIssue/BadItemHandleForm.tsx
new file mode 100644
index 0000000..4346528
--- /dev/null
+++ b/src/components/StockIssue/BadItemHandleForm.tsx
@@ -0,0 +1,515 @@
+"use client";
+
+import { InventoryLotLineResult } from "@/app/api/inventory";
+import {
+ fetchInventoryListFresh,
+ fetchStockIssueBadItemLotLinesFresh,
+ updateInventoryLotLineStatus,
+} from "@/app/api/inventory/actions";
+import { handleBadItem } from "@/app/api/stockIssue/actions";
+import { arrayToDateString } from "@/app/utils/formatUtil";
+import { msg, msgError } from "@/components/Swal/CustomAlerts";
+import { SessionWithTokens } from "@/config/authConfig";
+import {
+ Box,
+ Button,
+ Card,
+ CardContent,
+ FormControl,
+ InputLabel,
+ MenuItem,
+ Paper,
+ Select,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TablePagination,
+ TableRow,
+ TextField,
+ Typography,
+} from "@mui/material";
+import { uniq } from "lodash";
+import { useSession } from "next-auth/react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import StockIssueSearchPanel, {
+ StockIssueSearchField,
+} from "./StockIssueSearchPanel";
+
+const LOT_STATUSES = ["available", "unavailable"] as const;
+
+type SearchQuery = {
+ itemCode: string;
+ itemName: string;
+ itemType: string;
+ lotNo: string;
+};
+type SearchParamNames = keyof SearchQuery;
+
+type RowDraft = {
+ status: string;
+ badQty: string;
+ remarks: string;
+};
+
+const normalizeStatus = (status: string | undefined | null): string => {
+ const s = status?.toLowerCase() ?? "";
+ return LOT_STATUSES.includes(s as (typeof LOT_STATUSES)[number])
+ ? s
+ : "unavailable";
+};
+
+const BadItemHandleForm: React.FC = () => {
+ const { t } = useTranslation(["inventory", "common"]);
+ const { data: session } = useSession() as { data: SessionWithTokens | null };
+ const currentUserId = session?.id ? parseInt(session.id) : undefined;
+
+ const [rows, setRows] = useState([]);
+ const [totalCount, setTotalCount] = useState(0);
+ const [paging, setPaging] = useState({ pageNum: 1, pageSize: 20 });
+ const [filterArgs, setFilterArgs] = useState({
+ itemCode: "",
+ itemName: "",
+ itemType: "All",
+ lotNo: "",
+ });
+ const [drafts, setDrafts] = useState>({});
+ const [itemTypeOptions, setItemTypeOptions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [submittingIds, setSubmittingIds] = useState>(new Set());
+ const rowSubmitInFlightRef = useRef>(new Set());
+ const hasSearchedRef = useRef(false);
+
+ const formatItemTypeLabel = useCallback(
+ (code: string) => {
+ const key = code?.trim();
+ if (!key) return code;
+ const translated = t(key, { ns: "common", defaultValue: key });
+ return translated !== key ? translated : t(key, { defaultValue: key });
+ },
+ [t],
+ );
+
+ const searchFields: StockIssueSearchField[] = useMemo(
+ () => [
+ { name: "itemCode", label: t("Code"), type: "text" },
+ { name: "itemName", label: t("Name"), type: "text" },
+ { name: "lotNo", label: t("Lot No."), type: "text" },
+ {
+ name: "itemType",
+ label: t("Type"),
+ type: "select",
+ options: itemTypeOptions,
+ getOptionLabel: formatItemTypeLabel,
+ },
+ ],
+ [t, itemTypeOptions, formatItemTypeLabel],
+ );
+
+ useEffect(() => {
+ fetchInventoryListFresh()
+ .then((list) => {
+ if (!list?.length) return;
+ setItemTypeOptions(
+ uniq(
+ list
+ .map((row) => row.itemType?.trim())
+ .filter((type): type is string => Boolean(type)),
+ ).sort(),
+ );
+ })
+ .catch((e) => console.error("Failed to load item types:", e));
+ }, []);
+
+ const buildSearchParams = useCallback(
+ (query: SearchQuery, page: { pageNum: number; pageSize: number }) => ({
+ itemCode: query.itemCode?.trim() || undefined,
+ itemName: query.itemName?.trim() || undefined,
+ lotNo: query.lotNo?.trim() || undefined,
+ itemType:
+ !query.itemType || query.itemType === "All"
+ ? undefined
+ : query.itemType,
+ pageNum: page.pageNum - 1,
+ pageSize: page.pageSize,
+ }),
+ [],
+ );
+
+ const applyDraftsForRecords = useCallback(
+ (records: InventoryLotLineResult[], replaceAll: boolean) => {
+ setDrafts((prev) => {
+ const next: Record = replaceAll ? {} : { ...prev };
+ records.forEach((line) => {
+ if (next[line.id]) return;
+ const max = Math.max(0, Math.floor(line.availableQty ?? 0));
+ next[line.id] = {
+ status: normalizeStatus(line.status),
+ badQty: max > 0 ? String(max) : "",
+ remarks: "",
+ };
+ });
+ return next;
+ });
+ },
+ [],
+ );
+
+ const fetchRows = useCallback(
+ async (
+ query: SearchQuery,
+ page: { pageNum: number; pageSize: number },
+ options?: { replaceDrafts?: boolean },
+ ) => {
+ setLoading(true);
+ try {
+ const res = await fetchStockIssueBadItemLotLinesFresh(
+ buildSearchParams(query, page),
+ );
+ const records = res?.records ?? [];
+ setRows(records);
+ setTotalCount(res?.total ?? 0);
+ applyDraftsForRecords(records, options?.replaceDrafts ?? false);
+ } catch (e) {
+ console.error(e);
+ setRows([]);
+ setTotalCount(0);
+ setDrafts({});
+ } finally {
+ setLoading(false);
+ }
+ },
+ [buildSearchParams, applyDraftsForRecords],
+ );
+
+ const handleSearch = useCallback(
+ async (query: Record) => {
+ const q = query as SearchQuery;
+ const hasCriterion =
+ Boolean(q.itemCode?.trim()) ||
+ Boolean(q.itemName?.trim()) ||
+ Boolean(q.lotNo?.trim());
+ if (!hasCriterion) {
+ msgError(t("Please set at least one search criterion"));
+ return;
+ }
+
+ hasSearchedRef.current = true;
+ setFilterArgs(q);
+ const page = { pageNum: 1, pageSize: paging.pageSize };
+ setPaging(page);
+ await fetchRows(q, page, { replaceDrafts: true });
+ },
+ [fetchRows, paging.pageSize, t],
+ );
+
+ useEffect(() => {
+ if (!hasSearchedRef.current) return;
+ fetchRows(filterArgs, paging, { replaceDrafts: false });
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when paging changes only
+ }, [paging.pageNum, paging.pageSize]);
+
+ const formatStatusLabel = useCallback(
+ (status: string) => {
+ const key = status?.toLowerCase();
+ if (key === "available") return t("available");
+ if (key === "unavailable") return t("unavailable");
+ return status;
+ },
+ [t],
+ );
+
+ const handleRowSubmit = useCallback(
+ async (line: InventoryLotLineResult) => {
+ if (!currentUserId) return;
+ if (rowSubmitInFlightRef.current.has(line.id)) return;
+
+ const draft = drafts[line.id] ?? {
+ status: normalizeStatus(line.status),
+ badQty: "",
+ remarks: "",
+ };
+ const savedStatus = normalizeStatus(line.status);
+ const draftStatus = normalizeStatus(draft.status);
+ const statusChanged = draftStatus !== savedStatus;
+ const maxQty = Math.max(0, Math.floor(line.availableQty ?? 0));
+ const parsed = parseInt((draft.badQty ?? "").replace(/\D/g, ""), 10);
+ const hasBadQty = !Number.isNaN(parsed) && parsed >= 1;
+
+ if (!statusChanged && !hasBadQty) {
+ msgError(t("No changes to submit"));
+ return;
+ }
+ if (hasBadQty && parsed > maxQty) {
+ msgError(t("Quantity exceeds available quantity"));
+ return;
+ }
+
+ rowSubmitInFlightRef.current.add(line.id);
+ setSubmittingIds((prev) => new Set(prev).add(line.id));
+ try {
+ if (statusChanged) {
+ const statusRes = await updateInventoryLotLineStatus({
+ inventoryLotLineId: line.id,
+ status: draftStatus,
+ });
+ if (statusRes?.code && statusRes.code !== "SUCCESS") {
+ throw new Error(statusRes.message ?? t("Failed to submit"));
+ }
+ }
+
+ if (hasBadQty) {
+ const badRes = await handleBadItem({
+ inventoryLotLineId: line.id,
+ qty: parsed,
+ remarks: draft.remarks?.trim() || undefined,
+ handler: currentUserId,
+ });
+ if (badRes?.code && badRes.code !== "SUCCESS") {
+ throw new Error(badRes.message || t("Failed to submit"));
+ }
+ }
+
+ msg(t("Saved successfully"));
+
+ setRows((prev) => {
+ let next = prev.map((row) => {
+ if (row.id !== line.id) return row;
+ let updated = { ...row, status: draftStatus };
+ if (hasBadQty) {
+ updated = {
+ ...updated,
+ availableQty: Math.max(0, (updated.availableQty ?? 0) - parsed),
+ };
+ }
+ return updated;
+ });
+ if (hasBadQty) {
+ next = next.filter((row) => Math.floor(row.availableQty ?? 0) > 0);
+ }
+ return next;
+ });
+
+ setDrafts((prev) => {
+ const next = { ...prev };
+ if (hasBadQty && Math.max(0, maxQty - parsed) <= 0) {
+ delete next[line.id];
+ } else if (next[line.id]) {
+ next[line.id] = {
+ ...next[line.id],
+ status: draftStatus,
+ remarks: hasBadQty ? "" : next[line.id].remarks,
+ badQty: hasBadQty
+ ? String(Math.max(0, maxQty - parsed))
+ : next[line.id].badQty,
+ };
+ }
+ return next;
+ });
+ } catch (e: unknown) {
+ msgError(e instanceof Error ? e.message : t("Failed to submit"));
+ } finally {
+ rowSubmitInFlightRef.current.delete(line.id);
+ setSubmittingIds((prev) => {
+ const next = new Set(prev);
+ next.delete(line.id);
+ return next;
+ });
+ }
+ },
+ [currentUserId, drafts, t],
+ );
+
+ const updateDraft = useCallback((lineId: number, patch: Partial) => {
+ setDrafts((prev) => {
+ const current = prev[lineId] ?? {
+ status: "available",
+ badQty: "",
+ remarks: "",
+ };
+ return {
+ ...prev,
+ [lineId]: { ...current, ...patch },
+ };
+ });
+ }, []);
+
+ return (
+
+
+
+
+
+
+ {t("Bad Item Handle")}
+
+
+
+
+
+ {t("Code")}
+ {t("Name")}
+ {t("Lot No.")}
+ {t("Available Qty")}
+ {t("Stock UoM")}
+ {t("Expiry Date")}
+ {t("Warehouse")}
+ {t("Status")}
+ {t("Defective Qty")}
+ {t("Remarks")}
+
+ {t("Action")}
+
+
+
+
+ {!hasSearchedRef.current ? (
+
+
+ {t("Search to load lot lines")}
+
+
+ ) : loading ? (
+
+
+ {t("Loading")}
+
+
+ ) : rows.length === 0 ? (
+
+
+ {t("No record found")}
+
+
+ ) : (
+ rows.map((line) => {
+ const maxQty = Math.max(
+ 0,
+ Math.floor(line.availableQty ?? 0),
+ );
+ const draft = drafts[line.id] ?? {
+ status: normalizeStatus(line.status),
+ badQty: "",
+ remarks: "",
+ };
+ const isSubmitting = submittingIds.has(line.id);
+ const canSubmit =
+ Boolean(currentUserId) && !isSubmitting;
+
+ return (
+
+ {line.item?.code ?? "—"}
+ {line.item?.name ?? "—"}
+ {line.lotNo ?? "—"}
+ {maxQty}
+ {line.uom ?? "—"}
+
+ {arrayToDateString(line.expiryDate)}
+
+ {line.warehouse?.code ?? "—"}
+
+
+
+ {t("Status")}
+
+
+
+
+
+ {
+ const raw = e.target.value.replace(/\D/g, "");
+ if (raw === "") {
+ updateDraft(line.id, { badQty: "" });
+ return;
+ }
+ const n = parseInt(raw, 10);
+ if (!Number.isNaN(n)) {
+ updateDraft(line.id, {
+ badQty: String(
+ Math.min(Math.max(0, n), maxQty),
+ ),
+ });
+ }
+ }}
+ />
+
+
+
+ updateDraft(line.id, { remarks: e.target.value })
+ }
+ />
+
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ setPaging((p) => ({ ...p, pageNum: page + 1 }))
+ }
+ rowsPerPage={paging.pageSize}
+ onRowsPerPageChange={(e) =>
+ setPaging({ pageNum: 1, pageSize: parseInt(e.target.value, 10) })
+ }
+ rowsPerPageOptions={[10, 20, 50, 100]}
+ labelRowsPerPage={t("Rows per page")}
+ />
+
+
+
+ );
+};
+
+export default BadItemHandleForm;
diff --git a/src/components/StockIssue/BadItemHandleModal.tsx b/src/components/StockIssue/BadItemHandleModal.tsx
new file mode 100644
index 0000000..0744e36
--- /dev/null
+++ b/src/components/StockIssue/BadItemHandleModal.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory";
+import { handleBadItem } from "@/app/api/stockIssue/actions";
+import { msg, msgError } from "@/components/Swal/CustomAlerts";
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ TextField,
+ Typography,
+} from "@mui/material";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+interface Props {
+ open: boolean;
+ onClose: () => void;
+ lotLine: InventoryLotLineResult | null;
+ inventory: InventoryResult | null;
+ currentUserId?: number;
+ onSuccess: (payload: {
+ inventoryLotLineId: number;
+ qty: number;
+ }) => void | Promise;
+}
+
+const BadItemHandleModal: React.FC = ({
+ open,
+ onClose,
+ lotLine,
+ inventory,
+ currentUserId,
+ onSuccess,
+}) => {
+ const { t } = useTranslation("inventory");
+ const inFlightRef = useRef(false);
+ const [qty, setQty] = useState("");
+ const [remarks, setRemarks] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ const maxQty = lotLine?.availableQty ?? 0;
+
+ useEffect(() => {
+ if (open && lotLine) {
+ setQty(String(Math.max(0, Math.floor(maxQty))));
+ setRemarks("");
+ }
+ }, [open, lotLine, maxQty]);
+
+ const handleSubmit = useCallback(async () => {
+ if (!lotLine || !currentUserId) return;
+ if (inFlightRef.current) return;
+
+ const parsed = parseInt(qty.replace(/\D/g, ""), 10);
+ if (Number.isNaN(parsed) || parsed < 1) {
+ msgError(t("Please enter a valid quantity"));
+ return;
+ }
+ if (parsed > maxQty) {
+ msgError(t("Quantity exceeds available quantity"));
+ return;
+ }
+
+ inFlightRef.current = true;
+ setSubmitting(true);
+ try {
+ const res = await handleBadItem({
+ inventoryLotLineId: lotLine.id,
+ qty: parsed,
+ remarks: remarks.trim() || undefined,
+ handler: currentUserId,
+ });
+ if (res?.code && res.code !== "SUCCESS") {
+ throw new Error(res.message || t("Failed to submit"));
+ }
+ msg(t("Saved successfully"));
+ onClose();
+ await onSuccess({
+ inventoryLotLineId: lotLine.id,
+ qty: parsed,
+ });
+ } catch (e: unknown) {
+ msgError(e instanceof Error ? e.message : t("Failed to submit"));
+ } finally {
+ setSubmitting(false);
+ inFlightRef.current = false;
+ }
+ }, [lotLine, currentUserId, qty, remarks, maxQty, t, onClose, onSuccess]);
+
+ if (!lotLine || !inventory) return null;
+
+ return (
+
+ );
+};
+
+export default BadItemHandleModal;
diff --git a/src/components/StockIssue/BadItemHandleTab.tsx b/src/components/StockIssue/BadItemHandleTab.tsx
new file mode 100644
index 0000000..bb125ae
--- /dev/null
+++ b/src/components/StockIssue/BadItemHandleTab.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+import BadItemHandleForm from "./BadItemHandleForm";
+
+const BadItemHandleTab: React.FC = () => {
+ return ;
+};
+
+export default BadItemHandleTab;
diff --git a/src/components/StockIssue/ExpiryHandleTab.tsx b/src/components/StockIssue/ExpiryHandleTab.tsx
new file mode 100644
index 0000000..f4e7670
--- /dev/null
+++ b/src/components/StockIssue/ExpiryHandleTab.tsx
@@ -0,0 +1,229 @@
+"use client";
+
+import dayjs from "dayjs";
+import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
+import StockIssueSearchPanel, {
+ StockIssueSearchField,
+} from "./StockIssueSearchPanel";
+import { useCallback, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import SearchResults, { Column } from "@/components/SearchResults/index";
+import { SessionWithTokens } from "@/config/authConfig";
+import {
+ batchSubmitExpiryItem,
+ ExpiryItemResult,
+ fetchExpiryItemList,
+ submitExpiryItem,
+} from "@/app/api/stockIssue/actions";
+import { Box, Button } from "@mui/material";
+import { useSession } from "next-auth/react";
+
+type SearchQuery = {
+ itemCode: string;
+ itemName: string;
+ expiryDate: string;
+};
+type SearchParamNames = keyof SearchQuery;
+
+const ExpiryHandleTab: React.FC = () => {
+ const BATCH_CHUNK_SIZE = 20;
+ const { t } = useTranslation("inventory");
+ const { data: session } = useSession() as { data: SessionWithTokens | null };
+ const currentUserId = session?.id ? parseInt(session.id) : undefined;
+
+ const [expiryItems, setExpiryItems] = useState([]);
+ const [submittingIds, setSubmittingIds] = useState>(new Set());
+ const [batchSubmitting, setBatchSubmitting] = useState(false);
+ const [batchProgress, setBatchProgress] = useState<{
+ done: number;
+ total: number;
+ } | null>(null);
+ const expirySubmitInFlightRef = useRef>(new Set());
+ const batchSubmitInFlightRef = useRef(false);
+ const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 });
+
+ const searchFields: StockIssueSearchField[] = useMemo(
+ () => [
+ { name: "itemCode", label: t("Item Code"), type: "text" },
+ { name: "itemName", label: t("Item"), type: "text" },
+ { name: "expiryDate", label: t("Expiry Date"), type: "date" },
+ ],
+ [t],
+ );
+
+ const handleSubmitSingle = useCallback(
+ async (id: number) => {
+ if (!currentUserId) {
+ alert(t("User ID is required"));
+ return;
+ }
+ const item = expiryItems.find((i) => i.id === id);
+ if (!item) {
+ alert(t("Item not found"));
+ return;
+ }
+ if (expirySubmitInFlightRef.current.has(id)) return;
+
+ try {
+ expirySubmitInFlightRef.current.add(id);
+ setSubmittingIds((prev) => new Set(prev).add(id));
+ await submitExpiryItem(item.id, currentUserId);
+ setExpiryItems((prev) => prev.filter((i) => i.id !== id));
+ } catch (e) {
+ console.error("submitExpiryItem failed:", e);
+ const errMsg = e instanceof Error ? e.message : t("Unknown error");
+ alert(`${t("Failed to submit expiry item")}: ${errMsg}`);
+ } finally {
+ expirySubmitInFlightRef.current.delete(id);
+ setSubmittingIds((prev) => {
+ const next = new Set(prev);
+ next.delete(id);
+ return next;
+ });
+ }
+ },
+ [currentUserId, t, expiryItems],
+ );
+
+ const handleSubmitAll = useCallback(async () => {
+ if (!currentUserId) return;
+ if (batchSubmitInFlightRef.current) return;
+ const allIds = expiryItems.map((item) => item.id);
+ if (allIds.length === 0) return;
+
+ batchSubmitInFlightRef.current = true;
+ setBatchSubmitting(true);
+ setBatchProgress({ done: 0, total: allIds.length });
+ try {
+ for (let i = 0; i < allIds.length; i += BATCH_CHUNK_SIZE) {
+ const chunkIds = allIds.slice(i, i + BATCH_CHUNK_SIZE);
+ await batchSubmitExpiryItem(chunkIds, currentUserId);
+ setExpiryItems((prev) => prev.filter((item) => !chunkIds.includes(item.id)));
+ setBatchProgress({
+ done: Math.min(i + chunkIds.length, allIds.length),
+ total: allIds.length,
+ });
+ }
+ } catch (error) {
+ console.error("Failed to submit expiry items:", error);
+ alert(
+ `${t("Failed to submit")}: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ } finally {
+ setBatchSubmitting(false);
+ setBatchProgress(null);
+ batchSubmitInFlightRef.current = false;
+ }
+ }, [currentUserId, expiryItems, t]);
+
+ const expiryColumns = useMemo[]>(
+ () => [
+ { name: "itemCode", label: t("Item Code") },
+ { name: "itemDescription", label: t("Item") },
+ { name: "lotNo", label: t("Lot No.") },
+ { name: "storeLocation", label: t("Location") },
+ {
+ 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;
+ },
+ },
+ { name: "remainingQty", label: t("Remaining Qty") },
+ {
+ name: "id",
+ label: t("Action"),
+ renderCell: (item) => (
+
+ ),
+ },
+ ],
+ [t, handleSubmitSingle, submittingIds, currentUserId],
+ );
+
+ const handleSearch = useCallback(
+ async (query: Record) => {
+ setPaging((prev) => ({ ...prev, pageNum: 1 }));
+ try {
+ const result = await fetchExpiryItemList({
+ itemCode: query.itemCode?.trim() || undefined,
+ itemName: query.itemName?.trim() || undefined,
+ expiryDate: query.expiryDate || undefined,
+ });
+ setExpiryItems(result);
+ } catch (error) {
+ console.error("Failed to search expiry items:", error);
+ alert(t("Failed to load expiry items"));
+ }
+ },
+ [t],
+ );
+
+ const pagedItems = useMemo(() => {
+ const start = (paging.pageNum - 1) * paging.pageSize;
+ return expiryItems.slice(start, start + paging.pageSize);
+ }, [expiryItems, paging]);
+
+ return (
+
+
+
+
+
+
+ items={pagedItems}
+ columns={expiryColumns}
+ pagingController={paging}
+ setPagingController={setPaging}
+ totalCount={expiryItems.length}
+ />
+
+ );
+};
+
+export default ExpiryHandleTab;
diff --git a/src/components/StockIssue/SearchPage.tsx b/src/components/StockIssue/SearchPage.tsx
index 884ec49..8567eac 100644
--- a/src/components/StockIssue/SearchPage.tsx
+++ b/src/components/StockIssue/SearchPage.tsx
@@ -1,466 +1,37 @@
"use client";
-import dayjs from "dayjs";
-import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
-import SearchBox, { Criterion } from "../SearchBox";
-import { useCallback, useMemo, useState } from "react";
-import { useTranslation } from "react-i18next";
-import SearchResults, { Column } from "../SearchResults/index";
-import { SessionWithTokens } from "@/config/authConfig";
-import {
- batchSubmitBadItem,
- batchSubmitExpiryItem,
- batchSubmitMissItem,
- ExpiryItemResult,
- fetchExpiryItemList,
- StockIssueLists,
- StockIssueResult,
- submitBadItem,
- submitExpiryItem,
- submitMissItem,
-} from "@/app/api/stockIssue/actions";
-import { Box, Button, Tab, Tabs } from "@mui/material";
-import { useSession } from "next-auth/react";
-import SubmitIssueForm from "./SubmitIssueForm";
-interface Props {
- dataList: StockIssueLists;
-}
+import { Box, Tab, Tabs } from "@mui/material";
+import { useCallback, useState } from "react";
+import { useTranslation } from "react-i18next";
+import BadItemHandleTab from "./BadItemHandleTab";
+import StockIssueRecordTab from "./StockIssueRecordTab";
+import ExpiryHandleTab from "./ExpiryHandleTab";
-type SearchQuery = {
- lotNo: string;
- itemCode: string;
- itemName: string;
- expiryDate: string;
-};
-type SearchParamNames = keyof SearchQuery;
+type TabValue = "badHandle" | "badRecord" | "expiryHandle" | "expiryRecord";
-const SearchPage: React.FC = ({ dataList }) => {
- const BATCH_CHUNK_SIZE = 20;
+const SearchPage: React.FC = () => {
const { t } = useTranslation("inventory");
- const [tab, setTab] = useState<"miss" | "bad" | "expiry">("miss");
- const [search, setSearch] = useState({
- lotNo: "",
- itemCode: "",
- itemName: "",
- expiryDate: "",
- });
- const { data: session } = useSession() as { data: SessionWithTokens | null };
- const currentUserId = session?.id ? parseInt(session.id) : undefined;
- const [formOpen, setFormOpen] = useState(false);
- const [selectedLotId, setSelectedLotId] = useState(null);
- const [selectedItemId, setSelectedItemId] = useState(0);
- const [selectedIssueType, setSelectedIssueType] = useState<"miss" | "bad">("miss");
-
- const [missItems, setMissItems] = useState(
- dataList.missItems,
- );
- const [badItems, setBadItems] = useState(
- dataList.badItems,
- );
- const [expiryItems, setExpiryItems] = useState(
- dataList.expiryItems,
- );
- const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
- const [submittingIds, setSubmittingIds] = useState>(new Set());
- const [batchSubmitting, setBatchSubmitting] = useState(false);
- const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null);
- const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 });
- const searchCriteria: Criterion[] = useMemo(
- () => {
- if (tab === "expiry") {
- return [
- {
- label: t("Item Code"),
- paramName: "itemCode",
- type: "text",
- },
- {
- label: t("Item"),
- paramName: "itemName",
- type: "text",
- },
- {
- label: t("Expiry Date"),
- paramName: "expiryDate",
- type: "date",
- },
- ];
- }
-
- return [
- {
- label: t("Lot No."),
- paramName: "lotNo",
- type: "text",
- },
- ];
- },
- [t, tab],
- );
-
- const filterBySearch = useCallback(
- (items: T[]): T[] => {
- if (!search.lotNo) return items;
- const keyword = search.lotNo.toLowerCase();
- return items.filter(
- (i) => i.lotNo && i.lotNo.toLowerCase().includes(keyword),
- );
- },
- [search.lotNo],
- );
-
- const handleSubmitSingle = useCallback(
- async (id: number) => {
- if (!currentUserId) {
- alert(t("User ID is required"));
- return;
- }
-
- // Find the item to get lotId
- let lotId: number | null = null;
- let itemId = 0;
-
- if (tab === "miss") {
- const item = missItems.find((i) => i.id === id);
- if (item) {
- lotId = item.lotId;
- itemId = item.itemId;
- }
- } else if (tab === "bad") {
- const item = badItems.find((i) => i.id === id);
- if (item) {
- lotId = item.lotId;
- itemId = item.itemId;
- }
- } else if (tab === "expiry") {
- const item = expiryItems.find((i) => i.id === id);
- if (!item) {
- alert(t("Item not found"));
- return;
- }
-
- try {
- // 如果想要 loading 效果,可以这里把 id 加进 submittingIds
- await submitExpiryItem(item.id, currentUserId);
- // 成功后,从列表移除这一行,或直接 reload
- // setExpiryItems(prev => prev.filter(i => i.id !== id));
- window.location.reload();
- } catch (e) {
- console.error("submitExpiryItem failed:", e);
- const errMsg = e instanceof Error ? e.message : t("Unknown error");
- alert(`${t("Failed to submit expiry item")}: ${errMsg}`);
- }
- return; // 记得 return,避免再走到下面的 lotId/itemId 分支
- }
-
- if (lotId && itemId) {
- setSelectedLotId(lotId);
- setSelectedItemId(itemId);
- setSelectedIssueType(tab === "miss" ? "miss" : "bad");
- setFormOpen(true);
- } else {
- alert(t("Item not found"));
- }
- },
- [tab, currentUserId, t, missItems, badItems, expiryItems]
- );
-
- const handleFormSuccess = useCallback(() => {
- // Refresh the lists
- if (tab === "miss") {
- // Reload miss items - you may need to add a refresh function
- window.location.reload(); // Or use a proper refresh mechanism
- } else if (tab === "bad") {
- // Reload bad items
- window.location.reload(); // Or use a proper refresh mechanism
- }
- }, [tab]);
-
- const handleSubmitSelected = useCallback(async () => {
- if (!currentUserId) return;
-
- // Get all IDs from the current tab's filtered items
- let allIds: number[] = [];
- if (tab === "miss") {
- const items = filterBySearch(missItems);
- allIds = items.map((item) => item.id);
- } else if (tab === "bad") {
- const items = filterBySearch(badItems);
- allIds = items.map((item) => item.id);
- } else {
- const items = filterBySearch(expiryItems);
- allIds = items.map((item) => item.id);
- }
-
- if (allIds.length === 0) return;
-
- setBatchSubmitting(true);
- setBatchProgress({ done: 0, total: allIds.length });
- try {
- for (let i = 0; i < allIds.length; i += BATCH_CHUNK_SIZE) {
- const chunkIds = allIds.slice(i, i + BATCH_CHUNK_SIZE);
+ const [tab, setTab] = useState("badHandle");
- if (tab === "miss") {
- await batchSubmitMissItem(chunkIds, currentUserId);
- setMissItems((prev) => prev.filter((item) => !chunkIds.includes(item.id)));
- } else if (tab === "bad") {
- await batchSubmitBadItem(chunkIds, currentUserId);
- setBadItems((prev) => prev.filter((item) => !chunkIds.includes(item.id)));
- } else {
- await batchSubmitExpiryItem(chunkIds, currentUserId);
- setExpiryItems((prev) => prev.filter((item) => !chunkIds.includes(item.id)));
- }
-
- setBatchProgress({
- done: Math.min(i + chunkIds.length, allIds.length),
- total: allIds.length,
- });
- }
-
- setSelectedIds([]);
- } catch (error) {
- console.error("Failed to submit selected items:", error);
- const partialDone = batchProgress?.done ?? 0;
- alert(
- `${t("Failed to submit")}: ${error instanceof Error ? error.message : "Unknown error"} (${partialDone}/${allIds.length})`
- );
- } finally {
- setBatchSubmitting(false);
- setBatchProgress(null);
- }
- }, [tab, currentUserId, missItems, badItems, expiryItems, filterBySearch, batchProgress, t]);
-
- const missColumns = useMemo[]>(
- () => [
- { name: "itemCode", label: t("Item Code") },
- { name: "itemDescription", label: t("Item") },
- { name: "lotNo", label: t("Lot No.") },
- { name: "storeLocation", label: t("Location") },
- {
- name: "bookQty",
- label: t("Book Qty"),
- renderCell: (item) => (
- <>{item.bookQty?.toFixed(2) ?? "0"} {item.uomDesc ?? ""}>
- ),
- },
- { name: "issueQty", label: t("Miss Qty") },
- { name: "uomDesc", label: t("UoM"), renderCell: (item) => (
- <>{item.uomDesc ?? ""}>
- ) },
- {
- name: "id",
- label: t("Action"),
- renderCell: (item) => (
-
- ),
- },
- ],
- [t, handleSubmitSingle, submittingIds, currentUserId],
- );
-
- const badColumns = useMemo[]>(
- () => [
- { name: "itemCode", label: t("Item Code") },
- { name: "itemDescription", label: t("Item") },
- { name: "lotNo", label: t("Lot No.") },
- { name: "storeLocation", label: t("Location") },
- { name: "issueQty", label: t("Defective Qty") },
- { name: "uomDesc", label: t("UoM"), renderCell: (item) => (
- <>{item.uomDesc ?? ""}>
- ) },
- {
- name: "id",
- label: t("Action"),
- renderCell: (item) => (
-
- ),
- },
- ],
- [t, handleSubmitSingle, submittingIds, currentUserId],
- );
-
- const expiryColumns = useMemo[]>(
- () => [
- { name: "itemCode", label: t("Item Code") },
- { name: "itemDescription", label: t("Item") },
- { name: "lotNo", label: t("Lot No.") },
- { name: "storeLocation", label: t("Location") },
- {
- 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;
- },
- },
- { name: "remainingQty", label: t("Remaining Qty") },
- {
- name: "id",
- label: t("Action"),
- renderCell: (item) => (
-
- ),
- },
- ],
- [t, handleSubmitSingle, submittingIds, currentUserId],
- );
-
- const handleSearch = useCallback(async (query: Record) => {
- setSearch(query);
- setPaging((prev) => ({ ...prev, pageNum: 1 }));
-
- if (tab !== "expiry") {
- return;
- }
-
- try {
- const result = await fetchExpiryItemList({
- itemCode: query.itemCode?.trim() || undefined,
- itemName: query.itemName?.trim() || undefined,
- expiryDate: query.expiryDate || undefined,
- });
- setExpiryItems(result);
- setSelectedIds([]);
- } catch (error) {
- console.error("Failed to search expiry items:", error);
- alert(t("Failed to load expiry items"));
- }
- }, [tab, t]);
-
- const handleTabChange = useCallback(
- (_: React.SyntheticEvent, value: string) => {
- setTab(value as "miss" | "bad" | "expiry");
- setSelectedIds([]);
- setPaging((prev) => ({ ...prev, pageNum: 1 })); // 新增:切 Tab 时回到第 1 页
- },
- [],
- );
-
- const renderCurrentTab = () => {
- if (tab === "miss") {
- const items = filterBySearch(missItems);
- return (
-
- items={items}
- columns={missColumns}
- pagingController={paging}
- checkboxIds={selectedIds}
- setPagingController={setPaging}
- setCheckboxIds={setSelectedIds}
- />
- );
- }
-
- if (tab === "bad") {
- const items = filterBySearch(badItems);
- return (
-
- items={items}
- columns={badColumns}
- pagingController={paging}
- setPagingController={setPaging}
- checkboxIds={selectedIds}
- setCheckboxIds={setSelectedIds}
- />
- );
- }
-
- const items = filterBySearch(expiryItems);
- return (
-
- items={items}
- columns={expiryColumns}
- pagingController={paging}
- setPagingController={setPaging}
- checkboxIds={selectedIds}
- setCheckboxIds={setSelectedIds}
- />
- );
- };
+ const handleTabChange = useCallback((_: React.SyntheticEvent, value: string) => {
+ setTab(value as TabValue);
+ }, []);
return (
-
-
-
+
+
+
+
-
- criteria={searchCriteria}
- onSearch={handleSearch}
- />
-
-{tab === "expiry" && (
-
-
-
-)}
-
- {renderCurrentTab()}
- setFormOpen(false)}
- lotId={selectedLotId}
- itemId={selectedItemId}
- issueType={selectedIssueType}
- currentUserId={currentUserId || 0}
- onSuccess={handleFormSuccess}
- />
+ {tab === "badHandle" && }
+ {tab === "badRecord" && }
+ {tab === "expiryHandle" && }
+ {tab === "expiryRecord" && }
);
};
-export default SearchPage;
\ No newline at end of file
+export default SearchPage;
diff --git a/src/components/StockIssue/StockIssueInventoryTable.tsx b/src/components/StockIssue/StockIssueInventoryTable.tsx
new file mode 100644
index 0000000..f3bc023
--- /dev/null
+++ b/src/components/StockIssue/StockIssueInventoryTable.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { InventoryResult } from "@/app/api/inventory";
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { Column } from "../SearchResults";
+import SearchResults, {
+ defaultPagingController,
+ defaultSetPagingController,
+} from "../SearchResults/SearchResults";
+
+interface Props {
+ inventories: InventoryResult[];
+ setPagingController: defaultSetPagingController;
+ pagingController: typeof defaultPagingController;
+ totalCount: number;
+ onRowClick: (item: InventoryResult) => void;
+}
+
+const StockIssueInventoryTable: React.FC = ({
+ inventories,
+ pagingController,
+ setPagingController,
+ totalCount,
+ onRowClick,
+}) => {
+ const { t } = useTranslation(["inventory", "common"]);
+
+ const columns = useMemo[]>(
+ () => [
+ { name: "itemCode", label: t("Code") },
+ { name: "itemName", label: t("Name") },
+ {
+ name: "itemType",
+ label: t("Type"),
+ renderCell: (params) => {
+ const code = params.itemType?.trim() ?? "";
+ if (!code) return "—";
+ const fromCommon = t(code, { ns: "common", defaultValue: code });
+ return fromCommon !== code ? fromCommon : t(code, { defaultValue: code });
+ },
+ },
+ {
+ name: "availableQty",
+ label: t("Available Qty"),
+ align: "right",
+ headerAlign: "right",
+ type: "integer",
+ },
+ {
+ name: "uomUdfudesc",
+ label: t("Stock UoM"),
+ align: "left",
+ headerAlign: "left",
+ },
+ ],
+ [t],
+ );
+
+ return (
+
+ items={inventories}
+ columns={columns}
+ pagingController={pagingController}
+ setPagingController={setPagingController}
+ totalCount={totalCount}
+ onRowClick={onRowClick}
+ />
+ );
+};
+
+export default StockIssueInventoryTable;
diff --git a/src/components/StockIssue/StockIssueLotLineTable.tsx b/src/components/StockIssue/StockIssueLotLineTable.tsx
new file mode 100644
index 0000000..7433523
--- /dev/null
+++ b/src/components/StockIssue/StockIssueLotLineTable.tsx
@@ -0,0 +1,230 @@
+"use client";
+
+import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory";
+import { updateInventoryLotLineStatus } from "@/app/api/inventory/actions";
+import { arrayToDateString } from "@/app/utils/formatUtil";
+import { msg, msgError } from "@/components/Swal/CustomAlerts";
+import HighlightOffIcon from "@mui/icons-material/HighlightOff";
+import {
+ Box,
+ FormControl,
+ IconButton,
+ InputLabel,
+ MenuItem,
+ Select,
+ SelectChangeEvent,
+ Typography,
+} from "@mui/material";
+import { useCallback, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Column } from "../SearchResults";
+import SearchResults, {
+ defaultPagingController,
+ defaultSetPagingController,
+} from "../SearchResults/SearchResults";
+import BadItemHandleModal from "./BadItemHandleModal";
+
+const LOT_STATUSES = ["available", "unavailable"] as const;
+
+interface Props {
+ inventoryLotLines: InventoryLotLineResult[] | null;
+ setPagingController: defaultSetPagingController;
+ pagingController: typeof defaultPagingController;
+ totalCount: number;
+ inventory: InventoryResult | null;
+ currentUserId?: number;
+ onBadItemHandleSuccess?: (payload: {
+ inventoryLotLineId: number;
+ qty: number;
+ }) => void | Promise;
+ onLotLinesChanged?: () => void | Promise;
+}
+
+const StockIssueLotLineTable: React.FC = ({
+ inventoryLotLines,
+ pagingController,
+ setPagingController,
+ totalCount,
+ inventory,
+ currentUserId,
+ onBadItemHandleSuccess,
+ onLotLinesChanged,
+}) => {
+ const { t } = useTranslation("inventory");
+ const [modalOpen, setModalOpen] = useState(false);
+ const [selectedLotLine, setSelectedLotLine] =
+ useState(null);
+ const [statusUpdatingIds, setStatusUpdatingIds] = useState>(
+ new Set(),
+ );
+ const statusInFlightRef = useRef>(new Set());
+
+ const displayLotLines = useMemo(
+ () => inventoryLotLines ?? [],
+ [inventoryLotLines],
+ );
+
+ const isBadItemEnabled = useCallback((line: InventoryLotLineResult) => {
+ const qty = line.availableQty ?? 0;
+ return qty > 0;
+ }, []);
+
+ const handleBadItemClick = useCallback((lotLine: InventoryLotLineResult) => {
+ if (!isBadItemEnabled(lotLine)) return;
+ setSelectedLotLine(lotLine);
+ setModalOpen(true);
+ }, [isBadItemEnabled]);
+
+ const handleStatusChange = useCallback(
+ async (line: InventoryLotLineResult, event: SelectChangeEvent) => {
+ const nextStatus = event.target.value;
+ if (!nextStatus || nextStatus === line.status) return;
+ if (statusInFlightRef.current.has(line.id)) return;
+
+ statusInFlightRef.current.add(line.id);
+ setStatusUpdatingIds((prev) => new Set(prev).add(line.id));
+ try {
+ const res = await updateInventoryLotLineStatus({
+ inventoryLotLineId: line.id,
+ status: nextStatus,
+ });
+ if (res?.code && res.code !== "SUCCESS") {
+ throw new Error(res.message ?? t("Failed to submit"));
+ }
+ msg(t("Saved successfully"));
+ await onLotLinesChanged?.();
+ } catch (e: unknown) {
+ msgError(e instanceof Error ? e.message : t("Failed to submit"));
+ } finally {
+ statusInFlightRef.current.delete(line.id);
+ setStatusUpdatingIds((prev) => {
+ const next = new Set(prev);
+ next.delete(line.id);
+ return next;
+ });
+ }
+ },
+ [t, onLotLinesChanged],
+ );
+
+ const formatStatusLabel = useCallback(
+ (status: string) => {
+ const key = status?.toLowerCase();
+ if (key === "available") return t("available");
+ if (key === "unavailable") return t("unavailable");
+ return status;
+ },
+ [t],
+ );
+
+ const columns = useMemo[]>(
+ () => [
+ { name: "lotNo", label: t("Lot No") },
+ {
+ name: "availableQty",
+ label: t("Available Qty"),
+ align: "right",
+ headerAlign: "right",
+ type: "integer",
+ },
+ { name: "uom", label: t("Stock UoM") },
+ {
+ name: "expiryDate",
+ label: t("Expiry Date"),
+ renderCell: (params) => arrayToDateString(params.expiryDate),
+ },
+ {
+ name: "warehouse",
+ label: t("Warehouse"),
+ renderCell: (params) => params.warehouse?.code ?? "",
+ },
+ {
+ name: "status",
+ label: t("Status"),
+ renderCell: (row) => (
+
+ {t("Status")}
+
+
+ ),
+ },
+ {
+ name: "id",
+ label: t("Bad Item Handle"),
+ align: "center",
+ headerAlign: "center",
+ renderCell: (row) => (
+ handleBadItemClick(row)}
+ title={t("Bad Item Handle")}
+ >
+
+
+ ),
+ },
+ ],
+ [
+ t,
+ handleStatusChange,
+ formatStatusLabel,
+ statusUpdatingIds,
+ isBadItemEnabled,
+ handleBadItemClick,
+ currentUserId,
+ ],
+ );
+
+ return (
+ <>
+
+
+ {inventory
+ ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})`
+ : t("No items are selected yet.")}
+
+
+
+ items={displayLotLines}
+ columns={columns}
+ pagingController={pagingController}
+ setPagingController={setPagingController}
+ totalCount={totalCount}
+ />
+ setModalOpen(false)}
+ lotLine={selectedLotLine}
+ inventory={inventory}
+ currentUserId={currentUserId}
+ onSuccess={async (payload) => {
+ await onBadItemHandleSuccess?.(payload);
+ }}
+ />
+ >
+ );
+};
+
+export default StockIssueLotLineTable;
diff --git a/src/components/StockIssue/StockIssueRecordTab.tsx b/src/components/StockIssue/StockIssueRecordTab.tsx
new file mode 100644
index 0000000..3942f65
--- /dev/null
+++ b/src/components/StockIssue/StockIssueRecordTab.tsx
@@ -0,0 +1,193 @@
+"use client";
+
+import {
+ fetchBadItemRecords,
+ fetchExpiryItemRecords,
+ StockIssueHandleRecord,
+} from "@/app/api/stockIssue/actions";
+import StockIssueSearchPanel, {
+ StockIssueSearchField,
+} from "./StockIssueSearchPanel";
+import SearchResults, { Column } from "@/components/SearchResults";
+import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
+import dayjs from "dayjs";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+type RecordKind = "bad" | "expiry";
+
+type SearchQuery = {
+ itemCode: string;
+ itemName: string;
+ lotNo: string;
+ startDate: string;
+ endDate: string;
+};
+type SearchParamNames = keyof SearchQuery;
+
+interface Props {
+ kind: RecordKind;
+}
+
+const StockIssueRecordTab: React.FC = ({ kind }) => {
+ const { t } = useTranslation("inventory");
+ const [items, setItems] = useState([]);
+ const [totalCount, setTotalCount] = useState(0);
+ const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 });
+ const [filterArgs, setFilterArgs] = useState({
+ itemCode: "",
+ itemName: "",
+ lotNo: "",
+ startDate: "",
+ endDate: "",
+ });
+ const hasSearchedRef = useRef(false);
+ const prevPagingRef = useRef(paging);
+
+ const searchFields: StockIssueSearchField[] = 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",
+ },
+ ],
+ [t, kind],
+ );
+
+ const fetchRecords = useCallback(
+ async (query: SearchQuery, page: { pageNum: number; pageSize: number }) => {
+ try {
+ const params = {
+ itemCode: query.itemCode?.trim() || undefined,
+ itemName: query.itemName?.trim() || undefined,
+ lotNo: query.lotNo?.trim() || undefined,
+ startDate: query.startDate || undefined,
+ endDate: query.endDate || undefined,
+ pageNum: page.pageNum - 1,
+ pageSize: page.pageSize,
+ };
+ const res =
+ kind === "bad"
+ ? await fetchBadItemRecords(params)
+ : await fetchExpiryItemRecords(params);
+ setItems(res?.records ?? []);
+ setTotalCount(res?.total ?? 0);
+ } catch (e) {
+ console.error(e);
+ setItems([]);
+ setTotalCount(0);
+ }
+ },
+ [kind],
+ );
+
+ const handleSearch = useCallback(
+ async (query: Record) => {
+ const q = query as SearchQuery;
+ setFilterArgs(q);
+ const page = { pageNum: 1, pageSize: paging.pageSize };
+ setPaging(page);
+ hasSearchedRef.current = true;
+ await fetchRecords(q, page);
+ prevPagingRef.current = page;
+ },
+ [fetchRecords, paging.pageSize],
+ );
+
+ useEffect(() => {
+ if (!hasSearchedRef.current) return;
+ const pagingChanged =
+ prevPagingRef.current.pageNum !== paging.pageNum ||
+ prevPagingRef.current.pageSize !== paging.pageSize;
+ if (pagingChanged) {
+ fetchRecords(filterArgs, paging);
+ prevPagingRef.current = paging;
+ }
+ }, [paging, filterArgs, fetchRecords]);
+
+ const formatDateValue = (raw: string | null | undefined) => {
+ if (raw == null || raw === "") return "—";
+ const trimmed = String(raw).trim();
+ if (trimmed.includes(",")) {
+ const parts = trimmed.split(",").map((s) => parseInt(s.trim(), 10));
+ const [y, m, d_] = parts;
+ if (
+ parts.length >= 3 &&
+ !Number.isNaN(y) &&
+ !Number.isNaN(m) &&
+ !Number.isNaN(d_)
+ ) {
+ const d = dayjs(new Date(y, m - 1, d_));
+ return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : trimmed;
+ }
+ }
+ const d = dayjs(trimmed);
+ return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : trimmed;
+ };
+
+ const columns = useMemo[]>(() => {
+ const base: Column[] = [
+ {
+ name: "handledAt",
+ label: t("Handled Date"),
+ renderCell: (row) => formatDateValue(row.handledAt),
+ },
+ { name: "itemCode", label: t("Item Code") },
+ { name: "itemName", label: t("Item") },
+ { name: "lotNo", label: t("Lot No.") },
+ { name: "storeLocation", label: t("Location") },
+ ];
+ if (kind === "expiry") {
+ base.push({
+ name: "expiryDate",
+ label: t("Expiry Date"),
+ renderCell: (row) => formatDateValue(row.expiryDate),
+ });
+ }
+ base.push(
+ {
+ name: "qty",
+ label: kind === "expiry" ? t("Expiry Item Qty") : t("Bad Item Qty"),
+ renderCell: (row) => (
+ <>
+ {Number(row.qty).toFixed(2)} {row.uomDesc ?? ""}
+ >
+ ),
+ },
+ {
+ name: "handlerName",
+ label: t("Handler"),
+ renderCell: (row) =>
+ row.handlerName ?? (row.handlerId != null ? String(row.handlerId) : "—"),
+ },
+ { name: "remarks", label: t("Remarks") },
+ );
+ return base;
+ }, [t, kind]);
+
+ return (
+ <>
+
+
+ items={items}
+ columns={columns}
+ pagingController={paging}
+ setPagingController={setPaging}
+ totalCount={totalCount}
+ isAutoPaging={false}
+ />
+ >
+ );
+};
+
+export default StockIssueRecordTab;
diff --git a/src/components/StockIssue/StockIssueSearchPanel.tsx b/src/components/StockIssue/StockIssueSearchPanel.tsx
new file mode 100644
index 0000000..a7355f8
--- /dev/null
+++ b/src/components/StockIssue/StockIssueSearchPanel.tsx
@@ -0,0 +1,197 @@
+"use client";
+
+import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
+import RestartAlt from "@mui/icons-material/RestartAlt";
+import Search from "@mui/icons-material/Search";
+import {
+ Box,
+ Button,
+ Card,
+ CardActions,
+ CardContent,
+ FormControl,
+ Grid,
+ InputLabel,
+ MenuItem,
+ Select,
+ SelectChangeEvent,
+ TextField,
+ Typography,
+} from "@mui/material";
+import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
+import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
+import dayjs from "dayjs";
+import "dayjs/locale/zh-hk";
+import { useCallback, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+export type StockIssueSearchFieldType = "text" | "select" | "date";
+
+export interface StockIssueSearchField {
+ name: K;
+ label: string;
+ type: StockIssueSearchFieldType;
+ options?: string[];
+ /** Optional label for select option values (defaults to i18n `t(option)`). */
+ getOptionLabel?: (value: string) => string;
+ /** When this date is picked, copy the same value to `mirrorTo`. */
+ mirrorTo?: K;
+}
+
+interface Props {
+ fields: StockIssueSearchField[];
+ onSearch: (values: Record) => void;
+ onReset?: () => void;
+ extraActions?: React.ReactNode;
+ disabled?: boolean;
+}
+
+function StockIssueSearchPanel({
+ fields,
+ onSearch,
+ onReset,
+ extraActions,
+ disabled = false,
+}: Props) {
+ const { t } = useTranslation("inventory");
+ const { t: tCommon } = useTranslation("common"); // All
+
+ const emptyValues = useMemo(() => {
+ return fields.reduce(
+ (acc, field) => {
+ acc[field.name] =
+ field.type === "select" ? "All" : "";
+ return acc;
+ },
+ {} as Record,
+ );
+ }, [fields]);
+
+ const [values, setValues] = useState>(emptyValues);
+
+ const handleTextChange = useCallback(
+ (name: K) => (e: React.ChangeEvent) => {
+ setValues((prev) => ({ ...prev, [name]: e.target.value }));
+ },
+ [],
+ );
+
+ const handleSelectChange = useCallback(
+ (name: K) => (e: SelectChangeEvent) => {
+ setValues((prev) => ({ ...prev, [name]: e.target.value }));
+ },
+ [],
+ );
+
+ const handleDateChange = useCallback(
+ (name: K, mirrorTo?: K) => (date: dayjs.Dayjs | null) => {
+ const formatted =
+ date && dayjs(date).isValid() ? dayjs(date).format("YYYY-MM-DD") : "";
+ setValues((prev) => ({
+ ...prev,
+ [name]: formatted,
+ ...(mirrorTo ? { [mirrorTo]: formatted } : {}),
+ }));
+ },
+ [],
+ );
+
+ const handleReset = () => {
+ setValues(emptyValues);
+ onReset?.();
+ };
+
+ const handleSearchClick = () => {
+ onSearch(values);
+ };
+
+ return (
+
+
+
+ {t("Search Criteria")}
+
+
+ {fields.map((field) => (
+
+ {field.type === "text" && (
+
+ )}
+ {field.type === "select" && (
+
+ {field.label}
+
+
+ )}
+ {field.type === "date" && (
+
+
+
+ )}
+
+ ))}
+
+
+ }
+ onClick={handleReset}
+ disabled={disabled}
+ sx={{ borderColor: "#e2e8f0", color: "#334155" }}
+ >
+ {t("Reset")}
+
+ }
+ onClick={handleSearchClick}
+ disabled={disabled}
+ >
+ {t("Search")}
+
+ {extraActions}
+
+
+
+ );
+}
+
+export default StockIssueSearchPanel;
diff --git a/src/components/StockIssue/SubmitIssueForm.tsx b/src/components/StockIssue/SubmitIssueForm.tsx
deleted file mode 100644
index 1a891fe..0000000
--- a/src/components/StockIssue/SubmitIssueForm.tsx
+++ /dev/null
@@ -1,219 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import {
- Dialog,
- DialogTitle,
- DialogContent,
- DialogActions,
- Button,
- TextField,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- Paper,
- Box,
- Typography,
-} from "@mui/material";
-import {
- getLotIssueDetails,
- submitIssueWithQty,
- LotIssueDetailResponse,
-} from "@/app/api/stockIssue/actions";
-import { useTranslation } from "react-i18next";
-
-interface Props {
- open: boolean;
- onClose: () => void;
- lotId: number | null;
- itemId: number;
- issueType: "miss" | "bad";
- currentUserId: number;
- onSuccess: () => void;
-}
-
-const SubmitIssueForm: React.FC = ({
- open,
- onClose,
- lotId,
- itemId,
- issueType,
- currentUserId,
- onSuccess,
-}) => {
- const { t } = useTranslation("inventory");
- const [loading, setLoading] = useState(false);
- const [submitting, setSubmitting] = useState(false);
- const [details, setDetails] = useState(null);
- const [submitQty, setSubmitQty] = useState("");
- const bookQty = details?.bookQty ?? 0;
- const submitQtyNum = parseFloat(submitQty);
- const submitQtyValid = !Number.isNaN(submitQtyNum) && submitQtyNum >= 0;
- const remainAvailable = submitQtyValid ? Math.max(0, bookQty - submitQtyNum) : bookQty;
- useEffect(() => {
- if (open && lotId) {
- loadDetails();
- }
- }, [open, lotId, itemId, issueType]);
-
- const loadDetails = async () => {
- if (!lotId) return;
- setLoading(true);
- try {
- const data = await getLotIssueDetails(lotId, itemId, issueType);
- setDetails(data);
- // Set default qty to sum of issueQty (for bad) or missQty (for miss)
- const defaultQty = issueType === "bad"
- ? data.issues.reduce((sum, issue) => sum + (issue.issueQty || 0), 0)
- : data.issues.reduce((sum, issue) => sum + (issue.missQty || 0), 0);
- setSubmitQty(defaultQty.toString());
- } catch (error) {
- console.error("Failed to load details:", error);
- alert("Failed to load issue details");
- } finally {
- setLoading(false);
- }
- };
-
- const handleSubmit = async () => {
-
- if (!lotId || !submitQty || parseFloat(submitQty) < 0) {
- alert(t("Please enter a valid quantity"));
- return;
- }
-
- setSubmitting(true);
- try {
- await submitIssueWithQty(
- lotId,
- itemId,
- issueType,
- parseFloat(submitQty),
- currentUserId
- );
- onSuccess();
- onClose();
- } catch (error) {
- console.error("Failed to submit:", error);
- alert(`Failed to submit: ${error instanceof Error ? error.message : "Unknown error"}`);
- } finally {
- setSubmitting(false);
- }
- };
-
- if (!details) {
- return null;
- }
-
- return (
-
- );
-};
-
-export default SubmitIssueForm;
\ No newline at end of file
diff --git a/src/components/StockIssue/action.ts b/src/components/StockIssue/action.ts
index 6cb832b..903d663 100644
--- a/src/components/StockIssue/action.ts
+++ b/src/components/StockIssue/action.ts
@@ -1,17 +1,13 @@
-// Re-export types and functions from the main actions file
export {
- type StockIssueResult,
type ExpiryItemResult,
- type StockIssueLists,
- fetchList,
- fetchMissItemList,
- fetchBadItemList,
+ type ExpiryItemFilter,
+ type HandleBadItemRequest,
+ type StockIssueHandleRecord,
+ type SearchStockIssueRecordParams,
fetchExpiryItemList,
- submitMissItem,
- submitBadItem,
+ handleBadItem,
+ fetchBadItemRecords,
+ fetchExpiryItemRecords,
submitExpiryItem,
- batchSubmitMissItem,
- batchSubmitBadItem,
batchSubmitExpiryItem,
- PreloadList,
} from "@/app/api/stockIssue/actions";
diff --git a/src/components/StockIssue/index.tsx b/src/components/StockIssue/index.tsx
index fdf22b6..0994e69 100644
--- a/src/components/StockIssue/index.tsx
+++ b/src/components/StockIssue/index.tsx
@@ -1,15 +1,12 @@
import GeneralLoading from "../General/GeneralLoading";
import SearchPage from "./SearchPage";
-import { fetchList } from "@/app/api/stockIssue/actions";
interface SubComponents {
Loading: typeof GeneralLoading;
}
-const Wrapper: React.FC & SubComponents = async () => {
- const dataList = await fetchList();
-
- return ;
+const Wrapper: React.FC & SubComponents = () => {
+ return ;
};
Wrapper.Loading = GeneralLoading;
diff --git a/src/hooks/useMasterDataIssueNavCount.ts b/src/hooks/useMasterDataIssueNavCount.ts
new file mode 100644
index 0000000..f9fe303
--- /dev/null
+++ b/src/hooks/useMasterDataIssueNavCount.ts
@@ -0,0 +1,56 @@
+"use client";
+
+import { fetchMasterDataIssuesSummaryClient } from "@/app/api/masterDataIssues/client";
+import { useCallback, useEffect, useRef, useState } from "react";
+
+const POLL_MS = 120_000;
+
+export function useMasterDataIssueNavCount(enabled: boolean) {
+ const [bomGroupCount, setBomGroupCount] = useState(0);
+ const [itemGroupCount, setItemGroupCount] = useState(0);
+ const [totalGroupCount, setTotalGroupCount] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const inFlightRef = useRef(false);
+
+ const load = useCallback(async () => {
+ if (!enabled) {
+ setBomGroupCount(0);
+ setItemGroupCount(0);
+ setTotalGroupCount(0);
+ return;
+ }
+ if (inFlightRef.current) return;
+ inFlightRef.current = true;
+ setLoading(true);
+ try {
+ const data = await fetchMasterDataIssuesSummaryClient();
+ const bom = Number(data.bomGroupCount ?? 0);
+ const item = Number(data.itemGroupCount ?? 0);
+ const total = Number(data.totalGroupCount ?? bom + item);
+ setBomGroupCount(Number.isFinite(bom) && bom > 0 ? bom : 0);
+ setItemGroupCount(Number.isFinite(item) && item > 0 ? item : 0);
+ setTotalGroupCount(Number.isFinite(total) && total > 0 ? total : 0);
+ } catch {
+ setBomGroupCount(0);
+ setItemGroupCount(0);
+ setTotalGroupCount(0);
+ } finally {
+ setLoading(false);
+ inFlightRef.current = false;
+ }
+ }, [enabled]);
+
+ useEffect(() => {
+ if (!enabled) {
+ setBomGroupCount(0);
+ setItemGroupCount(0);
+ setTotalGroupCount(0);
+ return;
+ }
+ void load();
+ const id = window.setInterval(() => void load(), POLL_MS);
+ return () => window.clearInterval(id);
+ }, [enabled, load]);
+
+ return { bomGroupCount, itemGroupCount, totalGroupCount, loading, reload: load };
+}
diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json
index 8cd4dd7..a3118bf 100644
--- a/src/i18n/zh/inventory.json
+++ b/src/i18n/zh/inventory.json
@@ -288,6 +288,10 @@
"Batch Save Inputted": "批量保存已輸入",
"Batch Save Completed": "批量保存完成",
"Bad Item Handle": "不良品處理",
+ "Search to load lot lines": "請按搜索以載入批號",
+ "No changes to submit": "沒有可提交的變更",
+ "No record found": "沒有記錄",
+ "Rows per page": "每頁行數",
"Bad Item Records": "不良品處理紀錄",
"Expiry Item Handle": "過期品處理",
"Expiry Item Records": "過期品處理紀錄",
diff --git a/src/i18n/zh/masterDataIssue.json b/src/i18n/zh/masterDataIssue.json
index 3d75729..9fb1d1b 100644
--- a/src/i18n/zh/masterDataIssue.json
+++ b/src/i18n/zh/masterDataIssue.json
@@ -69,8 +69,8 @@
"masterDataIssue_bomMore": " 等 {{count}} 個",
"masterDataIssue_col_problem": "問題",
- "masterDataIssue_col_bom_uom": "BOM UOM",
- "masterDataIssue_col_item_uom": "M18 UOM",
+ "masterDataIssue_col_bom_uom": "BOM 單位",
+ "masterDataIssue_col_item_uom": "M18 單位",
"masterDataIssue_modifiedAt": "修改時間",
"masterDataIssue_unit_active": "使用中",
"masterDataIssue_unit_inactive": "已停用",