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