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