Просмотр исходного кода

bom import fix

item/bom uom issue page

new bad item handle page
production
CANCERYS\kw093 2 недель назад
Родитель
Сommit
91ce7ec396
37 измененных файлов: 3567 добавлений и 923 удалений
  1. +1
    -1
      src/app/(main)/ps/page.tsx
  2. +23
    -0
      src/app/(main)/settings/masterDataIssues/page.tsx
  3. +0
    -6
      src/app/(main)/stockIssue/page.tsx
  4. +1
    -0
      src/app/api/bom/client.ts
  5. +18
    -0
      src/app/api/bom/index.ts
  6. +71
    -26
      src/app/api/inventory/actions.ts
  7. +29
    -0
      src/app/api/masterDataIssues/client.ts
  8. +87
    -0
      src/app/api/masterDataIssues/index.ts
  9. +96
    -197
      src/app/api/stockIssue/actions.ts
  10. +2
    -2
      src/components/DoSearch/DoSearch.tsx
  11. +1
    -1
      src/components/ImportBom/ImportBomResultForm.tsx
  12. +0
    -1
      src/components/JoSearch/JoSearch.tsx
  13. +0
    -1
      src/components/JoWorkbench/JoWorkbenchSearch.tsx
  14. +109
    -0
      src/components/MasterDataIssues/MasterDataIssueDetailDialog.tsx
  15. +611
    -0
      src/components/MasterDataIssues/MasterDataIssuesPanel.tsx
  16. +107
    -0
      src/components/MasterDataIssues/MasterDataIssuesTabs.tsx
  17. +382
    -0
      src/components/MasterDataIssues/buildDisplayLines.ts
  18. +236
    -0
      src/components/MasterDataIssues/groupMasterDataIssues.ts
  19. +1
    -0
      src/components/MasterDataIssues/index.ts
  20. +49
    -0
      src/components/NavigationContent/MasterDataIssuesNavBadge.tsx
  21. +46
    -0
      src/components/NavigationContent/NavigationContent.tsx
  22. +1
    -1
      src/components/PoDetail/PoDetail.tsx
  23. +515
    -0
      src/components/StockIssue/BadItemHandleForm.tsx
  24. +159
    -0
      src/components/StockIssue/BadItemHandleModal.tsx
  25. +9
    -0
      src/components/StockIssue/BadItemHandleTab.tsx
  26. +229
    -0
      src/components/StockIssue/ExpiryHandleTab.tsx
  27. +21
    -450
      src/components/StockIssue/SearchPage.tsx
  28. +72
    -0
      src/components/StockIssue/StockIssueInventoryTable.tsx
  29. +230
    -0
      src/components/StockIssue/StockIssueLotLineTable.tsx
  30. +193
    -0
      src/components/StockIssue/StockIssueRecordTab.tsx
  31. +197
    -0
      src/components/StockIssue/StockIssueSearchPanel.tsx
  32. +0
    -219
      src/components/StockIssue/SubmitIssueForm.tsx
  33. +7
    -11
      src/components/StockIssue/action.ts
  34. +2
    -5
      src/components/StockIssue/index.tsx
  35. +56
    -0
      src/hooks/useMasterDataIssueNavCount.ts
  36. +4
    -0
      src/i18n/zh/inventory.json
  37. +2
    -2
      src/i18n/zh/masterDataIssue.json

+ 1
- 1
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"
>
<Search sx={{ fontSize: 16 }} />
</button>
</div>



+ 23
- 0
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 (
<>
<PageTitleBar title={t("masterDataIssue_pageTitle")} className="mb-4" />
<I18nProvider namespaces={["masterDataIssue"]}>
<MasterDataIssuesTabs />
</I18nProvider>
</>
);
};

export default MasterDataIssuesPage;

+ 0
- 6
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 (
<>
<I18nProvider namespaces={["inventory", "common"]}>


+ 1
- 0
src/app/api/bom/client.ts Просмотреть файл

@@ -81,6 +81,7 @@ export async function fetchBomComboClient(): Promise<BomCombo[]> {
);
return response.data;
}

export async function fetchBomDetailClient(id: number): Promise<BomDetailResponse> {
const response = await axiosInstance.get<BomDetailResponse>(


+ 18
- 0
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[];


+ 71
- 26
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<PostInventoryLotLineResponse<InventoryLotLineResult>>(`${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<PostInventoryLotLineResponse<InventoryLotLineResult>>(
`${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<InventoryListRow[]>(`${BASE_API_URL}/inventory/list`, {
next: { tags: ["inventories"] },
});
}

async function fetchInventoriesImpl(data: SearchInventory) {
const queryStr = convertObjToURLSearchParams(data);
return serverFetchJson<InventoryResultByPage>(
`${BASE_API_URL}/inventory/getRecordByPage?${queryStr}`,
{ next: { tags: ["inventories"] } },
);
}

return serverFetchJson<InventoryResultByPage>(`${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<InventoryLotLineResultByPage>(`${BASE_API_URL}/inventoryLotLine/getRecordByPage?${queryStr}`, {
next: { tags: ["inventoryLotLines"] },
});
});
async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) {
const queryStr = convertObjToURLSearchParams(data);
return serverFetchJson<InventoryLotLineResultByPage>(
`${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<InventoryLotLineResultByPage>(
`${BASE_API_URL}/inventoryLotLine/stockIssueBadItemSearch?${queryStr}`,
{ next: { tags: ["inventoryLotLines"] } },
);
}
export const updateInventoryLotLineQuantities = async (data: {
inventoryLotLineId: number;
qty: number;


+ 29
- 0
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<MasterDataIssue[]> {
const response = await axiosInstance.get<MasterDataIssue[]>(
`${NEXT_PUBLIC_API_URL}/bom/master-data/issues`,
);
return response.data;
}

export async function fetchItemMasterDataIssuesClient(): Promise<MasterDataIssue[]> {
const response = await axiosInstance.get<MasterDataIssue[]>(
`${NEXT_PUBLIC_API_URL}/items/master-data/issues`,
);
return response.data;
}

export async function fetchMasterDataIssuesSummaryClient(
options?: { includeTiming?: boolean },
): Promise<MasterDataIssueSummary> {
const response = await axiosInstance.get<MasterDataIssueSummary>(
`${NEXT_PUBLIC_API_URL}/master-data/issues/summary`,
{ params: options?.includeTiming ? { includeTiming: true } : undefined },
);
return response.data;
}

+ 87
- 0
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;
}

+ 96
- 197
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<StockIssueResult[]>(
`${BASE_API_URL}/pickExecution/issues/missItem?issueCategory=${issueCategory}`,
{
next: { tags: ["Miss Item List"] },
},
);
});

export const fetchBadItemList = cache(async (issueCategory: string = "lot_issue") => {
return serverFetchJson<StockIssueResult[]>(
`${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<ExpiryItemResult[]>(
url,
return serverFetchJson<ExpiryItemResult[]>(url, {
next: { tags: ["Expiry Item List"] },
});
});

export async function handleBadItem(request: HandleBadItemRequest) {
const res = await serverFetchJson<MessageResponse>(
`${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<StockIssueLists> => {
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<RecordsRes<StockIssueHandleRecord[]>>(
`${BASE_API_URL}/stockIssue/badItemRecords?${qs.toString()}`,
);
}

export async function submitMissItem(issueId: number, handler: number) {
return serverFetchJson<MessageResponse>(
`${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<MessageResponse>(
`${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<MessageResponse>(
`${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<MessageResponse>(
`${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<MessageResponse>(
`${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<MessageResponse>(
`${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<RecordsRes<StockIssueHandleRecord[]>>(
`${BASE_API_URL}/stockIssue/expiryItemRecords?${qs.toString()}`,
);
}

export async function submitExpiryItem(lotLineId: number, handler: number) {
return serverFetchJson<MessageResponse>(
`${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<LotIssueDetailResponse>(
`${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<MessageResponse>(
`${BASE_API_URL}/pickExecution/submitIssueWithQty`,
export async function batchSubmitExpiryItem(lotLineIds: number[], handler: number) {
return serverFetchJson<MessageResponse>(
`${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 }),
},
);
}
}

+ 2
- 2
src/components/DoSearch/DoSearch.tsx Просмотреть файл

@@ -79,7 +79,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
//console.log("🔍 DoSearch - session:", session);
//console.log("🔍 DoSearch - currentUserId:", currentUserId);
const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null);
/** 使用者明確取消勾選的送貨單 id;未在此集合中的搜結果視為「已選」以便跨頁記憶 */
/** 使用者明確取消勾選的送貨單 id;未在此集合中的搜結果視為「已選」以便跨頁記憶 */
const [excludedRowIds, setExcludedRowIds] = useState<number[]>([]);

const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]);
@@ -716,7 +716,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
setSearchBoxResetKey((prev) => prev + 1);
setPagingController((prev) => ({ ...prev, pageNum: 1 }));
setExcludedRowIds([]);
// 切換 tab 僅重置搜尋條件與結果;由使用者再次按「搜尋」後才查詢。
// 切換 tab 僅重置搜索條件與結果;由使用者再次按「搜索」後才查詢。
setSearchAllDos([]);
setTotalCount(0);
setHasSearched(false);


+ 1
- 1
src/components/ImportBom/ImportBomResultForm.tsx Просмотреть файл

@@ -156,7 +156,7 @@ type Props = {
</Typography>
<TextField
size="small"
placeholder="搜檔名"
placeholder="搜檔名"
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{


+ 0
- 1
src/components/JoSearch/JoSearch.tsx Просмотреть файл

@@ -666,7 +666,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT

const { data: session } = useSession();
const sessionToken = session as SessionWithTokens | null;

const [openModal, setOpenModal] = useState<boolean>(false);
const [modalInfo, setModalInfo] = useState<StockInLineInput>();



+ 0
- 1
src/components/JoWorkbench/JoWorkbenchSearch.tsx Просмотреть файл

@@ -667,7 +667,6 @@ const JoWorkbenchSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCo

const { data: session } = useSession();
const sessionToken = session as SessionWithTokens | null;

const [openModal, setOpenModal] = useState<boolean>(false);
const [modalInfo, setModalInfo] = useState<StockInLineInput>();



+ 109
- 0
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<Props> = ({
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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle sx={{ pr: 6 }}>
<Stack spacing={0.5}>
<Typography variant="h6" component="span" sx={{ fontWeight: 700 }}>
{primaryTitle(group)}
</Typography>
</Stack>
<IconButton
aria-label={t("masterDataIssue_close")}
onClick={onClose}
sx={{ position: "absolute", right: 8, top: 8 }}
>
<X size={18} />
</IconButton>
</DialogTitle>
<DialogContent dividers>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
{showBomCol ? (
<TableCell>{t("masterDataIssue_col_bom")}</TableCell>
) : null}
<TableCell>{t("masterDataIssue_col_issue")}</TableCell>
{showUomCols ? (
<TableCell>{t("masterDataIssue_col_expected")}</TableCell>
) : null}
{showUomCols ? (
<TableCell>{t("masterDataIssue_col_actual")}</TableCell>
) : null}
</TableRow>
</TableHead>
<TableBody>
{detailRows.map((row) => (
<TableRow key={row.key}>
{showBomCol ? (
<TableCell>{row.bomLabel ?? "-"}</TableCell>
) : null}
<TableCell sx={{ color: "warning.dark" }}>
{row.problem}
</TableCell>
{showUomCols ? (
<TableCell>{row.expected ?? "-"}</TableCell>
) : null}
{showUomCols ? (
<TableCell>{row.actual ?? "-"}</TableCell>
) : null}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
</Dialog>
);
};

export default MasterDataIssueDetailDialog;

+ 611
- 0
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<ItemUnitCellProps> = ({
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 (
<Stack spacing={0.75} sx={{ minWidth: 0 }}>
<Box sx={{ minHeight: 20, display: "flex", alignItems: "flex-end" }}>
<Typography
variant="body2"
sx={{
fontWeight: 700,
color: "text.primary",
lineHeight: 1.3,
}}
>
{desc}
</Typography>
</Box>
<Box sx={{ minHeight: 20, display: "flex", alignItems: "flex-start" }}>
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.35 }}>
{code}
</Typography>
</Box>
<Box sx={{ minHeight: 28, display: "flex", alignItems: "center" }}>
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.5,
px: 1,
py: 0.35,
borderRadius: 1,
width: "fit-content",
bgcolor: badgeBg,
color: badgeColor,
}}
>
{isActive ? <Check size={14} strokeWidth={2.5} /> : null}
{isDeleted ? <X size={14} strokeWidth={2.5} /> : null}
<Typography variant="caption" sx={{ fontWeight: 600, lineHeight: 1 }}>
{badgeText}
</Typography>
</Box>
</Box>
<Box sx={{ minHeight: 18, display: "flex", alignItems: "center", gap: 0.5 }}>
{!isMissing ? (
<>
<Clock size={12} style={{ flexShrink: 0, opacity: 0.55 }} />
<Typography
variant="caption"
color="text.secondary"
sx={{ lineHeight: 1.3, whiteSpace: "nowrap" }}
>
{modifiedAt}
</Typography>
</>
) : (
<Typography variant="caption" color="text.secondary">
-
</Typography>
)}
</Box>
</Stack>
);
};

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<Props> = ({
mode,
issues,
loading,
loadError,
onRefresh,
}) => {
const { t } = useTranslation("masterDataIssue");
const inFlightRef = useRef(false);
const [scopeFilter, setScopeFilter] = useState<string>("ALL");
const [search, setSearch] = useState("");
const [detailGroup, setDetailGroup] = useState<MasterDataIssueGroup | null>(
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<BomCompareColumnProps> = ({
rows,
valueKey,
labelColor,
valueColor,
}) => (
<Stack spacing={1.5}>
{rows.map((row) => (
<Stack key={row.key} spacing={0.35}>
<Typography
variant="body2"
sx={{ color: labelColor, fontWeight: 600, lineHeight: 1.3 }}
>
{row.unitLabel}
</Typography>
<Typography
variant="body2"
sx={{ color: valueColor, fontWeight: 500, lineHeight: 1.4, wordBreak: "break-word" }}
>
{row[valueKey]}
</Typography>
</Stack>
))}
</Stack>
);

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 (
<Box>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1.5}
alignItems={{ sm: "center" }}
sx={{ mb: 2 }}
flexWrap="wrap"
>
<Typography variant="body2" color="text.secondary">
{t("masterDataIssue_group_count", {
groups: groups.length,
issues: filteredIssues.length,
})}
</Typography>
<Box sx={{ flex: 1 }} />
<TextField
size="small"
label={t("masterDataIssue_search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ minWidth: 180 }}
/>
{mode === "bom" ? (
<TextField
select
size="small"
label={t("masterDataIssue_filter_type")}
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value)}
sx={{ minWidth: 140 }}
>
<MenuItem value="ALL">{t("masterDataIssue_filter_all")}</MenuItem>
<MenuItem value="BOM">{scopeLabel("BOM")}</MenuItem>
<MenuItem value="BOM_MATERIAL">{scopeLabel("BOM_MATERIAL")}</MenuItem>
</TextField>
) : null}
<Button
size="small"
variant="outlined"
disabled={loading}
onClick={handleRefreshClick}
>
{loading ? t("masterDataIssue_refreshing") : t("masterDataIssue_refresh")}
</Button>
<Button
size="small"
variant="outlined"
disabled={groupsWithLines.length === 0}
onClick={() => void handleCopy()}
>
{t("masterDataIssue_copy")}
</Button>
</Stack>

{loadError && (
<Alert severity="error" sx={{ mb: 2 }}>
{loadError}
</Alert>
)}

{loading && issues.length === 0 && !loadError ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress size={32} />
</Box>
) : null}

{!loading && !loadError && groupsWithLines.length === 0 ? (
<Typography variant="body2" color="text.secondary">
{t("masterDataIssue_empty")}
</Typography>
) : null}

{groupsWithLines.length > 0 ? (
<TableContainer component={Paper} variant="outlined">
<Table
size="small"
stickyHeader
sx={mode === "item" ? { tableLayout: "fixed", width: "100%" } : undefined}
>
<TableHead>
<TableRow>
<TableCell sx={{ width: "28%" }}>
{mode === "item"
? t("masterDataIssue_col_item")
: t("masterDataIssue_col_subject")}
</TableCell>
{mode === "bom" ? (
<>
<TableCell>{t("masterDataIssue_col_bom_uom", { defaultValue: "BOM UOM" })}</TableCell>
<TableCell>{t("masterDataIssue_col_item_uom", { defaultValue: "Item 正确 UOM" })}</TableCell>
</>
) : (
<>
<TableCell sx={itemUnitCellSx}>{t("masterDataIssue_unit_base")}</TableCell>
<TableCell sx={itemUnitCellSx}>{t("masterDataIssue_unit_stock")}</TableCell>
<TableCell sx={itemUnitCellSx}>{t("masterDataIssue_unit_purchase")}</TableCell>
<TableCell sx={itemUnitCellSx}>{t("masterDataIssue_unit_sales")}</TableCell>
</>
)}

</TableRow>
</TableHead>
<TableBody>
{groupsWithLines.map(({ group, showDetail }) => {
const matSubtitle = materialBomSubtitle(group, t);
const unitSnapshot = unitSnapshotFromGroup(group);
const bomCompareRows = buildBomCompareRows(group);
return (
<TableRow key={group.groupKey}>
<TableCell sx={mode === "item" ? { verticalAlign: "top" } : undefined}>
{mode === "item" ? (
<Stack spacing={0.35} sx={{ minWidth: 0 }}>
{group.itemCode?.trim() ? (
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{group.itemCode.trim()}
</Typography>
) : null}
{group.itemName?.trim() ? (
<Typography
variant="body2"
sx={{ lineHeight: 1.45, wordBreak: "break-word" }}
>
{group.itemName.trim()}
</Typography>
) : !group.itemCode?.trim() ? (
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatItemLabel(group)}
</Typography>
) : null}
</Stack>
) : (
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{primaryTitle(group)}
</Typography>
)}
{mode === "bom" && group.groupType === "bom_header" ? (
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
<Box>
<Chip
size="small"
label={scopeLabel("BOM")}
color="primary"
variant="outlined"
/>
</Box>
{group.itemCode?.trim() ? (
<Typography variant="caption" color="text.secondary" display="block">
{[group.itemCode.trim(), group.itemName?.trim()].filter(Boolean).join(" · ")}
</Typography>
) : null}
</Stack>
) : null}
{mode === "bom" && group.groupType === "bom_material" ? (
<Stack spacing={0.5} sx={{ mt: 0.5 }}>
<Box>
<Chip
size="small"
label={scopeLabel("BOM_MATERIAL")}
color="secondary"
variant="outlined"
/>
</Box>
{matSubtitle ? (
<Typography variant="caption" color="text.secondary" display="block">
{matSubtitle}
</Typography>
) : null}
</Stack>
) : null}
</TableCell>
{mode === "bom" ? (
<>
<TableCell sx={{ verticalAlign: "top" }}>
<BomCompareColumn
rows={bomCompareRows}
valueKey="bomValue"
labelColor="error.main"
valueColor="error.dark"
/>
</TableCell>
<TableCell sx={{ verticalAlign: "top" }}>
<BomCompareColumn
rows={bomCompareRows}
valueKey="itemValue"
labelColor="success.main"
valueColor="success.dark"
/>
</TableCell>
</>
) : (
<>
{(
[
["base", unitSnapshot.base],
["stock", unitSnapshot.stock],
["purchase", unitSnapshot.purchase],
["sales", unitSnapshot.sales],
] as const
).map(([key, unit]) => (
<TableCell key={key} sx={itemUnitCellSx}>
<ItemUnitStatusCell
status={unit.status}
value={unit.value}
modifiedAt={unit.modifiedAt}
activeLabel={t("masterDataIssue_unit_active", {
defaultValue: "啟用",
})}
inactiveLabel={t("masterDataIssue_unit_inactive", {
defaultValue: "已停用",
})}
missingLabel={t("masterDataIssue_unit_missing", {
defaultValue: "沒有單位",
})}
/>
</TableCell>
))}
</>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
) : null}

<MasterDataIssueDetailDialog
open={detailGroup != null}
group={detailGroup}
onClose={() => setDetailGroup(null)}
/>
</Box>
);
};

export default MasterDataIssuesPanel;

+ 107
- 0
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<MasterDataIssue[]>([]);
const [itemIssues, setItemIssues] = useState<MasterDataIssue[]>([]);
const [bomLoading, setBomLoading] = useState(false);
const [itemLoading, setItemLoading] = useState(false);
const [bomError, setBomError] = useState<string | null>(null);
const [itemError, setItemError] = useState<string | null>(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 (
<Box>
<Tabs
value={tab}
onChange={(_, v) => setTab(v)}
sx={{ mb: 2, borderBottom: 1, borderColor: "divider" }}
>
<Tab label={t("masterDataIssue_tab_bom")} />
<Tab label={t("masterDataIssue_tab_item")} />
</Tabs>

{tab === 0 ? (
<MasterDataIssuesPanel
mode="bom"
issues={bomIssues}
loading={bomLoading}
loadError={bomError}
onRefresh={loadBomIssues}
/>
) : (
<MasterDataIssuesPanel
mode="item"
issues={itemIssues}
loading={itemLoading}
loadError={itemError}
onRefresh={loadItemIssues}
/>
)}
</Box>
);
};

export default MasterDataIssuesTabs;

+ 382
- 0
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<string, string> = {
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<string, MasterDataIssue[]> {
const map = new Map<string, MasterDataIssue[]>();
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<MasterDataIssue>();

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<MasterDataIssue>();
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);
}

+ 236
- 0
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<string>();
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<string, string | null> {
const map = new Map<string, string | null>();
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<string, MasterDataIssue[]>();
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<string, MasterDataIssue[]>();
const materialMap = new Map<string, MasterDataIssue[]>();

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" });
});
}


+ 1
- 0
src/components/MasterDataIssues/index.ts Просмотреть файл

@@ -0,0 +1 @@
export { default as MasterDataIssuesTabs } from "./MasterDataIssuesTabs";

+ 49
- 0
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<Props> = ({ 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 (
<Tooltip title={label} placement="right">
<Box
component="span"
aria-label={label}
sx={{
flexShrink: 0,
minWidth: 22,
height: 22,
px: 0.75,
borderRadius: "11px",
bgcolor: "error.main",
color: "error.contrastText",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.75rem",
fontWeight: 700,
lineHeight: 1,
}}
>
{display}
</Box>
</Tooltip>
);
};

export default MasterDataIssuesNavBadge;

+ 46
- 0
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: <ReportProblem />,
label: "masterDataIssue_nav",
path: "/settings/masterDataIssues",
requiredAbility: [AUTH.ADMIN],
},
{
icon: <QrCodeIcon />,
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<string[]>([]);
/** 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 = () => {
</Box>
<PurchaseStockInNavAlerts enabled={canSeePoAlerts} />
</Box>
) : child.path === "/settings/masterDataIssues" ? (
<Box
key={`${child.label}-${child.path}`}
component={Link}
href={child.path}
sx={{
display: "block",
mx: 1,
borderRadius: 1,
textDecoration: "none",
color: "inherit",
"&:hover": { bgcolor: "action.hover" },
}}
>
<ListItemButton
selected={child.path === selectedLeafPath}
sx={{
py: 1,
pr: 1.5,
display: "flex",
alignItems: "center",
gap: 0.5,
"&.Mui-selected .MuiListItemIcon-root": { color: "primary.main" },
}}
>
<ListItemIcon sx={{ minWidth: 40 }}>{child.icon}</ListItemIcon>
<ListItemText
primary={t(child.label)}
sx={{ flex: 1, minWidth: 0, my: 0 }}
primaryTypographyProps={{
fontWeight:
child.path === selectedLeafPath ? 600 : 500,
fontSize: "0.875rem",
}}
/>
<MasterDataIssuesNavBadge enabled={canSeeMasterDataIssueBadge} />
</ListItemButton>
</Box>
) : child.path === "/productionProcess" ? (
<Box
key={`${child.label}-${child.path}`}


+ 1
- 1
src/components/PoDetail/PoDetail.tsx Просмотреть файл

@@ -232,7 +232,7 @@ const PoSearchList: React.FC<{
) : (
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
{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: "沒有可顯示的採購單" })}
</Typography>
)}


+ 515
- 0
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<InventoryLotLineResult[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [paging, setPaging] = useState({ pageNum: 1, pageSize: 20 });
const [filterArgs, setFilterArgs] = useState<SearchQuery>({
itemCode: "",
itemName: "",
itemType: "All",
lotNo: "",
});
const [drafts, setDrafts] = useState<Record<number, RowDraft>>({});
const [itemTypeOptions, setItemTypeOptions] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set());
const rowSubmitInFlightRef = useRef<Set<number>>(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<SearchParamNames>[] = 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<number, RowDraft> = 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<SearchParamNames, string>) => {
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<RowDraft>) => {
setDrafts((prev) => {
const current = prev[lineId] ?? {
status: "available",
badQty: "",
remarks: "",
};
return {
...prev,
[lineId]: { ...current, ...patch },
};
});
}, []);

return (
<Box>
<StockIssueSearchPanel
fields={searchFields}
onSearch={handleSearch}
disabled={loading}
/>

<Card elevation={0} sx={{ mt: 2 }}>
<CardContent sx={{ p: 0 }}>
<Typography variant="overline" sx={{ px: 2, pt: 2, display: "block" }}>
{t("Bad Item Handle")}
</Typography>
<TableContainer component={Paper} elevation={0}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("Code")}</TableCell>
<TableCell>{t("Name")}</TableCell>
<TableCell>{t("Lot No.")}</TableCell>
<TableCell align="right">{t("Available Qty")}</TableCell>
<TableCell>{t("Stock UoM")}</TableCell>
<TableCell>{t("Expiry Date")}</TableCell>
<TableCell>{t("Warehouse")}</TableCell>
<TableCell sx={{ minWidth: 140 }}>{t("Status")}</TableCell>
<TableCell sx={{ minWidth: 100 }}>{t("Defective Qty")}</TableCell>
<TableCell sx={{ minWidth: 120 }}>{t("Remarks")}</TableCell>
<TableCell align="center" sx={{ minWidth: 100 }}>
{t("Action")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!hasSearchedRef.current ? (
<TableRow>
<TableCell colSpan={11} align="center">
{t("Search to load lot lines")}
</TableCell>
</TableRow>
) : loading ? (
<TableRow>
<TableCell colSpan={11} align="center">
{t("Loading")}
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={11} align="center">
{t("No record found")}
</TableCell>
</TableRow>
) : (
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 (
<TableRow key={line.id} hover>
<TableCell>{line.item?.code ?? "—"}</TableCell>
<TableCell>{line.item?.name ?? "—"}</TableCell>
<TableCell>{line.lotNo ?? "—"}</TableCell>
<TableCell align="right">{maxQty}</TableCell>
<TableCell>{line.uom ?? "—"}</TableCell>
<TableCell>
{arrayToDateString(line.expiryDate)}
</TableCell>
<TableCell>{line.warehouse?.code ?? "—"}</TableCell>
<TableCell>
<FormControl
size="small"
fullWidth
disabled={isSubmitting}
>
<InputLabel id={`status-${line.id}`}>
{t("Status")}
</InputLabel>
<Select
labelId={`status-${line.id}`}
label={t("Status")}
value={normalizeStatus(draft.status)}
onChange={(e) =>
updateDraft(line.id, { status: e.target.value })
}
>
{LOT_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{formatStatusLabel(s)}
</MenuItem>
))}
</Select>
</FormControl>
</TableCell>
<TableCell>
<TextField
size="small"
fullWidth
value={draft.badQty}
disabled={!canSubmit || maxQty <= 0}
onChange={(e) => {
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),
),
});
}
}}
/>
</TableCell>
<TableCell>
<TextField
size="small"
fullWidth
value={draft.remarks}
disabled={!canSubmit}
onChange={(e) =>
updateDraft(line.id, { remarks: e.target.value })
}
/>
</TableCell>
<TableCell align="center">
<Button
variant="contained"
color="error"
size="small"
disabled={!canSubmit}
onClick={() => handleRowSubmit(line)}
>
{isSubmitting
? t("Processing...")
: t("Submit")}
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={paging.pageNum - 1}
onPageChange={(_, page) =>
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")}
/>
</CardContent>
</Card>
</Box>
);
};

export default BadItemHandleForm;

+ 159
- 0
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<void>;
}

const BadItemHandleModal: React.FC<Props> = ({
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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{t("Bad Item Handle")}</DialogTitle>
<DialogContent>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mt: 1 }}>
<Typography variant="body2">
<strong>{t("Item Code")}:</strong> {inventory.itemCode}
</Typography>
<Typography variant="body2">
<strong>{t("Item")}:</strong> {inventory.itemName}
</Typography>
<Typography variant="body2">
<strong>{t("Lot No.")}:</strong> {lotLine.lotNo}
</Typography>
<Typography variant="body2">
<strong>{t("Location")}:</strong> {lotLine.warehouse?.code ?? "—"}
</Typography>
<Typography variant="body2">
<strong>{t("Available Qty")}:</strong> {maxQty} {lotLine.uom ?? ""}
</Typography>
<TextField
label={t("Defective Qty")}
fullWidth
value={qty}
onChange={(e) => {
const raw = e.target.value.replace(/\D/g, "");
if (raw === "") {
setQty("");
return;
}
const n = parseInt(raw, 10);
if (!Number.isNaN(n)) {
setQty(String(Math.min(Math.max(1, n), maxQty)));
}
}}
sx={{ mt: 1 }}
/>
<TextField
label={t("Remarks")}
fullWidth
multiline
minRows={2}
value={remarks}
onChange={(e) => setRemarks(e.target.value)}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={submitting}>
{t("Cancel")}
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={submitting || !currentUserId || !qty}
>
{submitting ? t("Processing...") : t("Submit")}
</Button>
</DialogActions>
</Dialog>
);
};

export default BadItemHandleModal;

+ 9
- 0
src/components/StockIssue/BadItemHandleTab.tsx Просмотреть файл

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

import BadItemHandleForm from "./BadItemHandleForm";

const BadItemHandleTab: React.FC = () => {
return <BadItemHandleForm />;
};

export default BadItemHandleTab;

+ 229
- 0
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<ExpiryItemResult[]>([]);
const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set());
const [batchSubmitting, setBatchSubmitting] = useState(false);
const [batchProgress, setBatchProgress] = useState<{
done: number;
total: number;
} | null>(null);
const expirySubmitInFlightRef = useRef<Set<number>>(new Set());
const batchSubmitInFlightRef = useRef(false);
const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 });

const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo(
() => [
{ name: "itemCode", label: t("Item Code"), type: "text" },
{ name: "itemName", label: t("Item"), type: "text" },
{ name: "expiryDate", label: t("Expiry Date"), type: "date" },
],
[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<Column<ExpiryItemResult>[]>(
() => [
{ 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) => (
<Button
size="small"
variant="contained"
color="primary"
onClick={() => handleSubmitSingle(item.id)}
disabled={submittingIds.has(item.id) || !currentUserId}
>
{submittingIds.has(item.id) ? t("Disposing...") : t("Disposed")}
</Button>
),
},
],
[t, handleSubmitSingle, submittingIds, currentUserId],
);

const handleSearch = useCallback(
async (query: Record<SearchParamNames, string>) => {
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 (
<Box>
<StockIssueSearchPanel fields={searchFields} onSearch={handleSearch} />
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
<Button
variant="contained"
color="primary"
onClick={handleSubmitAll}
disabled={batchSubmitting || !currentUserId || expiryItems.length === 0}
>
{batchSubmitting
? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}`
: t("Batch Disposed All")}
</Button>
</Box>
<SearchResults<ExpiryItemResult>
items={pagedItems}
columns={expiryColumns}
pagingController={paging}
setPagingController={setPaging}
totalCount={expiryItems.length}
/>
</Box>
);
};

export default ExpiryHandleTab;

+ 21
- 450
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<Props> = ({ 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<SearchQuery>({
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<number | null>(null);
const [selectedItemId, setSelectedItemId] = useState<number>(0);
const [selectedIssueType, setSelectedIssueType] = useState<"miss" | "bad">("miss");

const [missItems, setMissItems] = useState<StockIssueResult[]>(
dataList.missItems,
);
const [badItems, setBadItems] = useState<StockIssueResult[]>(
dataList.badItems,
);
const [expiryItems, setExpiryItems] = useState<ExpiryItemResult[]>(
dataList.expiryItems,
);
const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
const [submittingIds, setSubmittingIds] = useState<Set<number>>(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<SearchParamNames>[] = 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(
<T extends { lotNo: string | null }>(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<TabValue>("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<Column<StockIssueResult>[]>(
() => [
{ 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) => (
<Button
size="small"
variant="contained"
color="primary"
onClick={() => handleSubmitSingle(item.id)}
disabled={submittingIds.has(item.id) || !currentUserId}
>
{submittingIds.has(item.id) ? t("Processing...") : t("Looked")}
</Button>
),
},
],
[t, handleSubmitSingle, submittingIds, currentUserId],
);

const badColumns = useMemo<Column<StockIssueResult>[]>(
() => [
{ 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) => (
<Button
size="small"
variant="contained"
color="primary"
onClick={() => handleSubmitSingle(item.id)}
disabled={submittingIds.has(item.id) || !currentUserId}
>
{submittingIds.has(item.id) ? t("Disposing...") : t("Disposed")}
</Button>
),
},
],
[t, handleSubmitSingle, submittingIds, currentUserId],
);

const expiryColumns = useMemo<Column<ExpiryItemResult>[]>(
() => [
{ 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) => (
<Button
size="small"
variant="contained"
color="primary"
onClick={() => handleSubmitSingle(item.id)}
disabled={submittingIds.has(item.id) || !currentUserId}
>
{submittingIds.has(item.id) ? t("Disposing...") : t("Disposed")}
</Button>
),
},
],
[t, handleSubmitSingle, submittingIds, currentUserId],
);

const handleSearch = useCallback(async (query: Record<SearchParamNames, string>) => {
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 (
<SearchResults<StockIssueResult>
items={items}
columns={missColumns}
pagingController={paging}
checkboxIds={selectedIds}
setPagingController={setPaging}
setCheckboxIds={setSelectedIds}
/>
);
}

if (tab === "bad") {
const items = filterBySearch(badItems);
return (
<SearchResults<StockIssueResult>
items={items}
columns={badColumns}
pagingController={paging}
setPagingController={setPaging}
checkboxIds={selectedIds}
setCheckboxIds={setSelectedIds}
/>
);
}

const items = filterBySearch(expiryItems);
return (
<SearchResults<ExpiryItemResult>
items={items}
columns={expiryColumns}
pagingController={paging}
setPagingController={setPaging}
checkboxIds={selectedIds}
setCheckboxIds={setSelectedIds}
/>
);
};
const handleTabChange = useCallback((_: React.SyntheticEvent, value: string) => {
setTab(value as TabValue);
}, []);

return (
<Box>
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 2 }}>
<Tab value="miss" label={t("Miss Item")} />
<Tab value="bad" label={t("Bad Item")} />
<Tab value="expiry" label={t("Expiry Item")} />
<Tab value="badHandle" label={t("Bad Item Handle")} />
<Tab value="badRecord" label={t("Bad Item Records")} />
<Tab value="expiryHandle" label={t("Expiry Item Handle")} />
<Tab value="expiryRecord" label={t("Expiry Item Records")} />
</Tabs>

<SearchBox<SearchParamNames>
criteria={searchCriteria}
onSearch={handleSearch}
/>

{tab === "expiry" && (
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}>
<Button
variant="contained"
color="primary"
onClick={handleSubmitSelected}
disabled={batchSubmitting || !currentUserId}
>
{batchSubmitting
? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}`
: t("Batch Disposed All")}
</Button>
</Box>
)}

{renderCurrentTab()}
<SubmitIssueForm
open={formOpen}
onClose={() => setFormOpen(false)}
lotId={selectedLotId}
itemId={selectedItemId}
issueType={selectedIssueType}
currentUserId={currentUserId || 0}
onSuccess={handleFormSuccess}
/>
{tab === "badHandle" && <BadItemHandleTab />}
{tab === "badRecord" && <StockIssueRecordTab kind="bad" />}
{tab === "expiryHandle" && <ExpiryHandleTab />}
{tab === "expiryRecord" && <StockIssueRecordTab kind="expiry" />}
</Box>
);
};

export default SearchPage;
export default SearchPage;

+ 72
- 0
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<Props> = ({
inventories,
pagingController,
setPagingController,
totalCount,
onRowClick,
}) => {
const { t } = useTranslation(["inventory", "common"]);

const columns = useMemo<Column<InventoryResult>[]>(
() => [
{ 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 (
<SearchResults<InventoryResult>
items={inventories}
columns={columns}
pagingController={pagingController}
setPagingController={setPagingController}
totalCount={totalCount}
onRowClick={onRowClick}
/>
);
};

export default StockIssueInventoryTable;

+ 230
- 0
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<void>;
onLotLinesChanged?: () => void | Promise<void>;
}

const StockIssueLotLineTable: React.FC<Props> = ({
inventoryLotLines,
pagingController,
setPagingController,
totalCount,
inventory,
currentUserId,
onBadItemHandleSuccess,
onLotLinesChanged,
}) => {
const { t } = useTranslation("inventory");
const [modalOpen, setModalOpen] = useState(false);
const [selectedLotLine, setSelectedLotLine] =
useState<InventoryLotLineResult | null>(null);
const [statusUpdatingIds, setStatusUpdatingIds] = useState<Set<number>>(
new Set(),
);
const statusInFlightRef = useRef<Set<number>>(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<string>) => {
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<Column<InventoryLotLineResult>[]>(
() => [
{ 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) => (
<FormControl
size="small"
fullWidth
disabled={statusUpdatingIds.has(row.id)}
>
<InputLabel id={`lot-status-${row.id}`}>{t("Status")}</InputLabel>
<Select
labelId={`lot-status-${row.id}`}
label={t("Status")}
value={
LOT_STATUSES.includes(
row.status?.toLowerCase() as (typeof LOT_STATUSES)[number],
)
? row.status!.toLowerCase()
: "unavailable"
}
onChange={(e) => handleStatusChange(row, e)}
>
{LOT_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{formatStatusLabel(s)}
</MenuItem>
))}
</Select>
</FormControl>
),
},
{
name: "id",
label: t("Bad Item Handle"),
align: "center",
headerAlign: "center",
renderCell: (row) => (
<IconButton
color="error"
disabled={!isBadItemEnabled(row) || !currentUserId}
onClick={() => handleBadItemClick(row)}
title={t("Bad Item Handle")}
>
<HighlightOffIcon />
</IconButton>
),
},
],
[
t,
handleStatusChange,
formatStatusLabel,
statusUpdatingIds,
isBadItemEnabled,
handleBadItemClick,
currentUserId,
],
);

return (
<>
<Box sx={{ mb: 2 }}>
<Typography variant="h6">
{inventory
? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})`
: t("No items are selected yet.")}
</Typography>
</Box>
<SearchResults<InventoryLotLineResult>
items={displayLotLines}
columns={columns}
pagingController={pagingController}
setPagingController={setPagingController}
totalCount={totalCount}
/>
<BadItemHandleModal
open={modalOpen}
onClose={() => setModalOpen(false)}
lotLine={selectedLotLine}
inventory={inventory}
currentUserId={currentUserId}
onSuccess={async (payload) => {
await onBadItemHandleSuccess?.(payload);
}}
/>
</>
);
};

export default StockIssueLotLineTable;

+ 193
- 0
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<Props> = ({ kind }) => {
const { t } = useTranslation("inventory");
const [items, setItems] = useState<StockIssueHandleRecord[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 });
const [filterArgs, setFilterArgs] = useState<SearchQuery>({
itemCode: "",
itemName: "",
lotNo: "",
startDate: "",
endDate: "",
});
const hasSearchedRef = useRef(false);
const prevPagingRef = useRef(paging);

const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo(
() => [
{ name: "itemCode", label: t("Item Code"), type: "text" },
{ name: "itemName", label: t("Item"), type: "text" },
{ name: "lotNo", label: t("Lot No."), type: "text" },
{
name: "startDate",
label: kind === "expiry" ? t("Expiry Start Date") : t("Start Date"),
type: "date",
mirrorTo: "endDate",
},
{
name: "endDate",
label: kind === "expiry" ? t("Expiry End Date") : t("End Date"),
type: "date",
},
],
[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<SearchParamNames, string>) => {
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<Column<StockIssueHandleRecord>[]>(() => {
const base: Column<StockIssueHandleRecord>[] = [
{
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 (
<>
<StockIssueSearchPanel fields={searchFields} onSearch={handleSearch} />
<SearchResults<StockIssueHandleRecord>
items={items}
columns={columns}
pagingController={paging}
setPagingController={setPaging}
totalCount={totalCount}
isAutoPaging={false}
/>
</>
);
};

export default StockIssueRecordTab;

+ 197
- 0
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<K extends string> {
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<K extends string> {
fields: StockIssueSearchField<K>[];
onSearch: (values: Record<K, string>) => void;
onReset?: () => void;
extraActions?: React.ReactNode;
disabled?: boolean;
}

function StockIssueSearchPanel<K extends string>({
fields,
onSearch,
onReset,
extraActions,
disabled = false,
}: Props<K>) {
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<K, string>,
);
}, [fields]);

const [values, setValues] = useState<Record<K, string>>(emptyValues);

const handleTextChange = useCallback(
(name: K) => (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Card className="app-search-criteria" elevation={0} sx={{ mb: 2 }}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography
className="app-search-criteria-label"
variant="overline"
sx={{ display: "block", mb: 0.5 }}
>
{t("Search Criteria")}
</Typography>
<Grid container spacing={2} columns={{ xs: 12, sm: 12, md: 12, lg: 12 }}>
{fields.map((field) => (
<Grid key={field.name} item xs={12} sm={6} md={4} lg={3}>
{field.type === "text" && (
<TextField
label={field.label}
fullWidth
value={values[field.name] ?? ""}
onChange={handleTextChange(field.name)}
disabled={disabled}
/>
)}
{field.type === "select" && (
<FormControl fullWidth disabled={disabled}>
<InputLabel>{field.label}</InputLabel>
<Select
label={field.label}
value={values[field.name] ?? "All"}
onChange={handleSelectChange(field.name)}
>
<MenuItem value="All">{tCommon("All")}</MenuItem>
{(field.options ?? []).map((option) => (
<MenuItem key={option} value={option}>
{field.getOptionLabel?.(option) ?? t(option)}
</MenuItem>
))}
</Select>
</FormControl>
)}
{field.type === "date" && (
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale="zh-hk"
>
<DatePicker
label={field.label}
format={OUTPUT_DATE_FORMAT}
disabled={disabled}
value={
values[field.name] && dayjs(values[field.name]).isValid()
? dayjs(values[field.name])
: null
}
onChange={handleDateChange(field.name, field.mirrorTo)}
slotProps={{ textField: { fullWidth: true } }}
/>
</LocalizationProvider>
)}
</Grid>
))}
</Grid>
<CardActions
sx={{ justifyContent: "flex-start", gap: 1, pt: 2, flexWrap: "wrap", px: 0 }}
>
<Button
variant="outlined"
startIcon={<RestartAlt />}
onClick={handleReset}
disabled={disabled}
sx={{ borderColor: "#e2e8f0", color: "#334155" }}
>
{t("Reset")}
</Button>
<Button
variant="contained"
color="primary"
startIcon={<Search />}
onClick={handleSearchClick}
disabled={disabled}
>
{t("Search")}
</Button>
{extraActions}
</CardActions>
</CardContent>
</Card>
);
}

export default StockIssueSearchPanel;

+ 0
- 219
src/components/StockIssue/SubmitIssueForm.tsx Просмотреть файл

@@ -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<Props> = ({
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<LotIssueDetailResponse | null>(null);
const [submitQty, setSubmitQty] = useState<string>("");
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 (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
{issueType === "miss" ? t("Submit Miss Item") : t("Submit Bad Item")}
</DialogTitle>
<DialogContent>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("Item Code")}:</strong> {details.itemCode}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("Item")}:</strong> {details.itemDescription}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("Lot No.")}:</strong> {details.lotNo}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("Location")}:</strong> {details.storeLocation}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("Book Qty")}:</strong>{" "}
{details.bookQty}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("UoM")}:</strong>{" "}
{details.uomDesc ?? ""}
</Typography>
</Box>

<TableContainer component={Paper} sx={{ mb: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("Picker Name")}</TableCell>
<TableCell align="right">
{issueType === "miss" ? t("Miss Qty") : t("Issue Qty")}
</TableCell>
<TableCell>{t("Pick Order Code")}</TableCell>
<TableCell>{t("DO Order Code")}</TableCell>
<TableCell>{t("JO Order Code")}</TableCell>
<TableCell>{t("Remark")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{details.issues.map((issue) => (
<TableRow key={issue.issueId}>
<TableCell>{issue.pickerName || "-"}</TableCell>
<TableCell align="right">
{issueType === "miss"
? issue.missQty?.toFixed(0) || "0"
: issue.issueQty?.toFixed(0) || "0"}
</TableCell>
<TableCell>{issue.pickOrderCode}</TableCell>
<TableCell>{issue.doOrderCode || "-"}</TableCell>
<TableCell>{issue.joOrderCode || "-"}</TableCell>
<TableCell>{issue.issueRemark || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>

<TextField
fullWidth
label={t("Submit Quantity")}
type="number"
value={submitQty}
onChange={(e) => setSubmitQty(e.target.value)}
inputProps={{ min: 0, step: 0.01 }}
sx={{ mt: 2 }}
/>
<TextField
fullWidth
label={t("Remain available Quantity")}
type="number"
value={remainAvailable}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
setSubmitQty("");
return;
}
const remain = parseFloat(raw);
if (!Number.isNaN(remain) && remain >= 0) {
const newSubmit = Math.max(0, bookQty - remain);
setSubmitQty(newSubmit.toFixed(0));
}
}}
inputProps={{ min: 0, step: 0.01, readOnly: false }}
sx={{ mt: 2 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={submitting}>
{t("Cancel")}
</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={submitting || !submitQty || parseFloat(submitQty) < 0}
>
{submitting ? t("Submitting...") : t("Submit")}
</Button>
</DialogActions>
</Dialog>
);
};

export default SubmitIssueForm;

+ 7
- 11
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";

+ 2
- 5
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 <SearchPage dataList={dataList} />;
const Wrapper: React.FC & SubComponents = () => {
return <SearchPage />;
};

Wrapper.Loading = GeneralLoading;


+ 56
- 0
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 };
}

+ 4
- 0
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": "過期品處理紀錄",


+ 2
- 2
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": "已停用",


Загрузка…
Отмена
Сохранить