| @@ -687,7 +687,7 @@ export default function ProductionSchedulePage() { | |||
| className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600" | |||
| > | |||
| <Search sx={{ fontSize: 16 }} /> | |||
| 搜尋 | |||
| 搜索 | |||
| </button> | |||
| </div> | |||
| @@ -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 { PreloadList } from "@/components/StockIssue/action"; | |||
| import { getServerI18n } from "@/i18n"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| import { Stack, Typography } from "@mui/material"; | |||
| import { Metadata } from "next"; | |||
| import { Suspense } from "react"; | |||
| @@ -11,10 +9,6 @@ export const metadata: Metadata = { | |||
| }; | |||
| const SearchView: React.FC = async () => { | |||
| const { t } = await getServerI18n("inventory"); | |||
| PreloadList(); | |||
| return ( | |||
| <> | |||
| <I18nProvider namespaces={["inventory", "common"]}> | |||
| @@ -81,6 +81,7 @@ export async function fetchBomComboClient(): Promise<BomCombo[]> { | |||
| ); | |||
| return response.data; | |||
| } | |||
| export async function fetchBomDetailClient(id: number): Promise<BomDetailResponse> { | |||
| const response = await axiosInstance.get<BomDetailResponse>( | |||
| @@ -11,6 +11,24 @@ export interface BomCombo { | |||
| description: string; | |||
| } | |||
| export type BomComboIssueCode = | |||
| | "MISSING_BOM_CODE" | |||
| | "MISSING_BOM_NAME" | |||
| | "MISSING_ITEM" | |||
| | "MISSING_SALES_UOM" | |||
| | "MISSING_UOM_CONVERSION" | |||
| | "MISSING_STOCK_UOM" | |||
| | "MISSING_STOCK_UOM_CONVERSION"; | |||
| export interface BomComboIssue { | |||
| bomId: number; | |||
| bomCode: string | null; | |||
| bomName: string | null; | |||
| itemId: number | null; | |||
| description: string | null; | |||
| issueCode: BomComboIssueCode; | |||
| } | |||
| export interface BomFormatFileGroup { | |||
| fileName: string; | |||
| problems: string[]; | |||
| @@ -8,6 +8,11 @@ import { QcItemResult } from "../settings/qcItem"; | |||
| import { RecordsRes } from "../utils"; | |||
| import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | |||
| import { InventoryLotLineResult, InventoryResult } from "."; | |||
| export type InventoryListRow = Pick< | |||
| InventoryResult, | |||
| "itemId" | "itemCode" | "itemName" | "itemType" | |||
| >; | |||
| // import { BASE_API_URL } from "@/config/api"; | |||
| export interface LotLineInfo { | |||
| @@ -22,6 +27,15 @@ export interface LotLineInfo { | |||
| export interface SearchInventoryLotLine extends Pageable { | |||
| itemId: number; | |||
| /** Non-expired lots with in > out; includes available and unavailable. */ | |||
| stockIssueBadItem?: boolean; | |||
| } | |||
| export interface SearchStockIssueBadItemLotLine extends Pageable { | |||
| itemCode?: string; | |||
| itemName?: string; | |||
| itemType?: string; | |||
| lotNo?: string; | |||
| } | |||
| export interface SearchInventory extends Pageable { | |||
| @@ -114,38 +128,69 @@ export const fetchLotDetail = cache(async (stockInLineId: number) => { | |||
| export const updateInventoryLotLineStatus = async (data: { | |||
| inventoryLotLineId: number; | |||
| status: string; | |||
| //qty: number; | |||
| //operation?: string; | |||
| }) => { | |||
| // return await serverFetchJson(`${BASE_API_URL}/inventoryLotLine/updateStatus`, { | |||
| // next: { tags: ["inventoryLotLine"] }, | |||
| // method: 'POST', | |||
| // body: JSON.stringify(data) | |||
| // }); | |||
| return await serverFetchJson<PostInventoryLotLineResponse<InventoryLotLineResult>>(`${BASE_API_URL}/inventoryLotLine/updateStatus`, { | |||
| next: { tags: ["inventoryLotLine"] }, | |||
| method: 'POST', | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| // revalidateTag("po"); | |||
| const res = await serverFetchJson<PostInventoryLotLineResponse<InventoryLotLineResult>>( | |||
| `${BASE_API_URL}/inventoryLotLine/updateStatus`, | |||
| { | |||
| next: { tags: ["inventoryLotLine"] }, | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| if (res?.code === "SUCCESS") { | |||
| revalidateTag("inventoryLotLines"); | |||
| revalidateTag("inventories"); | |||
| } | |||
| return res; | |||
| }; | |||
| export const fetchInventories = cache(async (data: SearchInventory) => { | |||
| const queryStr = convertObjToURLSearchParams(data) | |||
| /** Full inventory list (for item-type filter options). Not wrapped in cache(). */ | |||
| export async function fetchInventoryListFresh() { | |||
| return serverFetchJson<InventoryListRow[]>(`${BASE_API_URL}/inventory/list`, { | |||
| next: { tags: ["inventories"] }, | |||
| }); | |||
| } | |||
| async function fetchInventoriesImpl(data: SearchInventory) { | |||
| const queryStr = convertObjToURLSearchParams(data); | |||
| return serverFetchJson<InventoryResultByPage>( | |||
| `${BASE_API_URL}/inventory/getRecordByPage?${queryStr}`, | |||
| { next: { tags: ["inventories"] } }, | |||
| ); | |||
| } | |||
| return serverFetchJson<InventoryResultByPage>(`${BASE_API_URL}/inventory/getRecordByPage?${queryStr}`, | |||
| { next: { tags: ["inventories"] } } | |||
| ) | |||
| }) | |||
| export const fetchInventories = cache(fetchInventoriesImpl); | |||
| /** Bypass React cache() after mutations so lists show fresh qty. */ | |||
| export async function fetchInventoriesFresh(data: SearchInventory) { | |||
| return fetchInventoriesImpl(data); | |||
| } | |||
| export const fetchInventoryLotLines = cache(async (data: SearchInventoryLotLine) => { | |||
| const queryStr = convertObjToURLSearchParams(data) | |||
| return serverFetchJson<InventoryLotLineResultByPage>(`${BASE_API_URL}/inventoryLotLine/getRecordByPage?${queryStr}`, { | |||
| next: { tags: ["inventoryLotLines"] }, | |||
| }); | |||
| }); | |||
| async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) { | |||
| const queryStr = convertObjToURLSearchParams(data); | |||
| return serverFetchJson<InventoryLotLineResultByPage>( | |||
| `${BASE_API_URL}/inventoryLotLine/getRecordByPage?${queryStr}`, | |||
| { next: { tags: ["inventoryLotLines"] } }, | |||
| ); | |||
| } | |||
| export const fetchInventoryLotLines = cache(fetchInventoryLotLinesImpl); | |||
| /** Bypass React cache() after mutations so lists show fresh qty. */ | |||
| export async function fetchInventoryLotLinesFresh(data: SearchInventoryLotLine) { | |||
| return fetchInventoryLotLinesImpl(data); | |||
| } | |||
| export async function fetchStockIssueBadItemLotLinesFresh( | |||
| data: SearchStockIssueBadItemLotLine, | |||
| ) { | |||
| const queryStr = convertObjToURLSearchParams(data); | |||
| return serverFetchJson<InventoryLotLineResultByPage>( | |||
| `${BASE_API_URL}/inventoryLotLine/stockIssueBadItemSearch?${queryStr}`, | |||
| { next: { tags: ["inventoryLotLines"] } }, | |||
| ); | |||
| } | |||
| export const updateInventoryLotLineQuantities = async (data: { | |||
| inventoryLotLineId: number; | |||
| qty: number; | |||
| @@ -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 { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { cache } from "react"; | |||
| import type { MessageResponse } from "@/app/api/shop/actions"; | |||
| import { RecordsRes } from "@/app/api/utils"; | |||
| // Export types/interfaces (these are safe to import in client components) | |||
| export interface StockIssueResult { | |||
| id: number; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemDescription: string; | |||
| lotId: number; | |||
| lotNo: string; | |||
| storeLocation: string | null; | |||
| requiredQty: number | null; | |||
| actualPickQty: number | null; | |||
| missQty: number; | |||
| badItemQty: number; | |||
| bookQty: number; | |||
| issueQty: number; | |||
| issueRemark: string | null; | |||
| pickerName: string | null; | |||
| handleStatus: string; | |||
| handleDate: string | null; | |||
| handledBy: number | null; | |||
| uomDesc: string | null; | |||
| } | |||
| export interface ExpiryItemResult { | |||
| id: number; | |||
| itemId: number; | |||
| @@ -39,40 +19,46 @@ export interface ExpiryItemResult { | |||
| remainingQty: number; | |||
| } | |||
| export interface StockIssueLists { | |||
| missItems: StockIssueResult[]; | |||
| badItems: StockIssueResult[]; | |||
| expiryItems: ExpiryItemResult[]; | |||
| export interface ExpiryItemFilter { | |||
| expiryDate?: string; | |||
| itemCode?: string; | |||
| itemName?: string; | |||
| } | |||
| // Server actions (these work from both server and client components) | |||
| export const PreloadList = () => { | |||
| fetchList(); | |||
| }; | |||
| export const fetchMissItemList = cache(async (issueCategory: string = "lot_issue") => { | |||
| return serverFetchJson<StockIssueResult[]>( | |||
| `${BASE_API_URL}/pickExecution/issues/missItem?issueCategory=${issueCategory}`, | |||
| { | |||
| next: { tags: ["Miss Item List"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchBadItemList = cache(async (issueCategory: string = "lot_issue") => { | |||
| return serverFetchJson<StockIssueResult[]>( | |||
| `${BASE_API_URL}/pickExecution/issues/badItem?issueCategory=${issueCategory}`, | |||
| { | |||
| next: { tags: ["Bad Item List"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| export interface HandleBadItemRequest { | |||
| inventoryLotLineId: number; | |||
| qty: number; | |||
| remarks?: string; | |||
| handler?: number; | |||
| } | |||
| export interface StockIssueHandleRecord { | |||
| id: number; | |||
| stockOutLineId: number | null; | |||
| stockOutId: number | null; | |||
| handledAt: string | null; | |||
| itemId: number | null; | |||
| itemCode: string | null; | |||
| itemName: string | null; | |||
| lotNo: string | null; | |||
| storeLocation: string | null; | |||
| expiryDate: string | null; | |||
| qty: number; | |||
| uomDesc: string | null; | |||
| handlerId: number | null; | |||
| handlerName: string | null; | |||
| remarks: string | null; | |||
| type: string | null; | |||
| } | |||
| export interface ExpiryItemFilter { | |||
| expiryDate?: string; | |||
| export interface SearchStockIssueRecordParams { | |||
| startDate?: string; | |||
| endDate?: string; | |||
| itemCode?: string; | |||
| itemName?: string; | |||
| lotNo?: string; | |||
| pageNum?: number; | |||
| pageSize?: number; | |||
| } | |||
| export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => { | |||
| @@ -82,160 +68,73 @@ export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => { | |||
| if (filters?.itemName) params.set("itemName", filters.itemName); | |||
| const queryString = params.toString(); | |||
| const url = `${BASE_API_URL}/pickExecution/issues/expiryItem${queryString ? `?${queryString}` : ""}`; | |||
| return serverFetchJson<ExpiryItemResult[]>( | |||
| url, | |||
| return serverFetchJson<ExpiryItemResult[]>(url, { | |||
| next: { tags: ["Expiry Item List"] }, | |||
| }); | |||
| }); | |||
| export async function handleBadItem(request: HandleBadItemRequest) { | |||
| const res = await serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/stockIssue/handleBadItem`, | |||
| { | |||
| next: { tags: ["Expiry Item List"] }, | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(request), | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchList = cache(async (issueCategory: string = "lot_issue"): Promise<StockIssueLists> => { | |||
| const [missItems, badItems, expiryItems] = await Promise.all([ | |||
| fetchMissItemList(issueCategory), | |||
| fetchBadItemList(issueCategory), | |||
| fetchExpiryItemList(), | |||
| ]); | |||
| if (!res?.code || res.code === "SUCCESS") { | |||
| revalidateTag("inventoryLotLines"); | |||
| revalidateTag("inventories"); | |||
| } | |||
| return res; | |||
| } | |||
| return { | |||
| missItems, | |||
| badItems, | |||
| expiryItems, | |||
| }; | |||
| }); | |||
| export async function fetchBadItemRecords(params: SearchStockIssueRecordParams) { | |||
| const qs = new URLSearchParams(); | |||
| if (params.startDate) qs.set("startDate", params.startDate); | |||
| if (params.endDate) qs.set("endDate", params.endDate); | |||
| if (params.itemCode) qs.set("itemCode", params.itemCode); | |||
| if (params.itemName) qs.set("itemName", params.itemName); | |||
| if (params.lotNo) qs.set("lotNo", params.lotNo); | |||
| qs.set("pageNum", String(params.pageNum ?? 0)); | |||
| qs.set("pageSize", String(params.pageSize ?? 20)); | |||
| return serverFetchJson<RecordsRes<StockIssueHandleRecord[]>>( | |||
| `${BASE_API_URL}/stockIssue/badItemRecords?${qs.toString()}`, | |||
| ); | |||
| } | |||
| export async function submitMissItem(issueId: number, handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitMissItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ issueId, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function batchSubmitMissItem(issueIds: number[], handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/batchSubmitMissItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ issueIds, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function submitBadItem(issueId: number, handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitBadItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ issueId, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function batchSubmitBadItem(issueIds: number[], handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/batchSubmitBadItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ issueIds, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function submitExpiryItem(lotLineId: number, handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitExpiryItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ lotLineId, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function batchSubmitExpiryItem(lotLineIds: number[], handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/batchSubmitExpiryItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ lotLineIds, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function fetchExpiryItemRecords(params: SearchStockIssueRecordParams) { | |||
| const qs = new URLSearchParams(); | |||
| if (params.startDate) qs.set("startDate", params.startDate); | |||
| if (params.endDate) qs.set("endDate", params.endDate); | |||
| if (params.itemCode) qs.set("itemCode", params.itemCode); | |||
| if (params.itemName) qs.set("itemName", params.itemName); | |||
| if (params.lotNo) qs.set("lotNo", params.lotNo); | |||
| qs.set("pageNum", String(params.pageNum ?? 0)); | |||
| qs.set("pageSize", String(params.pageSize ?? 20)); | |||
| return serverFetchJson<RecordsRes<StockIssueHandleRecord[]>>( | |||
| `${BASE_API_URL}/stockIssue/expiryItemRecords?${qs.toString()}`, | |||
| ); | |||
| } | |||
| export async function submitExpiryItem(lotLineId: number, handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitExpiryItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ lotLineId, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export interface LotIssueDetailResponse { | |||
| lotId: number | null; | |||
| lotNo: string | null; | |||
| itemId: number; | |||
| itemCode: string | null; | |||
| itemDescription: string | null; | |||
| storeLocation: string | null; | |||
| issues: IssueDetailItem[]; | |||
| bookQty: number; | |||
| uomDesc: string | null; | |||
| } | |||
| export interface IssueDetailItem { | |||
| issueId: number; | |||
| pickerName: string | null; | |||
| missQty: number | null; | |||
| issueQty: number | null; | |||
| pickOrderCode: string; | |||
| doOrderCode: string | null; | |||
| joOrderCode: string | null; | |||
| issueRemark: string | null; | |||
| } | |||
| export async function getLotIssueDetails( | |||
| lotId: number, | |||
| itemId: number, | |||
| issueType: "miss" | "bad" | |||
| ) { | |||
| return serverFetchJson<LotIssueDetailResponse>( | |||
| `${BASE_API_URL}/pickExecution/lotIssueDetails?lotId=${lotId}&itemId=${itemId}&issueType=${issueType}`, | |||
| { | |||
| method: "GET", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| } | |||
| ); | |||
| } | |||
| export async function submitIssueWithQty( | |||
| lotId: number, | |||
| itemId: number, | |||
| issueType: "miss" | "bad", | |||
| submitQty: number, | |||
| handler: number | |||
| ){return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitIssueWithQty`, | |||
| export async function batchSubmitExpiryItem(lotLineIds: number[], handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/batchSubmitExpiryItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ lotId, itemId, issueType, submitQty, handler }), | |||
| } | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ lotLineIds, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| } | |||
| @@ -79,7 +79,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||
| //console.log("🔍 DoSearch - session:", session); | |||
| //console.log("🔍 DoSearch - currentUserId:", currentUserId); | |||
| const [searchTimeout, setSearchTimeout] = useState<NodeJS.Timeout | null>(null); | |||
| /** 使用者明確取消勾選的送貨單 id;未在此集合中的搜尋結果視為「已選」以便跨頁記憶 */ | |||
| /** 使用者明確取消勾選的送貨單 id;未在此集合中的搜索結果視為「已選」以便跨頁記憶 */ | |||
| const [excludedRowIds, setExcludedRowIds] = useState<number[]>([]); | |||
| const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]); | |||
| @@ -716,7 +716,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||
| setSearchBoxResetKey((prev) => prev + 1); | |||
| setPagingController((prev) => ({ ...prev, pageNum: 1 })); | |||
| setExcludedRowIds([]); | |||
| // 切換 tab 僅重置搜尋條件與結果;由使用者再次按「搜尋」後才查詢。 | |||
| // 切換 tab 僅重置搜索條件與結果;由使用者再次按「搜索」後才查詢。 | |||
| setSearchAllDos([]); | |||
| setTotalCount(0); | |||
| setHasSearched(false); | |||
| @@ -156,7 +156,7 @@ type Props = { | |||
| </Typography> | |||
| <TextField | |||
| size="small" | |||
| placeholder="搜尋檔名" | |||
| placeholder="搜索檔名" | |||
| value={search} | |||
| onChange={(e) => setSearch(e.target.value)} | |||
| InputProps={{ | |||
| @@ -666,7 +666,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| const { data: session } = useSession(); | |||
| const sessionToken = session as SessionWithTokens | null; | |||
| const [openModal, setOpenModal] = useState<boolean>(false); | |||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | |||
| @@ -667,7 +667,6 @@ const JoWorkbenchSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCo | |||
| const { data: session } = useSession(); | |||
| const sessionToken = session as SessionWithTokens | null; | |||
| const [openModal, setOpenModal] = useState<boolean>(false); | |||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | |||
| @@ -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 PurchaseStockInNavAlerts from "./PurchaseStockInNavAlerts"; | |||
| import JobOrderFgStockInNavAlerts from "./JobOrderFgStockInNavAlerts"; | |||
| import MasterDataIssuesNavBadge from "./MasterDataIssuesNavBadge"; | |||
| interface NavigationItem { | |||
| icon: React.ReactNode; | |||
| @@ -363,6 +364,12 @@ const NavigationContent: React.FC = () => { | |||
| label: "BOM Weighting Score List", | |||
| path: "/settings/bomWeighting", | |||
| }, | |||
| { | |||
| icon: <ReportProblem />, | |||
| label: "masterDataIssue_nav", | |||
| path: "/settings/masterDataIssues", | |||
| requiredAbility: [AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <QrCodeIcon />, | |||
| label: "QR Code Handle", | |||
| @@ -394,6 +401,7 @@ const NavigationContent: React.FC = () => { | |||
| abilitySet.has(AUTH.TESTING) || abilitySet.has(AUTH.ADMIN) || abilitySet.has(AUTH.STOCK); | |||
| /** 工單 QC/上架紅點:仍僅 TESTING */ | |||
| const canSeeJoFgAlerts = abilitySet.has(AUTH.TESTING); | |||
| const canSeeMasterDataIssueBadge = abilitySet.has(AUTH.ADMIN); | |||
| const [openItems, setOpenItems] = React.useState<string[]>([]); | |||
| /** Keep parent sections expanded on deep links (e.g. /po/edit from nav red spot) so alerts stay visible. */ | |||
| @@ -556,6 +564,44 @@ const NavigationContent: React.FC = () => { | |||
| </Box> | |||
| <PurchaseStockInNavAlerts enabled={canSeePoAlerts} /> | |||
| </Box> | |||
| ) : child.path === "/settings/masterDataIssues" ? ( | |||
| <Box | |||
| key={`${child.label}-${child.path}`} | |||
| component={Link} | |||
| href={child.path} | |||
| sx={{ | |||
| display: "block", | |||
| mx: 1, | |||
| borderRadius: 1, | |||
| textDecoration: "none", | |||
| color: "inherit", | |||
| "&:hover": { bgcolor: "action.hover" }, | |||
| }} | |||
| > | |||
| <ListItemButton | |||
| selected={child.path === selectedLeafPath} | |||
| sx={{ | |||
| py: 1, | |||
| pr: 1.5, | |||
| display: "flex", | |||
| alignItems: "center", | |||
| gap: 0.5, | |||
| "&.Mui-selected .MuiListItemIcon-root": { color: "primary.main" }, | |||
| }} | |||
| > | |||
| <ListItemIcon sx={{ minWidth: 40 }}>{child.icon}</ListItemIcon> | |||
| <ListItemText | |||
| primary={t(child.label)} | |||
| sx={{ flex: 1, minWidth: 0, my: 0 }} | |||
| primaryTypographyProps={{ | |||
| fontWeight: | |||
| child.path === selectedLeafPath ? 600 : 500, | |||
| fontSize: "0.875rem", | |||
| }} | |||
| /> | |||
| <MasterDataIssuesNavBadge enabled={canSeeMasterDataIssueBadge} /> | |||
| </ListItemButton> | |||
| </Box> | |||
| ) : child.path === "/productionProcess" ? ( | |||
| <Box | |||
| key={`${child.label}-${child.path}`} | |||
| @@ -232,7 +232,7 @@ const PoSearchList: React.FC<{ | |||
| ) : ( | |||
| <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}> | |||
| {searchTerm.trim() | |||
| ? t("No purchase orders match your search", { defaultValue: "沒有符合搜尋的採購單" }) | |||
| ? t("No purchase orders match your search", { defaultValue: "沒有符合搜索的採購單" }) | |||
| : t("No purchase orders to show", { defaultValue: "沒有可顯示的採購單" })} | |||
| </Typography> | |||
| )} | |||
| @@ -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"; | |||
| import dayjs from "dayjs"; | |||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { useCallback, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults/index"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { | |||
| batchSubmitBadItem, | |||
| batchSubmitExpiryItem, | |||
| batchSubmitMissItem, | |||
| ExpiryItemResult, | |||
| fetchExpiryItemList, | |||
| StockIssueLists, | |||
| StockIssueResult, | |||
| submitBadItem, | |||
| submitExpiryItem, | |||
| submitMissItem, | |||
| } from "@/app/api/stockIssue/actions"; | |||
| import { Box, Button, Tab, Tabs } from "@mui/material"; | |||
| import { useSession } from "next-auth/react"; | |||
| import SubmitIssueForm from "./SubmitIssueForm"; | |||
| interface Props { | |||
| dataList: StockIssueLists; | |||
| } | |||
| import { Box, Tab, Tabs } from "@mui/material"; | |||
| import { useCallback, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import BadItemHandleTab from "./BadItemHandleTab"; | |||
| import StockIssueRecordTab from "./StockIssueRecordTab"; | |||
| import ExpiryHandleTab from "./ExpiryHandleTab"; | |||
| type SearchQuery = { | |||
| lotNo: string; | |||
| itemCode: string; | |||
| itemName: string; | |||
| expiryDate: string; | |||
| }; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| type TabValue = "badHandle" | "badRecord" | "expiryHandle" | "expiryRecord"; | |||
| const SearchPage: React.FC<Props> = ({ dataList }) => { | |||
| const BATCH_CHUNK_SIZE = 20; | |||
| const SearchPage: React.FC = () => { | |||
| const { t } = useTranslation("inventory"); | |||
| const [tab, setTab] = useState<"miss" | "bad" | "expiry">("miss"); | |||
| const [search, setSearch] = useState<SearchQuery>({ | |||
| lotNo: "", | |||
| itemCode: "", | |||
| itemName: "", | |||
| expiryDate: "", | |||
| }); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const [formOpen, setFormOpen] = useState(false); | |||
| const [selectedLotId, setSelectedLotId] = useState<number | null>(null); | |||
| const [selectedItemId, setSelectedItemId] = useState<number>(0); | |||
| const [selectedIssueType, setSelectedIssueType] = useState<"miss" | "bad">("miss"); | |||
| const [missItems, setMissItems] = useState<StockIssueResult[]>( | |||
| dataList.missItems, | |||
| ); | |||
| const [badItems, setBadItems] = useState<StockIssueResult[]>( | |||
| dataList.badItems, | |||
| ); | |||
| const [expiryItems, setExpiryItems] = useState<ExpiryItemResult[]>( | |||
| dataList.expiryItems, | |||
| ); | |||
| const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]); | |||
| const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set()); | |||
| const [batchSubmitting, setBatchSubmitting] = useState(false); | |||
| const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null); | |||
| const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => { | |||
| if (tab === "expiry") { | |||
| return [ | |||
| { | |||
| label: t("Item Code"), | |||
| paramName: "itemCode", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Item"), | |||
| paramName: "itemName", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Expiry Date"), | |||
| paramName: "expiryDate", | |||
| type: "date", | |||
| }, | |||
| ]; | |||
| } | |||
| return [ | |||
| { | |||
| label: t("Lot No."), | |||
| paramName: "lotNo", | |||
| type: "text", | |||
| }, | |||
| ]; | |||
| }, | |||
| [t, tab], | |||
| ); | |||
| const filterBySearch = useCallback( | |||
| <T extends { lotNo: string | null }>(items: T[]): T[] => { | |||
| if (!search.lotNo) return items; | |||
| const keyword = search.lotNo.toLowerCase(); | |||
| return items.filter( | |||
| (i) => i.lotNo && i.lotNo.toLowerCase().includes(keyword), | |||
| ); | |||
| }, | |||
| [search.lotNo], | |||
| ); | |||
| const handleSubmitSingle = useCallback( | |||
| async (id: number) => { | |||
| if (!currentUserId) { | |||
| alert(t("User ID is required")); | |||
| return; | |||
| } | |||
| // Find the item to get lotId | |||
| let lotId: number | null = null; | |||
| let itemId = 0; | |||
| if (tab === "miss") { | |||
| const item = missItems.find((i) => i.id === id); | |||
| if (item) { | |||
| lotId = item.lotId; | |||
| itemId = item.itemId; | |||
| } | |||
| } else if (tab === "bad") { | |||
| const item = badItems.find((i) => i.id === id); | |||
| if (item) { | |||
| lotId = item.lotId; | |||
| itemId = item.itemId; | |||
| } | |||
| } else if (tab === "expiry") { | |||
| const item = expiryItems.find((i) => i.id === id); | |||
| if (!item) { | |||
| alert(t("Item not found")); | |||
| return; | |||
| } | |||
| try { | |||
| // 如果想要 loading 效果,可以这里把 id 加进 submittingIds | |||
| await submitExpiryItem(item.id, currentUserId); | |||
| // 成功后,从列表移除这一行,或直接 reload | |||
| // setExpiryItems(prev => prev.filter(i => i.id !== id)); | |||
| window.location.reload(); | |||
| } catch (e) { | |||
| console.error("submitExpiryItem failed:", e); | |||
| const errMsg = e instanceof Error ? e.message : t("Unknown error"); | |||
| alert(`${t("Failed to submit expiry item")}: ${errMsg}`); | |||
| } | |||
| return; // 记得 return,避免再走到下面的 lotId/itemId 分支 | |||
| } | |||
| if (lotId && itemId) { | |||
| setSelectedLotId(lotId); | |||
| setSelectedItemId(itemId); | |||
| setSelectedIssueType(tab === "miss" ? "miss" : "bad"); | |||
| setFormOpen(true); | |||
| } else { | |||
| alert(t("Item not found")); | |||
| } | |||
| }, | |||
| [tab, currentUserId, t, missItems, badItems, expiryItems] | |||
| ); | |||
| const handleFormSuccess = useCallback(() => { | |||
| // Refresh the lists | |||
| if (tab === "miss") { | |||
| // Reload miss items - you may need to add a refresh function | |||
| window.location.reload(); // Or use a proper refresh mechanism | |||
| } else if (tab === "bad") { | |||
| // Reload bad items | |||
| window.location.reload(); // Or use a proper refresh mechanism | |||
| } | |||
| }, [tab]); | |||
| const handleSubmitSelected = useCallback(async () => { | |||
| if (!currentUserId) return; | |||
| // Get all IDs from the current tab's filtered items | |||
| let allIds: number[] = []; | |||
| if (tab === "miss") { | |||
| const items = filterBySearch(missItems); | |||
| allIds = items.map((item) => item.id); | |||
| } else if (tab === "bad") { | |||
| const items = filterBySearch(badItems); | |||
| allIds = items.map((item) => item.id); | |||
| } else { | |||
| const items = filterBySearch(expiryItems); | |||
| allIds = items.map((item) => item.id); | |||
| } | |||
| if (allIds.length === 0) return; | |||
| setBatchSubmitting(true); | |||
| setBatchProgress({ done: 0, total: allIds.length }); | |||
| try { | |||
| for (let i = 0; i < allIds.length; i += BATCH_CHUNK_SIZE) { | |||
| const chunkIds = allIds.slice(i, i + BATCH_CHUNK_SIZE); | |||
| const [tab, setTab] = useState<TabValue>("badHandle"); | |||
| if (tab === "miss") { | |||
| await batchSubmitMissItem(chunkIds, currentUserId); | |||
| setMissItems((prev) => prev.filter((item) => !chunkIds.includes(item.id))); | |||
| } else if (tab === "bad") { | |||
| await batchSubmitBadItem(chunkIds, currentUserId); | |||
| setBadItems((prev) => prev.filter((item) => !chunkIds.includes(item.id))); | |||
| } else { | |||
| await batchSubmitExpiryItem(chunkIds, currentUserId); | |||
| setExpiryItems((prev) => prev.filter((item) => !chunkIds.includes(item.id))); | |||
| } | |||
| setBatchProgress({ | |||
| done: Math.min(i + chunkIds.length, allIds.length), | |||
| total: allIds.length, | |||
| }); | |||
| } | |||
| setSelectedIds([]); | |||
| } catch (error) { | |||
| console.error("Failed to submit selected items:", error); | |||
| const partialDone = batchProgress?.done ?? 0; | |||
| alert( | |||
| `${t("Failed to submit")}: ${error instanceof Error ? error.message : "Unknown error"} (${partialDone}/${allIds.length})` | |||
| ); | |||
| } finally { | |||
| setBatchSubmitting(false); | |||
| setBatchProgress(null); | |||
| } | |||
| }, [tab, currentUserId, missItems, badItems, expiryItems, filterBySearch, batchProgress, t]); | |||
| const missColumns = useMemo<Column<StockIssueResult>[]>( | |||
| () => [ | |||
| { name: "itemCode", label: t("Item Code") }, | |||
| { name: "itemDescription", label: t("Item") }, | |||
| { name: "lotNo", label: t("Lot No.") }, | |||
| { name: "storeLocation", label: t("Location") }, | |||
| { | |||
| name: "bookQty", | |||
| label: t("Book Qty"), | |||
| renderCell: (item) => ( | |||
| <>{item.bookQty?.toFixed(2) ?? "0"} {item.uomDesc ?? ""}</> | |||
| ), | |||
| }, | |||
| { name: "issueQty", label: t("Miss Qty") }, | |||
| { name: "uomDesc", label: t("UoM"), renderCell: (item) => ( | |||
| <>{item.uomDesc ?? ""}</> | |||
| ) }, | |||
| { | |||
| name: "id", | |||
| label: t("Action"), | |||
| renderCell: (item) => ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={() => handleSubmitSingle(item.id)} | |||
| disabled={submittingIds.has(item.id) || !currentUserId} | |||
| > | |||
| {submittingIds.has(item.id) ? t("Processing...") : t("Looked")} | |||
| </Button> | |||
| ), | |||
| }, | |||
| ], | |||
| [t, handleSubmitSingle, submittingIds, currentUserId], | |||
| ); | |||
| const badColumns = useMemo<Column<StockIssueResult>[]>( | |||
| () => [ | |||
| { name: "itemCode", label: t("Item Code") }, | |||
| { name: "itemDescription", label: t("Item") }, | |||
| { name: "lotNo", label: t("Lot No.") }, | |||
| { name: "storeLocation", label: t("Location") }, | |||
| { name: "issueQty", label: t("Defective Qty") }, | |||
| { name: "uomDesc", label: t("UoM"), renderCell: (item) => ( | |||
| <>{item.uomDesc ?? ""}</> | |||
| ) }, | |||
| { | |||
| name: "id", | |||
| label: t("Action"), | |||
| renderCell: (item) => ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={() => handleSubmitSingle(item.id)} | |||
| disabled={submittingIds.has(item.id) || !currentUserId} | |||
| > | |||
| {submittingIds.has(item.id) ? t("Disposing...") : t("Disposed")} | |||
| </Button> | |||
| ), | |||
| }, | |||
| ], | |||
| [t, handleSubmitSingle, submittingIds, currentUserId], | |||
| ); | |||
| const expiryColumns = useMemo<Column<ExpiryItemResult>[]>( | |||
| () => [ | |||
| { name: "itemCode", label: t("Item Code") }, | |||
| { name: "itemDescription", label: t("Item") }, | |||
| { name: "lotNo", label: t("Lot No.") }, | |||
| { name: "storeLocation", label: t("Location") }, | |||
| { | |||
| name: "expiryDate", | |||
| label: t("Expiry Date"), | |||
| renderCell: (item) => { | |||
| const raw = String(item.expiryDate ?? "").trim(); | |||
| if (!raw) return "—"; | |||
| let d; | |||
| if (raw.includes(",")) { | |||
| const parts = raw.split(",").map((s) => parseInt(s.trim(), 10)); | |||
| const [y, m, d_] = parts; | |||
| if (parts.length >= 3 && y != null && m != null && d_ != null && !Number.isNaN(y) && !Number.isNaN(m) && !Number.isNaN(d_)) { | |||
| d = dayjs(new Date(y, m - 1, d_)); | |||
| } else { | |||
| d = dayjs(""); | |||
| } | |||
| } else { | |||
| let normalized = raw; | |||
| if (raw.length === 7) { | |||
| normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7); | |||
| } else if (raw.length === 6) { | |||
| normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6); | |||
| } | |||
| d = dayjs(normalized, "YYYYMMDD", true); | |||
| } | |||
| return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : raw; | |||
| }, | |||
| }, | |||
| { name: "remainingQty", label: t("Remaining Qty") }, | |||
| { | |||
| name: "id", | |||
| label: t("Action"), | |||
| renderCell: (item) => ( | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={() => handleSubmitSingle(item.id)} | |||
| disabled={submittingIds.has(item.id) || !currentUserId} | |||
| > | |||
| {submittingIds.has(item.id) ? t("Disposing...") : t("Disposed")} | |||
| </Button> | |||
| ), | |||
| }, | |||
| ], | |||
| [t, handleSubmitSingle, submittingIds, currentUserId], | |||
| ); | |||
| const handleSearch = useCallback(async (query: Record<SearchParamNames, string>) => { | |||
| setSearch(query); | |||
| setPaging((prev) => ({ ...prev, pageNum: 1 })); | |||
| if (tab !== "expiry") { | |||
| return; | |||
| } | |||
| try { | |||
| const result = await fetchExpiryItemList({ | |||
| itemCode: query.itemCode?.trim() || undefined, | |||
| itemName: query.itemName?.trim() || undefined, | |||
| expiryDate: query.expiryDate || undefined, | |||
| }); | |||
| setExpiryItems(result); | |||
| setSelectedIds([]); | |||
| } catch (error) { | |||
| console.error("Failed to search expiry items:", error); | |||
| alert(t("Failed to load expiry items")); | |||
| } | |||
| }, [tab, t]); | |||
| const handleTabChange = useCallback( | |||
| (_: React.SyntheticEvent, value: string) => { | |||
| setTab(value as "miss" | "bad" | "expiry"); | |||
| setSelectedIds([]); | |||
| setPaging((prev) => ({ ...prev, pageNum: 1 })); // 新增:切 Tab 时回到第 1 页 | |||
| }, | |||
| [], | |||
| ); | |||
| const renderCurrentTab = () => { | |||
| if (tab === "miss") { | |||
| const items = filterBySearch(missItems); | |||
| return ( | |||
| <SearchResults<StockIssueResult> | |||
| items={items} | |||
| columns={missColumns} | |||
| pagingController={paging} | |||
| checkboxIds={selectedIds} | |||
| setPagingController={setPaging} | |||
| setCheckboxIds={setSelectedIds} | |||
| /> | |||
| ); | |||
| } | |||
| if (tab === "bad") { | |||
| const items = filterBySearch(badItems); | |||
| return ( | |||
| <SearchResults<StockIssueResult> | |||
| items={items} | |||
| columns={badColumns} | |||
| pagingController={paging} | |||
| setPagingController={setPaging} | |||
| checkboxIds={selectedIds} | |||
| setCheckboxIds={setSelectedIds} | |||
| /> | |||
| ); | |||
| } | |||
| const items = filterBySearch(expiryItems); | |||
| return ( | |||
| <SearchResults<ExpiryItemResult> | |||
| items={items} | |||
| columns={expiryColumns} | |||
| pagingController={paging} | |||
| setPagingController={setPaging} | |||
| checkboxIds={selectedIds} | |||
| setCheckboxIds={setSelectedIds} | |||
| /> | |||
| ); | |||
| }; | |||
| const handleTabChange = useCallback((_: React.SyntheticEvent, value: string) => { | |||
| setTab(value as TabValue); | |||
| }, []); | |||
| return ( | |||
| <Box> | |||
| <Tabs value={tab} onChange={handleTabChange} sx={{ mb: 2 }}> | |||
| <Tab value="miss" label={t("Miss Item")} /> | |||
| <Tab value="bad" label={t("Bad Item")} /> | |||
| <Tab value="expiry" label={t("Expiry Item")} /> | |||
| <Tab value="badHandle" label={t("Bad Item Handle")} /> | |||
| <Tab value="badRecord" label={t("Bad Item Records")} /> | |||
| <Tab value="expiryHandle" label={t("Expiry Item Handle")} /> | |||
| <Tab value="expiryRecord" label={t("Expiry Item Records")} /> | |||
| </Tabs> | |||
| <SearchBox<SearchParamNames> | |||
| criteria={searchCriteria} | |||
| onSearch={handleSearch} | |||
| /> | |||
| {tab === "expiry" && ( | |||
| <Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleSubmitSelected} | |||
| disabled={batchSubmitting || !currentUserId} | |||
| > | |||
| {batchSubmitting | |||
| ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` | |||
| : t("Batch Disposed All")} | |||
| </Button> | |||
| </Box> | |||
| )} | |||
| {renderCurrentTab()} | |||
| <SubmitIssueForm | |||
| open={formOpen} | |||
| onClose={() => setFormOpen(false)} | |||
| lotId={selectedLotId} | |||
| itemId={selectedItemId} | |||
| issueType={selectedIssueType} | |||
| currentUserId={currentUserId || 0} | |||
| onSuccess={handleFormSuccess} | |||
| /> | |||
| {tab === "badHandle" && <BadItemHandleTab />} | |||
| {tab === "badRecord" && <StockIssueRecordTab kind="bad" />} | |||
| {tab === "expiryHandle" && <ExpiryHandleTab />} | |||
| {tab === "expiryRecord" && <StockIssueRecordTab kind="expiry" />} | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default SearchPage; | |||
| export default SearchPage; | |||
| @@ -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 { | |||
| type StockIssueResult, | |||
| type ExpiryItemResult, | |||
| type StockIssueLists, | |||
| fetchList, | |||
| fetchMissItemList, | |||
| fetchBadItemList, | |||
| type ExpiryItemFilter, | |||
| type HandleBadItemRequest, | |||
| type StockIssueHandleRecord, | |||
| type SearchStockIssueRecordParams, | |||
| fetchExpiryItemList, | |||
| submitMissItem, | |||
| submitBadItem, | |||
| handleBadItem, | |||
| fetchBadItemRecords, | |||
| fetchExpiryItemRecords, | |||
| submitExpiryItem, | |||
| batchSubmitMissItem, | |||
| batchSubmitBadItem, | |||
| batchSubmitExpiryItem, | |||
| PreloadList, | |||
| } from "@/app/api/stockIssue/actions"; | |||
| @@ -1,15 +1,12 @@ | |||
| import GeneralLoading from "../General/GeneralLoading"; | |||
| import SearchPage from "./SearchPage"; | |||
| import { fetchList } from "@/app/api/stockIssue/actions"; | |||
| interface SubComponents { | |||
| Loading: typeof GeneralLoading; | |||
| } | |||
| const Wrapper: React.FC & SubComponents = async () => { | |||
| const dataList = await fetchList(); | |||
| return <SearchPage dataList={dataList} />; | |||
| const Wrapper: React.FC & SubComponents = () => { | |||
| return <SearchPage />; | |||
| }; | |||
| Wrapper.Loading = GeneralLoading; | |||
| @@ -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 Completed": "批量保存完成", | |||
| "Bad Item Handle": "不良品處理", | |||
| "Search to load lot lines": "請按搜索以載入批號", | |||
| "No changes to submit": "沒有可提交的變更", | |||
| "No record found": "沒有記錄", | |||
| "Rows per page": "每頁行數", | |||
| "Bad Item Records": "不良品處理紀錄", | |||
| "Expiry Item Handle": "過期品處理", | |||
| "Expiry Item Records": "過期品處理紀錄", | |||
| @@ -69,8 +69,8 @@ | |||
| "masterDataIssue_bomMore": " 等 {{count}} 個", | |||
| "masterDataIssue_col_problem": "問題", | |||
| "masterDataIssue_col_bom_uom": "BOM UOM", | |||
| "masterDataIssue_col_item_uom": "M18 UOM", | |||
| "masterDataIssue_col_bom_uom": "BOM 單位", | |||
| "masterDataIssue_col_item_uom": "M18 單位", | |||
| "masterDataIssue_modifiedAt": "修改時間", | |||
| "masterDataIssue_unit_active": "使用中", | |||
| "masterDataIssue_unit_inactive": "已停用", | |||