Procházet zdrojové kódy

bom import fix

item/bom uom issue page

new bad item handle page
production
CANCERYS\kw093 před 2 týdny
rodič
revize
91ce7ec396
37 změnil soubory, kde provedl 3567 přidání a 923 odebrání
  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 Zobrazit soubor

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




+ 23
- 0
src/app/(main)/settings/masterDataIssues/page.tsx Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -1,8 +1,6 @@
import SearchPage from "@/components/StockIssue/index"; import SearchPage from "@/components/StockIssue/index";
import { PreloadList } from "@/components/StockIssue/action";
import { getServerI18n } from "@/i18n"; import { getServerI18n } from "@/i18n";
import { I18nProvider } from "@/i18n"; import { I18nProvider } from "@/i18n";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next"; import { Metadata } from "next";
import { Suspense } from "react"; import { Suspense } from "react";


@@ -11,10 +9,6 @@ export const metadata: Metadata = {
}; };


const SearchView: React.FC = async () => { const SearchView: React.FC = async () => {
const { t } = await getServerI18n("inventory");

PreloadList();

return ( return (
<> <>
<I18nProvider namespaces={["inventory", "common"]}> <I18nProvider namespaces={["inventory", "common"]}>


+ 1
- 0
src/app/api/bom/client.ts Zobrazit soubor

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

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


+ 18
- 0
src/app/api/bom/index.ts Zobrazit soubor

@@ -11,6 +11,24 @@ export interface BomCombo {
description: string; 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 { export interface BomFormatFileGroup {
fileName: string; fileName: string;
problems: string[]; problems: string[];


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

@@ -8,6 +8,11 @@ import { QcItemResult } from "../settings/qcItem";
import { RecordsRes } from "../utils"; import { RecordsRes } from "../utils";
import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; import { convertObjToURLSearchParams } from "@/app/utils/commonUtil";
import { InventoryLotLineResult, InventoryResult } from "."; import { InventoryLotLineResult, InventoryResult } from ".";

export type InventoryListRow = Pick<
InventoryResult,
"itemId" | "itemCode" | "itemName" | "itemType"
>;
// import { BASE_API_URL } from "@/config/api"; // import { BASE_API_URL } from "@/config/api";


export interface LotLineInfo { export interface LotLineInfo {
@@ -22,6 +27,15 @@ export interface LotLineInfo {


export interface SearchInventoryLotLine extends Pageable { export interface SearchInventoryLotLine extends Pageable {
itemId: number; 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 { export interface SearchInventory extends Pageable {
@@ -114,38 +128,69 @@ export const fetchLotDetail = cache(async (stockInLineId: number) => {
export const updateInventoryLotLineStatus = async (data: { export const updateInventoryLotLineStatus = async (data: {
inventoryLotLineId: number; inventoryLotLineId: number;
status: string; 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: { export const updateInventoryLotLineQuantities = async (data: {
inventoryLotLineId: number; inventoryLotLineId: number;
qty: number; qty: number;


+ 29
- 0
src/app/api/masterDataIssues/client.ts Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -2,31 +2,11 @@


import { BASE_API_URL } from "@/config/api"; import { BASE_API_URL } from "@/config/api";
import { serverFetchJson } from "@/app/utils/fetchUtil"; import { serverFetchJson } from "@/app/utils/fetchUtil";
import { revalidateTag } from "next/cache";
import { cache } from "react"; import { cache } from "react";
import type { MessageResponse } from "@/app/api/shop/actions"; 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 { export interface ExpiryItemResult {
id: number; id: number;
itemId: number; itemId: number;
@@ -39,40 +19,46 @@ export interface ExpiryItemResult {
remainingQty: number; 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; itemCode?: string;
itemName?: string; itemName?: string;
lotNo?: string;
pageNum?: number;
pageSize?: number;
} }


export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => { 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); if (filters?.itemName) params.set("itemName", filters.itemName);
const queryString = params.toString(); const queryString = params.toString();
const url = `${BASE_API_URL}/pickExecution/issues/expiryItem${queryString ? `?${queryString}` : ""}`; 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", 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 Zobrazit soubor

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


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


+ 1
- 1
src/components/ImportBom/ImportBomResultForm.tsx Zobrazit soubor

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


+ 0
- 1
src/components/JoSearch/JoSearch.tsx Zobrazit soubor

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


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

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




+ 0
- 1
src/components/JoWorkbench/JoWorkbenchSearch.tsx Zobrazit soubor

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


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

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




+ 109
- 0
src/components/MasterDataIssues/MasterDataIssueDetailDialog.tsx Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

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

+ 49
- 0
src/components/NavigationContent/MasterDataIssuesNavBadge.tsx Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -49,6 +49,7 @@ import { AUTH } from "../../authorities";
import { isMonitoringEnabled } from "@/config/monitoring"; import { isMonitoringEnabled } from "@/config/monitoring";
import PurchaseStockInNavAlerts from "./PurchaseStockInNavAlerts"; import PurchaseStockInNavAlerts from "./PurchaseStockInNavAlerts";
import JobOrderFgStockInNavAlerts from "./JobOrderFgStockInNavAlerts"; import JobOrderFgStockInNavAlerts from "./JobOrderFgStockInNavAlerts";
import MasterDataIssuesNavBadge from "./MasterDataIssuesNavBadge";


interface NavigationItem { interface NavigationItem {
icon: React.ReactNode; icon: React.ReactNode;
@@ -363,6 +364,12 @@ const NavigationContent: React.FC = () => {
label: "BOM Weighting Score List", label: "BOM Weighting Score List",
path: "/settings/bomWeighting", path: "/settings/bomWeighting",
}, },
{
icon: <ReportProblem />,
label: "masterDataIssue_nav",
path: "/settings/masterDataIssues",
requiredAbility: [AUTH.ADMIN],
},
{ {
icon: <QrCodeIcon />, icon: <QrCodeIcon />,
label: "QR Code Handle", label: "QR Code Handle",
@@ -394,6 +401,7 @@ const NavigationContent: React.FC = () => {
abilitySet.has(AUTH.TESTING) || abilitySet.has(AUTH.ADMIN) || abilitySet.has(AUTH.STOCK); abilitySet.has(AUTH.TESTING) || abilitySet.has(AUTH.ADMIN) || abilitySet.has(AUTH.STOCK);
/** 工單 QC/上架紅點:仍僅 TESTING */ /** 工單 QC/上架紅點:仍僅 TESTING */
const canSeeJoFgAlerts = abilitySet.has(AUTH.TESTING); const canSeeJoFgAlerts = abilitySet.has(AUTH.TESTING);
const canSeeMasterDataIssueBadge = abilitySet.has(AUTH.ADMIN);


const [openItems, setOpenItems] = React.useState<string[]>([]); 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. */ /** 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> </Box>
<PurchaseStockInNavAlerts enabled={canSeePoAlerts} /> <PurchaseStockInNavAlerts enabled={canSeePoAlerts} />
</Box> </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" ? ( ) : child.path === "/productionProcess" ? (
<Box <Box
key={`${child.label}-${child.path}`} key={`${child.label}-${child.path}`}


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

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


+ 515
- 0
src/components/StockIssue/BadItemHandleForm.tsx Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -1,466 +1,37 @@
"use client"; "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 { 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 ( return (
<Box> <Box>
<Tabs value={tab} onChange={handleTabChange} sx={{ mb: 2 }}> <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> </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> </Box>
); );
}; };


export default SearchPage;
export default SearchPage;

+ 72
- 0
src/components/StockIssue/StockIssueInventoryTable.tsx Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -1,17 +1,13 @@
// Re-export types and functions from the main actions file
export { export {
type StockIssueResult,
type ExpiryItemResult, type ExpiryItemResult,
type StockIssueLists,
fetchList,
fetchMissItemList,
fetchBadItemList,
type ExpiryItemFilter,
type HandleBadItemRequest,
type StockIssueHandleRecord,
type SearchStockIssueRecordParams,
fetchExpiryItemList, fetchExpiryItemList,
submitMissItem,
submitBadItem,
handleBadItem,
fetchBadItemRecords,
fetchExpiryItemRecords,
submitExpiryItem, submitExpiryItem,
batchSubmitMissItem,
batchSubmitBadItem,
batchSubmitExpiryItem, batchSubmitExpiryItem,
PreloadList,
} from "@/app/api/stockIssue/actions"; } from "@/app/api/stockIssue/actions";

+ 2
- 5
src/components/StockIssue/index.tsx Zobrazit soubor

@@ -1,15 +1,12 @@
import GeneralLoading from "../General/GeneralLoading"; import GeneralLoading from "../General/GeneralLoading";
import SearchPage from "./SearchPage"; import SearchPage from "./SearchPage";
import { fetchList } from "@/app/api/stockIssue/actions";


interface SubComponents { interface SubComponents {
Loading: typeof GeneralLoading; 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; Wrapper.Loading = GeneralLoading;


+ 56
- 0
src/hooks/useMasterDataIssueNavCount.ts Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -288,6 +288,10 @@
"Batch Save Inputted": "批量保存已輸入", "Batch Save Inputted": "批量保存已輸入",
"Batch Save Completed": "批量保存完成", "Batch Save Completed": "批量保存完成",
"Bad Item Handle": "不良品處理", "Bad Item Handle": "不良品處理",
"Search to load lot lines": "請按搜索以載入批號",
"No changes to submit": "沒有可提交的變更",
"No record found": "沒有記錄",
"Rows per page": "每頁行數",
"Bad Item Records": "不良品處理紀錄", "Bad Item Records": "不良品處理紀錄",
"Expiry Item Handle": "過期品處理", "Expiry Item Handle": "過期品處理",
"Expiry Item Records": "過期品處理紀錄", "Expiry Item Records": "過期品處理紀錄",


+ 2
- 2
src/i18n/zh/masterDataIssue.json Zobrazit soubor

@@ -69,8 +69,8 @@
"masterDataIssue_bomMore": " 等 {{count}} 個", "masterDataIssue_bomMore": " 等 {{count}} 個",


"masterDataIssue_col_problem": "問題", "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_modifiedAt": "修改時間",
"masterDataIssue_unit_active": "使用中", "masterDataIssue_unit_active": "使用中",
"masterDataIssue_unit_inactive": "已停用", "masterDataIssue_unit_inactive": "已停用",


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