| @@ -0,0 +1,35 @@ | |||||
| import BagSearchWrapper from "@/components/BagSearch/BagSearchWrapper"; | |||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| import { Stack, Typography } from "@mui/material"; | |||||
| import { Metadata } from "next"; | |||||
| import React, { Suspense } from "react"; | |||||
| export const metadata: Metadata = { | |||||
| title: "Bag Usage Records" | |||||
| } | |||||
| const bagPage: React.FC = async () => { | |||||
| const { t } = await getServerI18n("jo"); | |||||
| return ( | |||||
| <> | |||||
| <Stack | |||||
| direction="row" | |||||
| justifyContent="space-between" | |||||
| flexWrap="wrap" | |||||
| rowGap={2} | |||||
| > | |||||
| <Typography variant="h4" marginInlineEnd={2}> | |||||
| {t("Bag Usage")} | |||||
| </Typography> | |||||
| </Stack> | |||||
| <I18nProvider namespaces={["jo", "common"]}> | |||||
| <Suspense fallback={<BagSearchWrapper.Loading />}> | |||||
| <BagSearchWrapper /> | |||||
| </Suspense> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ) | |||||
| } | |||||
| export default bagPage; | |||||
| @@ -23,7 +23,7 @@ const jo: React.FC = async () => { | |||||
| rowGap={2} | rowGap={2} | ||||
| > | > | ||||
| <Typography variant="h4" marginInlineEnd={2}> | <Typography variant="h4" marginInlineEnd={2}> | ||||
| {t("Job Order")} | |||||
| {t("Search Job Order/ Create Job Order")} | |||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| <I18nProvider namespaces={["jo", "common", "purchaseOrder", "dashboard","common"]}> {/* TODO: Improve */} | <I18nProvider namespaces={["jo", "common", "purchaseOrder", "dashboard","common"]}> {/* TODO: Improve */} | ||||
| @@ -44,4 +44,78 @@ export const getBagInfo = cache(async () => { | |||||
| body: JSON.stringify(request), | body: JSON.stringify(request), | ||||
| } | } | ||||
| ); | ); | ||||
| }); | |||||
| }); | |||||
| export interface BagUsageRecordResponse { | |||||
| id: number; | |||||
| bagId: number; | |||||
| bagLotLineId: number; | |||||
| jobId: number; | |||||
| jobOrderCode: string; | |||||
| stockOutLineId: number; | |||||
| startQty: number; | |||||
| consumedQty: number; | |||||
| scrapQty: number; | |||||
| endQty: number; | |||||
| date: string; | |||||
| time: string; | |||||
| bagName?: string; | |||||
| bagCode?: string; | |||||
| lotNo?: string; | |||||
| } | |||||
| // 添加 API 调用函数: | |||||
| export const getBagUsageRecords = cache(async () => { | |||||
| return serverFetchJson<BagUsageRecordResponse[]>( | |||||
| `${BASE_API_URL}/bag/bagUsageRecords`, | |||||
| { | |||||
| method: "GET", | |||||
| next: { tags: ["bagUsageRecords"] }, | |||||
| } | |||||
| ); | |||||
| }); | |||||
| export interface BagSummaryResponse { | |||||
| id: number; | |||||
| bagName: string; | |||||
| bagCode: string; | |||||
| takenBagBalance: number; | |||||
| deleted: boolean; | |||||
| } | |||||
| export interface BagLotLineResponse { | |||||
| id: number; | |||||
| bagId: number; | |||||
| lotNo: string; | |||||
| stockOutLineId: number; | |||||
| startQty: number; | |||||
| consumedQty: number; | |||||
| scrapQty: number; | |||||
| balanceQty: number; | |||||
| firstUseDate: string; | |||||
| lastUseDate: string; | |||||
| } | |||||
| export interface BagConsumptionResponse { | |||||
| id: number; | |||||
| bagId: number; | |||||
| bagLotLineId: number; | |||||
| jobId: number; | |||||
| jobOrderCode: string; | |||||
| stockOutLineId: number; | |||||
| startQty: number; | |||||
| consumedQty: number; | |||||
| scrapQty: number; | |||||
| endQty: number; | |||||
| date: string; | |||||
| time: string; | |||||
| } | |||||
| export const fetchBags = cache(async () => | |||||
| serverFetchJson<BagSummaryResponse[]>(`${BASE_API_URL}/bag/bags`, { method: "GET" }) | |||||
| ); | |||||
| export const fetchBagLotLines = cache(async (bagId: number) => | |||||
| serverFetchJson<BagLotLineResponse[]>(`${BASE_API_URL}/bag/bags/${bagId}/lot-lines`, { method: "GET" }) | |||||
| ); | |||||
| export const fetchBagConsumptions = cache(async (bagLotLineId: number) => | |||||
| serverFetchJson<BagConsumptionResponse[]>(`${BASE_API_URL}/bag/lot-lines/${bagLotLineId}/consumptions`, { method: "GET" }) | |||||
| ); | |||||
| @@ -318,6 +318,30 @@ export async function printDNLabels(request: PrintDNLabelsRequest){ | |||||
| return { success: true, message: "Print job sent successfully (labels)"} as PrintDeliveryNoteResponse | return { success: true, message: "Print job sent successfully (labels)"} as PrintDeliveryNoteResponse | ||||
| } | } | ||||
| export interface Check4FTruckBatchResponse { | |||||
| hasProblem: boolean; | |||||
| problems: ProblemDoDto[]; | |||||
| } | |||||
| export interface ProblemDoDto { | |||||
| deliveryOrderId: number; | |||||
| deliveryOrderCode: string; | |||||
| targetDate: string; | |||||
| availableTrucks: TruckInfoDto[]; | |||||
| } | |||||
| export interface TruckInfoDto { | |||||
| id: number; | |||||
| truckLanceCode: string; | |||||
| departureTime: string; | |||||
| storeId: string; | |||||
| shopCode: string; | |||||
| shopName: string; | |||||
| } | |||||
| export const check4FTrucksBatch = cache(async (doIds: number[]) => { | |||||
| return await serverFetchJson<Check4FTruckBatchResponse>(`${BASE_API_URL}/do/check-4f-trucks-batch`, { | |||||
| method: "POST", | |||||
| body: JSON.stringify(doIds), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| }); | |||||
| @@ -166,7 +166,19 @@ export const printFGStockInLabel = cache(async(data: PrintFGStockInLabelRequest) | |||||
| } | } | ||||
| ); | ); | ||||
| }); | }); | ||||
| export interface UpdateJoReqQtyRequest { | |||||
| id: number; | |||||
| reqQty: number; | |||||
| } | |||||
| // 添加更新 reqQty 的函数 | |||||
| export const updateJoReqQty = cache(async (data: UpdateJoReqQtyRequest) => { | |||||
| return serverFetchJson<SaveJoResponse>(`${BASE_API_URL}/jo/updateReqQty`, { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }) | |||||
| }) | |||||
| export const recordSecondScanIssue = cache(async ( | export const recordSecondScanIssue = cache(async ( | ||||
| pickOrderId: number, | pickOrderId: number, | ||||
| @@ -250,6 +262,7 @@ export interface ProductProcessWithLinesResponse { | |||||
| bomDescription: string; | bomDescription: string; | ||||
| jobType: string; | jobType: string; | ||||
| isDark: string; | isDark: string; | ||||
| bomBaseQty: number; | |||||
| isDense: number; | isDense: number; | ||||
| isFloat: string; | isFloat: string; | ||||
| timeSequence: number; | timeSequence: number; | ||||
| @@ -262,6 +275,7 @@ export interface ProductProcessWithLinesResponse { | |||||
| outputQty: number; | outputQty: number; | ||||
| outputQtyUom: string; | outputQtyUom: string; | ||||
| productionPriority: number; | productionPriority: number; | ||||
| submitedBagRecord?: boolean; | |||||
| jobOrderLines: JobOrderLineInfo[]; | jobOrderLines: JobOrderLineInfo[]; | ||||
| productProcessLines: ProductProcessLineResponse[]; | productProcessLines: ProductProcessLineResponse[]; | ||||
| @@ -417,6 +431,7 @@ export interface JobOrderProcessLineDetailResponse { | |||||
| stopTime: string | number[]; | stopTime: string | number[]; | ||||
| totalPausedTimeMs?: number; // API 返回的是数组格式 | totalPausedTimeMs?: number; // API 返回的是数组格式 | ||||
| status: string; | status: string; | ||||
| submitedBagRecord: boolean; | |||||
| outputFromProcessQty: number; | outputFromProcessQty: number; | ||||
| outputFromProcessUom: string; | outputFromProcessUom: string; | ||||
| defectQty: number; | defectQty: number; | ||||
| @@ -779,7 +794,14 @@ export const fetchProductProcessesByJobOrderId = cache(async (jobOrderId: number | |||||
| } | } | ||||
| ); | ); | ||||
| }); | }); | ||||
| export const newProductProcessLine = cache(async (lineId: number) => { | |||||
| return serverFetchJson<any>( | |||||
| `${BASE_API_URL}/product-process/Demo/ProcessLine/new/${lineId}`, | |||||
| { | |||||
| method: "POST", | |||||
| } | |||||
| ); | |||||
| }); | |||||
| // 获取 process 的所有 lines | // 获取 process 的所有 lines | ||||
| export const fetchProductProcessLines = cache(async (processId: number) => { | export const fetchProductProcessLines = cache(async (processId: number) => { | ||||
| return serverFetchJson<ProductProcessLineResponse[]>( | return serverFetchJson<ProductProcessLineResponse[]>( | ||||
| @@ -1129,4 +1151,20 @@ export const passProductProcessLine = async (lineId: number) => { | |||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| } | } | ||||
| ); | ); | ||||
| }; | |||||
| export interface UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest { | |||||
| productProcessLineId: number; | |||||
| processingTime: number; | |||||
| setupTime: number; | |||||
| changeoverTime: number; | |||||
| } | |||||
| export const updateProductProcessLineProcessingTimeSetupTimeChangeoverTime = async (lineId: number, request: UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest) => { | |||||
| return serverFetchJson<any>( | |||||
| `${BASE_API_URL}/product-process/Demo/ProcessLine/update/processingTimeSetupTimeChangeoverTime/${lineId}`, | |||||
| { | |||||
| method: "POST", | |||||
| body: JSON.stringify(request), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| } | |||||
| ); | |||||
| }; | }; | ||||
| @@ -1,10 +1,91 @@ | |||||
| // actions.ts | |||||
| "use server"; | "use server"; | ||||
| import { serverFetchString } from "@/app/utils/fetchUtil"; | |||||
| import { cache } from 'react'; | |||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; // 改为 serverFetchJson | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| export interface InventoryLotDetailResponse { | |||||
| id: number; | |||||
| inventoryLotId: number; | |||||
| itemId: number; | |||||
| itemCode: string; | |||||
| itemName: string; | |||||
| lotNo: string; | |||||
| expiryDate: string; | |||||
| productionDate: string; | |||||
| stockInDate: string; | |||||
| inQty: number; | |||||
| outQty: number; | |||||
| holdQty: number; | |||||
| availableQty: number; | |||||
| uom: string; | |||||
| warehouseCode: string; | |||||
| warehouseName: string; | |||||
| warehouseSlot: string; | |||||
| warehouseArea: string; | |||||
| warehouse: string; | |||||
| varianceQty: number | null; | |||||
| status: string; | |||||
| remarks: string | null; | |||||
| stockTakeRecordStatus: string; | |||||
| stockTakeRecordId: number | null; | |||||
| firstStockTakeQty: number | null; | |||||
| secondStockTakeQty: number | null; | |||||
| firstBadQty: number | null; | |||||
| secondBadQty: number | null; | |||||
| approverQty: number | null; | |||||
| approverBadQty: number | null; | |||||
| finalQty: number | null; | |||||
| } | |||||
| export const getInventoryLotDetailsBySection = async ( | |||||
| stockTakeSection: string, | |||||
| stockTakeId?: number | null | |||||
| ) => { | |||||
| console.log('🌐 [API] getInventoryLotDetailsBySection called with:', { | |||||
| stockTakeSection, | |||||
| stockTakeId | |||||
| }); | |||||
| const encodedSection = encodeURIComponent(stockTakeSection); | |||||
| let url = `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsBySection?stockTakeSection=${encodedSection}`; | |||||
| if (stockTakeId != null && stockTakeId > 0) { | |||||
| url += `&stockTakeId=${stockTakeId}`; | |||||
| } | |||||
| console.log(' [API] Full URL:', url); | |||||
| const details = await serverFetchJson<InventoryLotDetailResponse[]>( | |||||
| url, | |||||
| { | |||||
| method: "GET", | |||||
| }, | |||||
| ); | |||||
| console.log('[API] Response received:', details); | |||||
| return details; | |||||
| } | |||||
| export interface SaveStockTakeRecordRequest { | |||||
| stockTakeRecordId?: number | null; | |||||
| inventoryLotLineId: number; | |||||
| qty: number; | |||||
| badQty: number; | |||||
| //stockTakerName: string; | |||||
| remark?: string | null; | |||||
| } | |||||
| export interface AllPickedStockTakeListReponse { | |||||
| id: number; | |||||
| stockTakeSession: string; | |||||
| lastStockTakeDate: string | null; | |||||
| status: string|null; | |||||
| currentStockTakeItemNumber: number; | |||||
| totalInventoryLotNumber: number; | |||||
| stockTakeId: number; | |||||
| stockTakerName: string | null; | |||||
| totalItemNumber: number; | |||||
| } | |||||
| export const importStockTake = async (data: FormData) => { | export const importStockTake = async (data: FormData) => { | ||||
| const importStockTake = await serverFetchString<string>( | |||||
| const importStockTake = await serverFetchJson<string>( | |||||
| `${BASE_API_URL}/stockTake/import`, | `${BASE_API_URL}/stockTake/import`, | ||||
| { | { | ||||
| method: "POST", | method: "POST", | ||||
| @@ -12,4 +93,208 @@ export const importStockTake = async (data: FormData) => { | |||||
| }, | }, | ||||
| ); | ); | ||||
| return importStockTake; | return importStockTake; | ||||
| } | |||||
| } | |||||
| export const getStockTakeRecords = async () => { | |||||
| const stockTakeRecords = await serverFetchJson<AllPickedStockTakeListReponse[]>( // 改为 serverFetchJson | |||||
| `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList`, | |||||
| { | |||||
| method: "GET", | |||||
| }, | |||||
| ); | |||||
| return stockTakeRecords; | |||||
| } | |||||
| export const getApproverStockTakeRecords = async () => { | |||||
| const stockTakeRecords = await serverFetchJson<AllPickedStockTakeListReponse[]>( // 改为 serverFetchJson | |||||
| `${BASE_API_URL}/stockTakeRecord/AllApproverStockTakeList`, | |||||
| { | |||||
| method: "GET", | |||||
| }, | |||||
| ); | |||||
| return stockTakeRecords; | |||||
| } | |||||
| export const createStockTakeForSections = async () => { | |||||
| const createStockTakeForSections = await serverFetchJson<Map<string, string>>( | |||||
| `${BASE_API_URL}/stockTake/createForSections`, | |||||
| { | |||||
| method: "POST", | |||||
| }, | |||||
| ); | |||||
| return createStockTakeForSections; | |||||
| } | |||||
| export const saveStockTakeRecord = async ( | |||||
| request: SaveStockTakeRecordRequest, | |||||
| stockTakeId: number, | |||||
| stockTakerId: number | |||||
| ) => { | |||||
| try { | |||||
| const result = await serverFetchJson<any>( | |||||
| `${BASE_API_URL}/stockTakeRecord/saveStockTakeRecord?stockTakeId=${stockTakeId}&stockTakerId=${stockTakerId}`, | |||||
| { | |||||
| method: "POST", | |||||
| headers: { | |||||
| "Content-Type": "application/json", | |||||
| }, | |||||
| body: JSON.stringify(request), | |||||
| }, | |||||
| ); | |||||
| console.log('saveStockTakeRecord: request:', request); | |||||
| console.log('saveStockTakeRecord: stockTakeId:', stockTakeId); | |||||
| console.log('saveStockTakeRecord: stockTakerId:', stockTakerId); | |||||
| return result; | |||||
| } catch (error: any) { | |||||
| // 尝试从错误响应中提取消息 | |||||
| if (error?.response) { | |||||
| try { | |||||
| const errorData = await error.response.json(); | |||||
| const errorWithMessage = new Error(errorData.message || errorData.error || "Failed to save stock take record"); | |||||
| (errorWithMessage as any).response = error.response; | |||||
| throw errorWithMessage; | |||||
| } catch { | |||||
| throw error; | |||||
| } | |||||
| } | |||||
| throw error; | |||||
| } | |||||
| } | |||||
| export interface BatchSaveStockTakeRecordRequest { | |||||
| stockTakeId: number; | |||||
| stockTakeSection: string; | |||||
| stockTakerId: number; | |||||
| //stockTakerName: string; | |||||
| } | |||||
| export interface BatchSaveStockTakeRecordResponse { | |||||
| successCount: number; | |||||
| errorCount: number; | |||||
| errors: string[]; | |||||
| } | |||||
| export const batchSaveStockTakeRecords = cache(async (data: BatchSaveStockTakeRecordRequest) => { | |||||
| return serverFetchJson<BatchSaveStockTakeRecordResponse>(`${BASE_API_URL}/stockTakeRecord/batchSaveStockTakeRecords`, | |||||
| { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }) | |||||
| }) | |||||
| // Add these interfaces and functions | |||||
| export interface SaveApproverStockTakeRecordRequest { | |||||
| stockTakeRecordId?: number | null; | |||||
| qty: number; | |||||
| badQty: number; | |||||
| approverId?: number | null; | |||||
| approverQty?: number | null; | |||||
| approverBadQty?: number | null; | |||||
| } | |||||
| export interface BatchSaveApproverStockTakeRecordRequest { | |||||
| stockTakeId: number; | |||||
| stockTakeSection: string; | |||||
| approverId: number; | |||||
| } | |||||
| export interface BatchSaveApproverStockTakeRecordResponse { | |||||
| successCount: number; | |||||
| errorCount: number; | |||||
| errors: string[]; | |||||
| } | |||||
| export const saveApproverStockTakeRecord = async ( | |||||
| request: SaveApproverStockTakeRecordRequest, | |||||
| stockTakeId: number | |||||
| ) => { | |||||
| try { | |||||
| const result = await serverFetchJson<any>( | |||||
| `${BASE_API_URL}/stockTakeRecord/saveApproverStockTakeRecord?stockTakeId=${stockTakeId}`, | |||||
| { | |||||
| method: "POST", | |||||
| headers: { | |||||
| "Content-Type": "application/json", | |||||
| }, | |||||
| body: JSON.stringify(request), | |||||
| }, | |||||
| ); | |||||
| return result; | |||||
| } catch (error: any) { | |||||
| if (error?.response) { | |||||
| try { | |||||
| const errorData = await error.response.json(); | |||||
| const errorWithMessage = new Error(errorData.message || errorData.error || "Failed to save approver stock take record"); | |||||
| (errorWithMessage as any).response = error.response; | |||||
| throw errorWithMessage; | |||||
| } catch { | |||||
| throw error; | |||||
| } | |||||
| } | |||||
| throw error; | |||||
| } | |||||
| } | |||||
| export const batchSaveApproverStockTakeRecords = cache(async (data: BatchSaveApproverStockTakeRecordRequest) => { | |||||
| return serverFetchJson<BatchSaveApproverStockTakeRecordResponse>( | |||||
| `${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecords`, | |||||
| { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| } | |||||
| ) | |||||
| } | |||||
| ) | |||||
| export const updateStockTakeRecordStatusToNotMatch = async ( | |||||
| stockTakeRecordId: number | |||||
| ) => { | |||||
| try { | |||||
| const result = await serverFetchJson<any>( | |||||
| `${BASE_API_URL}/stockTakeRecord/updateStockTakeRecordStatusToNotMatch?stockTakeRecordId=${stockTakeRecordId}`, | |||||
| { | |||||
| method: "POST", | |||||
| }, | |||||
| ); | |||||
| return result; | |||||
| } catch (error: any) { | |||||
| if (error?.response) { | |||||
| try { | |||||
| const errorData = await error.response.json(); | |||||
| const errorWithMessage = new Error(errorData.message || errorData.error || "Failed to update stock take record status"); | |||||
| (errorWithMessage as any).response = error.response; | |||||
| throw errorWithMessage; | |||||
| } catch { | |||||
| throw error; | |||||
| } | |||||
| } | |||||
| throw error; | |||||
| } | |||||
| } | |||||
| export const getInventoryLotDetailsBySectionNotMatch = async ( | |||||
| stockTakeSection: string, | |||||
| stockTakeId?: number | null | |||||
| ) => { | |||||
| console.log('🌐 [API] getInventoryLotDetailsBySectionNotMatch called with:', { | |||||
| stockTakeSection, | |||||
| stockTakeId | |||||
| }); | |||||
| const encodedSection = encodeURIComponent(stockTakeSection); | |||||
| let url = `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsBySectionNotMatch?stockTakeSection=${encodedSection}`; | |||||
| if (stockTakeId != null && stockTakeId > 0) { | |||||
| url += `&stockTakeId=${stockTakeId}`; | |||||
| } | |||||
| console.log(' [API] Full URL:', url); | |||||
| const details = await serverFetchJson<InventoryLotDetailResponse[]>( | |||||
| url, | |||||
| { | |||||
| method: "GET", | |||||
| }, | |||||
| ); | |||||
| console.log('[API] Response received:', details); | |||||
| return details; | |||||
| } | |||||
| @@ -0,0 +1,312 @@ | |||||
| "use client"; | |||||
| import { useEffect, useMemo, useState, useCallback } from "react"; | |||||
| import { | |||||
| fetchBags, | |||||
| fetchBagLotLines, | |||||
| fetchBagConsumptions, | |||||
| BagSummaryResponse, | |||||
| BagLotLineResponse, | |||||
| BagConsumptionResponse, | |||||
| } from "@/app/api/bag/action"; | |||||
| import SearchResults, { | |||||
| Column, | |||||
| defaultPagingController, | |||||
| } from "../SearchResults/SearchResults"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { | |||||
| integerFormatter, | |||||
| arrayToDateString, | |||||
| arrayToDateTimeString, | |||||
| } from "@/app/utils/formatUtil"; | |||||
| import { | |||||
| Box, | |||||
| Typography, | |||||
| Stack, | |||||
| IconButton, | |||||
| Button, | |||||
| } from "@mui/material"; | |||||
| import VisibilityIcon from "@mui/icons-material/Visibility"; | |||||
| type ViewLevel = "bag" | "lot" | "consumption"; | |||||
| const BagSearch: React.FC = () => { | |||||
| const { t } = useTranslation("jo"); | |||||
| const [level, setLevel] = useState<ViewLevel>("bag"); | |||||
| const [selectedBag, setSelectedBag] = useState<BagSummaryResponse | null>(null); | |||||
| const [selectedLotLine, setSelectedLotLine] = useState<BagLotLineResponse | null>(null); | |||||
| const [bags, setBags] = useState<BagSummaryResponse[]>([]); | |||||
| const [lotLines, setLotLines] = useState<BagLotLineResponse[]>([]); | |||||
| const [consumptions, setConsumptions] = useState<BagConsumptionResponse[]>([]); | |||||
| const [pagingController, setPagingController] = useState(defaultPagingController); | |||||
| const [totalCount, setTotalCount] = useState(0); | |||||
| const bagColumns = useMemo<Column<BagSummaryResponse>[]>(() => [ | |||||
| { | |||||
| name: "bagName", | |||||
| label: t("Bag Name"), | |||||
| flex: 2, | |||||
| }, | |||||
| { | |||||
| name: "bagCode", | |||||
| label: t("Bag Code"), | |||||
| flex: 1, | |||||
| }, | |||||
| { | |||||
| name: "takenBagBalance", | |||||
| label: t("Balance"), | |||||
| align: "right", | |||||
| headerAlign: "right", | |||||
| renderCell: (row) => integerFormatter.format(row.takenBagBalance ?? 0), | |||||
| }, | |||||
| { | |||||
| name: "actions" as any, | |||||
| label: t("Actions"), | |||||
| align: "center", | |||||
| headerAlign: "center", | |||||
| renderCell: (row) => ( | |||||
| <IconButton | |||||
| size="small" | |||||
| color="primary" | |||||
| onClick={() => handleViewBagDetail(row)} | |||||
| > | |||||
| <VisibilityIcon fontSize="small" /> | |||||
| </IconButton> | |||||
| ), | |||||
| }, | |||||
| ], [t]); | |||||
| const lotColumns = useMemo<Column<BagLotLineResponse>[]>(() => [ | |||||
| { | |||||
| name: "lotNo", | |||||
| label: t("Lot No"), | |||||
| flex: 2, | |||||
| }, | |||||
| { | |||||
| name: "startQty", | |||||
| label: t("Start Qty"), | |||||
| align: "right", | |||||
| headerAlign: "right", | |||||
| renderCell: (row) => integerFormatter.format(row.startQty ?? 0), | |||||
| }, | |||||
| { | |||||
| name: "consumedQty", | |||||
| label: t("Consumed Qty"), | |||||
| align: "right", | |||||
| headerAlign: "right", | |||||
| renderCell: (row) => integerFormatter.format(row.consumedQty ?? 0), | |||||
| }, | |||||
| { | |||||
| name: "scrapQty", | |||||
| label: t("Scrap Qty"), | |||||
| align: "right", | |||||
| headerAlign: "right", | |||||
| renderCell: (row) => integerFormatter.format(row.scrapQty ?? 0), | |||||
| }, | |||||
| { | |||||
| name: "balanceQty", | |||||
| label: t("Balance Qty"), | |||||
| align: "right", | |||||
| headerAlign: "right", | |||||
| renderCell: (row) => integerFormatter.format(row.balanceQty ?? 0), | |||||
| }, | |||||
| { | |||||
| name: "actions" as any, | |||||
| label: t("Actions"), | |||||
| align: "center", | |||||
| headerAlign: "center", | |||||
| renderCell: (row) => ( | |||||
| <IconButton | |||||
| size="small" | |||||
| color="primary" | |||||
| onClick={() => handleViewLotDetail(row)} | |||||
| > | |||||
| <VisibilityIcon fontSize="small" /> | |||||
| </IconButton> | |||||
| ), | |||||
| }, | |||||
| ], [t]); | |||||
| const consColumns = useMemo<Column<BagConsumptionResponse>[]>(() => [ | |||||
| { | |||||
| name: "jobOrderCode", | |||||
| label: t("Job Order Code"), | |||||
| flex: 2, | |||||
| }, | |||||
| { | |||||
| name: "startQty", | |||||
| label: t("Start Qty"), | |||||
| align: "right", | |||||
| headerAlign: "right", | |||||
| renderCell: (row) => integerFormatter.format(row.startQty ?? 0), | |||||
| }, | |||||
| { | |||||
| name: "consumedQty", | |||||
| label: t("Consumed Qty"), | |||||
| align: "right", | |||||
| headerAlign: "right", | |||||
| renderCell: (row) => integerFormatter.format(row.consumedQty ?? 0), | |||||
| }, | |||||
| { | |||||
| name: "scrapQty", | |||||
| label: t("Scrap Qty"), | |||||
| align: "right", | |||||
| headerAlign: "right", | |||||
| renderCell: (row) => integerFormatter.format(row.scrapQty ?? 0), | |||||
| }, | |||||
| { | |||||
| name: "endQty", | |||||
| label: t("End Qty"), | |||||
| align: "right", | |||||
| headerAlign: "right", | |||||
| renderCell: (row) => integerFormatter.format(row.endQty ?? 0), | |||||
| }, | |||||
| { | |||||
| name: "date", | |||||
| label: t("Date"), | |||||
| renderCell: (row) => | |||||
| row.date ? arrayToDateString(row.date as any) : "-", | |||||
| }, | |||||
| { | |||||
| name: "time", | |||||
| label: t("Time"), | |||||
| renderCell: (row) => | |||||
| row.time ? arrayToDateTimeString(row.time as any) : "-", | |||||
| }, | |||||
| ], [t]); | |||||
| useEffect(() => { | |||||
| const load = async () => { | |||||
| const data = await fetchBags(); | |||||
| const safe = data ?? []; | |||||
| setBags(safe); | |||||
| setTotalCount(safe.length); | |||||
| setPagingController(defaultPagingController); | |||||
| setLevel("bag"); | |||||
| setSelectedBag(null); | |||||
| setSelectedLotLine(null); | |||||
| setLotLines([]); | |||||
| setConsumptions([]); | |||||
| }; | |||||
| load(); | |||||
| }, []); | |||||
| const handleViewBagDetail = useCallback(async (row: BagSummaryResponse) => { | |||||
| setSelectedBag(row); | |||||
| setLevel("lot"); | |||||
| setPagingController(defaultPagingController); | |||||
| setSelectedLotLine(null); | |||||
| setConsumptions([]); | |||||
| const data = await fetchBagLotLines(row.id); | |||||
| const safe = data ?? []; | |||||
| setLotLines(safe); | |||||
| setTotalCount(safe.length); | |||||
| }, []); | |||||
| const handleViewLotDetail = useCallback(async (row: BagLotLineResponse) => { | |||||
| setSelectedLotLine(row); | |||||
| setLevel("consumption"); | |||||
| setPagingController(defaultPagingController); | |||||
| const data = await fetchBagConsumptions(row.id); | |||||
| const safe = data ?? []; | |||||
| setConsumptions(safe); | |||||
| setTotalCount(safe.length); | |||||
| }, []); | |||||
| const handleBack = useCallback(async () => { | |||||
| if (level === "consumption" && selectedBag) { | |||||
| setLevel("lot"); | |||||
| setSelectedLotLine(null); | |||||
| setPagingController(defaultPagingController); | |||||
| const data = await fetchBagLotLines(selectedBag.id); | |||||
| const safe = data ?? []; | |||||
| setLotLines(safe); | |||||
| setTotalCount(safe.length); | |||||
| return; | |||||
| } | |||||
| if (level === "lot") { | |||||
| setLevel("bag"); | |||||
| setSelectedBag(null); | |||||
| setSelectedLotLine(null); | |||||
| setPagingController(defaultPagingController); | |||||
| setTotalCount(bags.length); | |||||
| return; | |||||
| } | |||||
| }, [level, selectedBag, bags]); | |||||
| const { title, items, columns } = useMemo(() => { | |||||
| if (level === "bag") { | |||||
| return { | |||||
| title: t("Bag List"), | |||||
| items: bags, | |||||
| columns: bagColumns, | |||||
| }; | |||||
| } | |||||
| if (level === "lot") { | |||||
| return { | |||||
| title: `${t("Bag Lot Lines")}${ | |||||
| selectedBag ? ` - ${selectedBag.bagName ?? ""} (${selectedBag.bagCode ?? ""})` : "" | |||||
| }`, | |||||
| items: lotLines, | |||||
| columns: lotColumns, | |||||
| }; | |||||
| } | |||||
| return { | |||||
| title: `${t("Bag Consumption Records")}${ | |||||
| selectedLotLine ? ` - ${selectedLotLine.lotNo ?? ""}` : "" | |||||
| }`, | |||||
| items: consumptions, | |||||
| columns: consColumns, | |||||
| }; | |||||
| }, [level, t, bags, bagColumns, lotLines, lotColumns, consumptions, consColumns, selectedBag, selectedLotLine]); | |||||
| return ( | |||||
| <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> | |||||
| <Stack direction="row" justifyContent="space-between" alignItems="center"> | |||||
| <Typography variant="h5"> | |||||
| {t(" ")} | |||||
| </Typography> | |||||
| <Stack direction="row" spacing={1}> | |||||
| {level !== "bag" && ( | |||||
| <Button variant="outlined" onClick={handleBack}> | |||||
| {t("Back")} | |||||
| </Button> | |||||
| )} | |||||
| </Stack> | |||||
| </Stack> | |||||
| <Typography variant="h6" gutterBottom> | |||||
| {title} | |||||
| </Typography> | |||||
| <SearchResults<any> | |||||
| items={items ?? []} | |||||
| columns={columns as any} | |||||
| setPagingController={setPagingController} | |||||
| pagingController={pagingController} | |||||
| totalCount={totalCount} | |||||
| isAutoPaging={false} | |||||
| /> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default BagSearch; | |||||
| @@ -0,0 +1,15 @@ | |||||
| import React from "react"; | |||||
| import GeneralLoading from "../General/GeneralLoading"; | |||||
| import BagSearch from "./BagSearch"; | |||||
| interface SubComponents { | |||||
| Loading: typeof GeneralLoading; | |||||
| } | |||||
| const BagSearchWrapper: React.FC & SubComponents = async () => { | |||||
| return <BagSearch /> | |||||
| } | |||||
| BagSearchWrapper.Loading = GeneralLoading; | |||||
| export default BagSearchWrapper; | |||||
| @@ -0,0 +1 @@ | |||||
| export { default } from "./BagSearchWrapper" | |||||
| @@ -1,5 +1,5 @@ | |||||
| "use client" | "use client" | ||||
| import { SearchJoResultRequest, fetchJos, updateJo,updateProductProcessPriority } from "@/app/api/jo/actions"; | |||||
| import { SearchJoResultRequest, fetchJos, updateJo, updateProductProcessPriority, updateJoReqQty } from "@/app/api/jo/actions"; | |||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | import React, { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { Criterion } from "../SearchBox"; | import { Criterion } from "../SearchBox"; | ||||
| @@ -12,7 +12,7 @@ import { useRouter } from "next/navigation"; | |||||
| import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; | import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; | ||||
| import { StockInLineInput } from "@/app/api/stockIn"; | import { StockInLineInput } from "@/app/api/stockIn"; | ||||
| import { JobOrder, JoDetailPickLine, JoStatus } from "@/app/api/jo"; | import { JobOrder, JoDetailPickLine, JoStatus } from "@/app/api/jo"; | ||||
| import { Button, Stack, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, InputAdornment } from "@mui/material"; | |||||
| import { Button, Stack, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, InputAdornment, Typography, Box } from "@mui/material"; | |||||
| import { BomCombo } from "@/app/api/bom"; | import { BomCombo } from "@/app/api/bom"; | ||||
| import JoCreateFormModal from "./JoCreateFormModal"; | import JoCreateFormModal from "./JoCreateFormModal"; | ||||
| import AddIcon from '@mui/icons-material/Add'; | import AddIcon from '@mui/icons-material/Add'; | ||||
| @@ -55,14 +55,16 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| const [inventoryData, setInventoryData] = useState<InventoryResult[]>([]); | const [inventoryData, setInventoryData] = useState<InventoryResult[]>([]); | ||||
| const [detailedJos, setDetailedJos] = useState<Map<number, JobOrder>>(new Map()); | const [detailedJos, setDetailedJos] = useState<Map<number, JobOrder>>(new Map()); | ||||
| const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false); | |||||
| const [operationPriority, setOperationPriority] = useState<number>(50); | |||||
| const [selectedJo, setSelectedJo] = useState<JobOrder | null>(null); | |||||
| const [selectedProductProcessId, setSelectedProductProcessId] = useState<number | null>(null); | |||||
| const [openPlanStartDialog, setOpenPlanStartDialog] = useState(false); | |||||
| const [planStartDate, setPlanStartDate] = useState<dayjs.Dayjs | null>(null); | |||||
| const [selectedJoForDate, setSelectedJoForDate] = useState<JobOrder | null>(null); | |||||
| // 合并后的统一编辑 Dialog 状态 | |||||
| const [openEditDialog, setOpenEditDialog] = useState(false); | |||||
| const [selectedJoForEdit, setSelectedJoForEdit] = useState<JobOrder | null>(null); | |||||
| const [editPlanStartDate, setEditPlanStartDate] = useState<dayjs.Dayjs | null>(null); | |||||
| const [editReqQtyMultiplier, setEditReqQtyMultiplier] = useState<number>(1); | |||||
| const [editBomForReqQty, setEditBomForReqQty] = useState<BomCombo | null>(null); | |||||
| const [editProductionPriority, setEditProductionPriority] = useState<number>(50); | |||||
| const [editProductProcessId, setEditProductProcessId] = useState<number | null>(null); | |||||
| const fetchJoDetailClient = async (id: number): Promise<JobOrder> => { | const fetchJoDetailClient = async (id: number): Promise<JobOrder> => { | ||||
| const response = await fetch(`/api/jo/detail?id=${id}`); | const response = await fetch(`/api/jo/detail?id=${id}`); | ||||
| if (!response.ok) { | if (!response.ok) { | ||||
| @@ -111,32 +113,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| fetchInventoryData(); | fetchInventoryData(); | ||||
| }, []); | }, []); | ||||
| const handleOpenPriorityDialog = useCallback(async (jo: JobOrder) => { | |||||
| setSelectedJo(jo); | |||||
| setOperationPriority(jo.productionPriority ?? 50); | |||||
| // 获取 productProcessId | |||||
| try { | |||||
| const { fetchProductProcessesByJobOrderId } = await import("@/app/api/jo/actions"); | |||||
| const processes = await fetchProductProcessesByJobOrderId(jo.id); | |||||
| if (processes && processes.length > 0) { | |||||
| setSelectedProductProcessId(processes[0].id); | |||||
| setOpenOperationPriorityDialog(true); | |||||
| } else { | |||||
| msg(t("No product process found for this job order")); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("Error fetching product process:", error); | |||||
| msg(t("Error loading product process")); | |||||
| } | |||||
| }, [t]); | |||||
| const handleClosePriorityDialog = useCallback((_event?: object, _reason?: "backdropClick" | "escapeKeyDown") => { | |||||
| setOpenOperationPriorityDialog(false); | |||||
| setSelectedJo(null); | |||||
| setSelectedProductProcessId(null); | |||||
| }, []); | |||||
| const getStockAvailable = (pickLine: JoDetailPickLine) => { | const getStockAvailable = (pickLine: JoDetailPickLine) => { | ||||
| const inventory = inventoryData.find(inventory => | const inventory = inventoryData.find(inventory => | ||||
| inventory.itemCode === pickLine.code || inventory.itemName === pickLine.name | inventory.itemCode === pickLine.code || inventory.itemName === pickLine.name | ||||
| @@ -175,19 +151,72 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| options: jobTypes.map(jt => jt.name) | options: jobTypes.map(jt => jt.name) | ||||
| }, | }, | ||||
| ], [t, jobTypes]) | ], [t, jobTypes]) | ||||
| const handleOpenPlanStartDialog = useCallback((jo: JobOrder) => { | |||||
| setSelectedJoForDate(jo); | |||||
| // 将 planStart 数组转换为 dayjs 对象 | |||||
| const fetchBomForJo = useCallback(async (jo: JobOrder): Promise<BomCombo | null> => { | |||||
| try { | |||||
| const detailedJo = detailedJos.get(jo.id) || await fetchJoDetailClient(jo.id); | |||||
| const matchingBom = bomCombo.find(bom => { | |||||
| return true; // 临时占位 | |||||
| }); | |||||
| return matchingBom || null; | |||||
| } catch (error) { | |||||
| console.error("Error fetching BOM for JO:", error); | |||||
| return null; | |||||
| } | |||||
| }, [bomCombo, detailedJos]); | |||||
| // 统一的打开编辑对话框函数 | |||||
| const handleOpenEditDialog = useCallback(async (jo: JobOrder) => { | |||||
| setSelectedJoForEdit(jo); | |||||
| // 设置 Plan Start Date | |||||
| if (jo.planStart && Array.isArray(jo.planStart)) { | if (jo.planStart && Array.isArray(jo.planStart)) { | ||||
| setPlanStartDate(arrayToDayjs(jo.planStart)); | |||||
| setEditPlanStartDate(arrayToDayjs(jo.planStart)); | |||||
| } else { | } else { | ||||
| setPlanStartDate(dayjs()); | |||||
| setEditPlanStartDate(dayjs()); | |||||
| } | } | ||||
| setOpenPlanStartDialog(true); | |||||
| // 设置 Production Priority | |||||
| setEditProductionPriority(jo.productionPriority ?? 50); | |||||
| // 获取 productProcessId | |||||
| try { | |||||
| const { fetchProductProcessesByJobOrderId } = await import("@/app/api/jo/actions"); | |||||
| const processes = await fetchProductProcessesByJobOrderId(jo.id); | |||||
| if (processes && processes.length > 0) { | |||||
| setEditProductProcessId(processes[0].id); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("Error fetching product process:", error); | |||||
| } | |||||
| // 设置 ReqQty | |||||
| const bom = await fetchBomForJo(jo); | |||||
| if (bom) { | |||||
| setEditBomForReqQty(bom); | |||||
| const currentMultiplier = bom.outputQty > 0 | |||||
| ? Math.round(jo.reqQty / bom.outputQty) | |||||
| : 1; | |||||
| setEditReqQtyMultiplier(currentMultiplier); | |||||
| } | |||||
| setOpenEditDialog(true); | |||||
| }, [fetchBomForJo]); | |||||
| // 统一的关闭函数 | |||||
| const handleCloseEditDialog = useCallback((_event?: object, _reason?: "backdropClick" | "escapeKeyDown") => { | |||||
| setOpenEditDialog(false); | |||||
| setSelectedJoForEdit(null); | |||||
| setEditPlanStartDate(null); | |||||
| setEditReqQtyMultiplier(1); | |||||
| setEditBomForReqQty(null); | |||||
| setEditProductionPriority(50); | |||||
| setEditProductProcessId(null); | |||||
| }, []); | }, []); | ||||
| const columns = useMemo<Column<JobOrder>[]>( | const columns = useMemo<Column<JobOrder>[]>( | ||||
| () => [ | () => [ | ||||
| { | { | ||||
| name: "planStart", | name: "planStart", | ||||
| label: t("Estimated Production Date"), | label: t("Estimated Production Date"), | ||||
| @@ -196,19 +225,20 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| renderCell: (row) => { | renderCell: (row) => { | ||||
| return ( | return ( | ||||
| <Stack direction="row" alignItems="center" spacing={1}> | <Stack direction="row" alignItems="center" spacing={1}> | ||||
| <span>{row.planStart ? arrayToDateString(row.planStart) : '-'}</span> | |||||
| {row.status == "planning" && ( | |||||
| <IconButton | |||||
| size="small" | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| handleOpenPlanStartDialog(row); | |||||
| }} | |||||
| sx={{ padding: '4px' }} | |||||
| > | |||||
| <EditIcon fontSize="small" /> | |||||
| </IconButton> | |||||
| {row.status == "planning" && ( | |||||
| <IconButton | |||||
| size="small" | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| handleOpenEditDialog(row); | |||||
| }} | |||||
| sx={{ padding: '4px' }} | |||||
| > | |||||
| <EditIcon fontSize="small" /> | |||||
| </IconButton> | |||||
| )} | )} | ||||
| <span>{row.planStart ? arrayToDateString(row.planStart) : '-'}</span> | |||||
| </Stack> | </Stack> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -220,16 +250,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| return ( | return ( | ||||
| <Stack direction="row" alignItems="center" spacing={1}> | <Stack direction="row" alignItems="center" spacing={1}> | ||||
| <span>{integerFormatter.format(row.productionPriority)}</span> | <span>{integerFormatter.format(row.productionPriority)}</span> | ||||
| <IconButton | |||||
| size="small" | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| handleOpenPriorityDialog(row); | |||||
| }} | |||||
| sx={{ padding: '4px' }} | |||||
| > | |||||
| <EditIcon fontSize="small" /> | |||||
| </IconButton> | |||||
| </Stack> | </Stack> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -239,12 +260,11 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| label: t("Code"), | label: t("Code"), | ||||
| flex: 2 | flex: 2 | ||||
| }, | }, | ||||
| { | { | ||||
| name: "item", | name: "item", | ||||
| label: `${t("Item Name")}`, | label: `${t("Item Name")}`, | ||||
| renderCell: (row) => { | renderCell: (row) => { | ||||
| return row.item ? <>{t(row.item.code)} {t(row.item.name)}</> : '-' | |||||
| return row.item ? <>{t(row.jobTypeName)} {t(row.item.code)} {t(row.item.name)}</> : '-' | |||||
| } | } | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -253,7 +273,12 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| align: "right", | align: "right", | ||||
| headerAlign: "right", | headerAlign: "right", | ||||
| renderCell: (row) => { | renderCell: (row) => { | ||||
| return integerFormatter.format(row.reqQty) | |||||
| return ( | |||||
| <Stack direction="row" alignItems="center" spacing={1} justifyContent="flex-end"> | |||||
| <span>{integerFormatter.format(row.reqQty)}</span> | |||||
| </Stack> | |||||
| ); | |||||
| } | } | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -288,13 +313,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| </span> | </span> | ||||
| } | } | ||||
| }, | }, | ||||
| { | |||||
| name: "jobTypeName", | |||||
| label: t("Job Type"), | |||||
| renderCell: (row) => { | |||||
| return row.jobTypeName ? t(row.jobTypeName) : '-' | |||||
| } | |||||
| }, | |||||
| { | { | ||||
| name: "id", | name: "id", | ||||
| label: t("Actions"), | label: t("Actions"), | ||||
| @@ -313,10 +331,9 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| ) | ) | ||||
| } | } | ||||
| }, | }, | ||||
| ], [t, inventoryData, detailedJos, handleOpenPriorityDialog,handleOpenPlanStartDialog] | |||||
| ], [t, inventoryData, detailedJos, handleOpenEditDialog] | |||||
| ) | ) | ||||
| // 按照 PoSearch 的模式:创建 newPageFetch 函数 | |||||
| const newPageFetch = useCallback( | const newPageFetch = useCallback( | ||||
| async ( | async ( | ||||
| pagingController: { pageNum: number; pageSize: number }, | pagingController: { pageNum: number; pageSize: number }, | ||||
| @@ -333,7 +350,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| if (response && response.records) { | if (response && response.records) { | ||||
| console.log("newPageFetch - setting filteredJos with", response.records.length, "records"); | console.log("newPageFetch - setting filteredJos with", response.records.length, "records"); | ||||
| setTotalCount(response.total); | setTotalCount(response.total); | ||||
| // 后端已经按 id DESC 排序,不需要再次排序 | |||||
| setFilteredJos(response.records); | setFilteredJos(response.records); | ||||
| console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id); | console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id); | ||||
| } else { | } else { | ||||
| @@ -343,21 +359,73 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| }, | }, | ||||
| [], | [], | ||||
| ); | ); | ||||
| const handleUpdateReqQty = useCallback(async (jobOrderId: number, newReqQty: number) => { | |||||
| try { | |||||
| const response = await updateJoReqQty({ | |||||
| id: jobOrderId, | |||||
| reqQty: newReqQty | |||||
| }); | |||||
| if (response) { | |||||
| msg(t("update success")); | |||||
| await newPageFetch(pagingController, inputs); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("Error updating reqQty:", error); | |||||
| msg(t("update failed")); | |||||
| } | |||||
| }, [pagingController, inputs, newPageFetch, t]); | |||||
| const handleUpdatePlanStart = useCallback(async (jobOrderId: number, planStart: string) => { | |||||
| const response = await updateJoPlanStart({ id: jobOrderId, planStart }); | |||||
| if (response) { | |||||
| await newPageFetch(pagingController, inputs); | |||||
| } | |||||
| }, [pagingController, inputs, newPageFetch]); | |||||
| const handleUpdateOperationPriority = useCallback(async (productProcessId: number, productionPriority: number) => { | const handleUpdateOperationPriority = useCallback(async (productProcessId: number, productionPriority: number) => { | ||||
| const response = await updateProductProcessPriority(productProcessId, productionPriority) | const response = await updateProductProcessPriority(productProcessId, productionPriority) | ||||
| if (response) { | if (response) { | ||||
| // 刷新数据 | |||||
| await newPageFetch(pagingController, inputs); | await newPageFetch(pagingController, inputs); | ||||
| } | } | ||||
| }, [pagingController, inputs, newPageFetch]); | }, [pagingController, inputs, newPageFetch]); | ||||
| const handleConfirmPriority = useCallback(async () => { | |||||
| if (!selectedProductProcessId) return; | |||||
| await handleUpdateOperationPriority(selectedProductProcessId, Number(operationPriority)); | |||||
| setOpenOperationPriorityDialog(false); | |||||
| setSelectedJo(null); | |||||
| setSelectedProductProcessId(null); | |||||
| }, [selectedProductProcessId, operationPriority, handleUpdateOperationPriority]); | |||||
| // 按照 PoSearch 的模式:使用相同的 useEffect 逻辑 | |||||
| // 统一的确认函数 | |||||
| const handleConfirmEdit = useCallback(async () => { | |||||
| if (!selectedJoForEdit) return; | |||||
| try { | |||||
| // 更新 Plan Start | |||||
| if (editPlanStartDate) { | |||||
| const dateString = `${dayjsToDateString(editPlanStartDate, "input")}T00:00:00`; | |||||
| await handleUpdatePlanStart(selectedJoForEdit.id, dateString); | |||||
| } | |||||
| // 更新 ReqQty | |||||
| if (editBomForReqQty) { | |||||
| const newReqQty = editReqQtyMultiplier * editBomForReqQty.outputQty; | |||||
| await handleUpdateReqQty(selectedJoForEdit.id, newReqQty); | |||||
| } | |||||
| // 更新 Production Priority | |||||
| if (editProductProcessId) { | |||||
| await handleUpdateOperationPriority(editProductProcessId, Number(editProductionPriority)); | |||||
| } | |||||
| setOpenEditDialog(false); | |||||
| setSelectedJoForEdit(null); | |||||
| setEditPlanStartDate(null); | |||||
| setEditReqQtyMultiplier(1); | |||||
| setEditBomForReqQty(null); | |||||
| setEditProductionPriority(50); | |||||
| setEditProductProcessId(null); | |||||
| } catch (error) { | |||||
| console.error("Error updating:", error); | |||||
| msg(t("update failed")); | |||||
| } | |||||
| }, [selectedJoForEdit, editPlanStartDate, editBomForReqQty, editReqQtyMultiplier, editProductionPriority, editProductProcessId, handleUpdatePlanStart, handleUpdateReqQty, handleUpdateOperationPriority, t]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| newPageFetch(pagingController, inputs); | newPageFetch(pagingController, inputs); | ||||
| }, [newPageFetch, pagingController, inputs]); | }, [newPageFetch, pagingController, inputs]); | ||||
| @@ -378,7 +446,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| const res = await createStockInLine(postData); | const res = await createStockInLine(postData); | ||||
| console.log(`%c Created Stock In Line`, "color:lime", res); | console.log(`%c Created Stock In Line`, "color:lime", res); | ||||
| msg(t("update success")); | msg(t("update success")); | ||||
| // 重置为默认输入,让 useEffect 自动触发 | |||||
| setInputs(defaultInputs); | setInputs(defaultInputs); | ||||
| setPagingController(defaultPagingController); | setPagingController(defaultPagingController); | ||||
| } | } | ||||
| @@ -427,7 +494,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| const closeNewModal = useCallback(() => { | const closeNewModal = useCallback(() => { | ||||
| setOpenModal(false); | setOpenModal(false); | ||||
| setInputs(defaultInputs); | setInputs(defaultInputs); | ||||
| setPagingController(defaultPagingController); | setPagingController(defaultPagingController); | ||||
| }, [defaultInputs]); | }, [defaultInputs]); | ||||
| @@ -440,7 +506,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| jobTypeName: query.jobTypeName && query.jobTypeName !== "All" ? query.jobTypeName : "" | jobTypeName: query.jobTypeName && query.jobTypeName !== "All" ? query.jobTypeName : "" | ||||
| }; | }; | ||||
| setInputs({ | setInputs({ | ||||
| code: transformedQuery.code, | code: transformedQuery.code, | ||||
| itemName: transformedQuery.itemName, | itemName: transformedQuery.itemName, | ||||
| @@ -452,38 +517,11 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| setPagingController(defaultPagingController); | setPagingController(defaultPagingController); | ||||
| }, [defaultInputs]) | }, [defaultInputs]) | ||||
| const handleClosePlanStartDialog = useCallback((_event?: object, _reason?: "backdropClick" | "escapeKeyDown") => { | |||||
| setOpenPlanStartDialog(false); | |||||
| setSelectedJoForDate(null); | |||||
| setPlanStartDate(null); | |||||
| }, []); | |||||
| const handleUpdatePlanStart = useCallback(async (jobOrderId: number, planStart: string) => { | |||||
| const response = await updateJoPlanStart({ id: jobOrderId, planStart }); | |||||
| if (response) { | |||||
| // 刷新数据 | |||||
| await newPageFetch(pagingController, inputs); | |||||
| } | |||||
| }, [pagingController, inputs, newPageFetch]); | |||||
| const handleConfirmPlanStart = useCallback(async () => { | |||||
| if (!selectedJoForDate?.id || !planStartDate) return; | |||||
| // 将日期转换为后端需要的格式 (YYYY-MM-DDTHH:mm:ss) | |||||
| const dateString = `${dayjsToDateString(planStartDate, "input")}T00:00:00`; | |||||
| await handleUpdatePlanStart(selectedJoForDate.id, dateString); | |||||
| setOpenPlanStartDialog(false); | |||||
| setSelectedJoForDate(null); | |||||
| setPlanStartDate(null); | |||||
| }, [selectedJoForDate, planStartDate, handleUpdatePlanStart]); | |||||
| const onReset = useCallback(() => { | const onReset = useCallback(() => { | ||||
| setInputs(defaultInputs); | setInputs(defaultInputs); | ||||
| setPagingController(defaultPagingController); | setPagingController(defaultPagingController); | ||||
| }, [defaultInputs]) | }, [defaultInputs]) | ||||
| const onOpenCreateJoModal = useCallback(() => { | const onOpenCreateJoModal = useCallback(() => { | ||||
| setIsCreateJoModalOpen(() => true) | setIsCreateJoModalOpen(() => true) | ||||
| }, []) | }, []) | ||||
| @@ -526,7 +564,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| jobTypes={jobTypes} | jobTypes={jobTypes} | ||||
| onClose={onCloseCreateJoModal} | onClose={onCloseCreateJoModal} | ||||
| onSearch={() => { | onSearch={() => { | ||||
| setInputs({ ...defaultInputs }); // 创建新对象,确保引用变化 | |||||
| setInputs({ ...defaultInputs }); | |||||
| setPagingController(defaultPagingController); | setPagingController(defaultPagingController); | ||||
| }} | }} | ||||
| /> | /> | ||||
| @@ -538,66 +576,128 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| inputDetail={modalInfo} | inputDetail={modalInfo} | ||||
| printerCombo={printerCombo} | printerCombo={printerCombo} | ||||
| /> | /> | ||||
| <Dialog | |||||
| open={openOperationPriorityDialog} | |||||
| onClose={handleClosePriorityDialog} | |||||
| fullWidth | |||||
| maxWidth="xs" | |||||
| > | |||||
| <DialogTitle>{t("Update Production Priority")}</DialogTitle> | |||||
| <DialogContent> | |||||
| <TextField | |||||
| autoFocus | |||||
| margin="dense" | |||||
| label={t("Production Priority")} | |||||
| type="number" | |||||
| fullWidth | |||||
| value={operationPriority} | |||||
| onChange={(e) => setOperationPriority(Number(e.target.value))} | |||||
| /> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={handleClosePriorityDialog}>{t("Cancel")}</Button> | |||||
| <Button variant="contained" onClick={handleConfirmPriority}>{t("Save")}</Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| <Dialog | |||||
| open={openPlanStartDialog} | |||||
| onClose={handleClosePlanStartDialog} | |||||
| {/* 合并后的统一编辑 Dialog */} | |||||
| <Dialog | |||||
| open={openEditDialog} | |||||
| onClose={handleCloseEditDialog} | |||||
| fullWidth | fullWidth | ||||
| maxWidth="xs" | |||||
| maxWidth="sm" | |||||
| > | > | ||||
| <DialogTitle>{t("Update Estimated Production Date")}</DialogTitle> | |||||
| <DialogTitle>{t("Edit Job Order")}</DialogTitle> | |||||
| <DialogContent> | <DialogContent> | ||||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||||
| <DatePicker | |||||
| label={t("Estimated Production Date")} | |||||
| value={planStartDate} | |||||
| onChange={(newValue) => setPlanStartDate(newValue)} | |||||
| slotProps={{ | |||||
| textField: { | |||||
| fullWidth: true, | |||||
| margin: "dense", | |||||
| autoFocus: true, | |||||
| <Stack spacing={3} sx={{ mt: 1 }}> | |||||
| {/* Plan Start Date */} | |||||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||||
| <DatePicker | |||||
| label={t("Estimated Production Date")} | |||||
| value={editPlanStartDate} | |||||
| onChange={(newValue) => setEditPlanStartDate(newValue)} | |||||
| slotProps={{ | |||||
| textField: { | |||||
| fullWidth: true, | |||||
| margin: "dense", | |||||
| } | |||||
| }} | |||||
| /> | |||||
| </LocalizationProvider> | |||||
| {/* Production Priority */} | |||||
| <TextField | |||||
| label={t("Production Priority")} | |||||
| type="number" | |||||
| fullWidth | |||||
| value={editProductionPriority} | |||||
| onChange={(e) => { | |||||
| const val = Number(e.target.value); | |||||
| if (val >= 1 && val <= 100) { | |||||
| setEditProductionPriority(val); | |||||
| } | } | ||||
| }} | }} | ||||
| inputProps={{ | |||||
| min: 1, | |||||
| max: 100, | |||||
| step: 1 | |||||
| }} | |||||
| /> | /> | ||||
| </LocalizationProvider> | |||||
| {/* ReqQty */} | |||||
| {editBomForReqQty && ( | |||||
| <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> | |||||
| <TextField | |||||
| label={t("Base Qty")} | |||||
| fullWidth | |||||
| type="number" | |||||
| variant="outlined" | |||||
| value={editBomForReqQty.outputQty} | |||||
| disabled | |||||
| InputProps={{ | |||||
| endAdornment: editBomForReqQty.outputQtyUom ? ( | |||||
| <InputAdornment position="end"> | |||||
| <Typography variant="body2" sx={{ color: "text.secondary" }}> | |||||
| {editBomForReqQty.outputQtyUom} | |||||
| </Typography> | |||||
| </InputAdornment> | |||||
| ) : null | |||||
| }} | |||||
| sx={{ flex: 1 }} | |||||
| /> | |||||
| <Typography variant="body1" sx={{ color: "text.secondary" }}> | |||||
| × | |||||
| </Typography> | |||||
| <TextField | |||||
| label={t("Batch Count")} | |||||
| fullWidth | |||||
| type="number" | |||||
| variant="outlined" | |||||
| value={editReqQtyMultiplier} | |||||
| onChange={(e) => { | |||||
| const val = e.target.value === "" ? 1 : Math.max(1, Math.floor(Number(e.target.value))); | |||||
| setEditReqQtyMultiplier(val); | |||||
| }} | |||||
| inputProps={{ | |||||
| min: 1, | |||||
| step: 1 | |||||
| }} | |||||
| sx={{ flex: 1 }} | |||||
| /> | |||||
| <Typography variant="body1" sx={{ color: "text.secondary" }}> | |||||
| = | |||||
| </Typography> | |||||
| <TextField | |||||
| label={t("Req. Qty")} | |||||
| fullWidth | |||||
| variant="outlined" | |||||
| type="number" | |||||
| value={editBomForReqQty ? (editReqQtyMultiplier * editBomForReqQty.outputQty) : ""} | |||||
| disabled | |||||
| InputProps={{ | |||||
| endAdornment: editBomForReqQty.outputQtyUom ? ( | |||||
| <InputAdornment position="end"> | |||||
| <Typography variant="body2" sx={{ color: "text.secondary" }}> | |||||
| {editBomForReqQty.outputQtyUom} | |||||
| </Typography> | |||||
| </InputAdornment> | |||||
| ) : null | |||||
| }} | |||||
| sx={{ flex: 1 }} | |||||
| /> | |||||
| </Box> | |||||
| )} | |||||
| </Stack> | |||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={handleClosePlanStartDialog}>{t("Cancel")}</Button> | |||||
| <Button onClick={handleCloseEditDialog}>{t("Cancel")}</Button> | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| onClick={handleConfirmPlanStart} | |||||
| disabled={!planStartDate} | |||||
| onClick={handleConfirmEdit} | |||||
| disabled={!editPlanStartDate || !editBomForReqQty} | |||||
| > | > | ||||
| {t("Save")} | {t("Save")} | ||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| </> | </> | ||||
| } | } | ||||
| export default JoSearch; | export default JoSearch; | ||||
| @@ -39,6 +39,7 @@ interface BagConsumptionFormProps { | |||||
| lineId: number; | lineId: number; | ||||
| bomDescription?: string; | bomDescription?: string; | ||||
| isLastLine: boolean; | isLastLine: boolean; | ||||
| submitedBagRecord?: boolean; | |||||
| onRefresh?: () => void; | onRefresh?: () => void; | ||||
| } | } | ||||
| @@ -47,6 +48,7 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({ | |||||
| lineId, | lineId, | ||||
| bomDescription, | bomDescription, | ||||
| isLastLine, | isLastLine, | ||||
| submitedBagRecord, | |||||
| onRefresh, | onRefresh, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(["common", "jo"]); | const { t } = useTranslation(["common", "jo"]); | ||||
| @@ -59,8 +61,12 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({ | |||||
| // 判断是否显示表单 | // 判断是否显示表单 | ||||
| const shouldShow = useMemo(() => { | const shouldShow = useMemo(() => { | ||||
| // 如果 submitedBagRecord 为 true,则不显示表单 | |||||
| if (submitedBagRecord === true) { | |||||
| return false; | |||||
| } | |||||
| return bomDescription === "FG" && isLastLine; | return bomDescription === "FG" && isLastLine; | ||||
| }, [bomDescription, isLastLine]); | |||||
| }, [bomDescription, isLastLine, submitedBagRecord]); | |||||
| // 加载 Bag 列表 | // 加载 Bag 列表 | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -1,5 +1,8 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useCallback, useEffect, useState, useRef } from "react"; | import React, { useCallback, useEffect, useState, useRef } from "react"; | ||||
| import EditIcon from "@mui/icons-material/Edit"; | |||||
| import AddIcon from '@mui/icons-material/Add'; | |||||
| import Fab from '@mui/material/Fab'; | |||||
| import { | import { | ||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| @@ -21,6 +24,7 @@ import { | |||||
| DialogTitle, | DialogTitle, | ||||
| DialogContent, | DialogContent, | ||||
| DialogActions, | DialogActions, | ||||
| IconButton | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import QrCodeIcon from '@mui/icons-material/QrCode'; | import QrCodeIcon from '@mui/icons-material/QrCode'; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -40,9 +44,12 @@ import { | |||||
| ProductProcessLineInfoResponse, | ProductProcessLineInfoResponse, | ||||
| startProductProcessLine, | startProductProcessLine, | ||||
| fetchProductProcessesByJobOrderId, | fetchProductProcessesByJobOrderId, | ||||
| ProductProcessWithLinesResponse, // ✅ 添加 | |||||
| ProductProcessWithLinesResponse, // 添加 | |||||
| ProductProcessLineResponse, | ProductProcessLineResponse, | ||||
| passProductProcessLine, | passProductProcessLine, | ||||
| newProductProcessLine, | |||||
| updateProductProcessLineProcessingTimeSetupTimeChangeoverTime, | |||||
| UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest, | |||||
| } from "@/app/api/jo/actions"; | } from "@/app/api/jo/actions"; | ||||
| import { updateProductProcessLineStatus } from "@/app/api/jo/actions"; | import { updateProductProcessLineStatus } from "@/app/api/jo/actions"; | ||||
| @@ -61,16 +68,21 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||||
| onBack, | onBack, | ||||
| fromJosave, | fromJosave, | ||||
| }) => { | }) => { | ||||
| console.log(" ProductionProcessDetail RENDER", { jobOrderId, fromJosave }); | |||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | ||||
| const [showOutputPage, setShowOutputPage] = useState(false); | const [showOutputPage, setShowOutputPage] = useState(false); | ||||
| // 基本信息 | // 基本信息 | ||||
| const [processData, setProcessData] = useState<ProductProcessWithLinesResponse | null>(null); // ✅ 修改类型 | |||||
| const [lines, setLines] = useState<ProductProcessLineResponse[]>([]); // ✅ 修改类型 | |||||
| const [processData, setProcessData] = useState<ProductProcessWithLinesResponse | null>(null); // 修改类型 | |||||
| const [lines, setLines] = useState<ProductProcessLineResponse[]>([]); // 修改类型 | |||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const linesRef = useRef<ProductProcessLineResponse[]>([]); | |||||
| const onBackRef = useRef(onBack); | |||||
| const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||||
| // 选中的 line 和执行状态 | // 选中的 line 和执行状态 | ||||
| const [selectedLineId, setSelectedLineId] = useState<number | null>(null); | const [selectedLineId, setSelectedLineId] = useState<number | null>(null); | ||||
| const [isExecutingLine, setIsExecutingLine] = useState(false); | const [isExecutingLine, setIsExecutingLine] = useState(false); | ||||
| @@ -88,8 +100,14 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||||
| const [lineDetailForScan, setLineDetailForScan] = useState<JobOrderProcessLineDetailResponse | null>(null); | const [lineDetailForScan, setLineDetailForScan] = useState<JobOrderProcessLineDetailResponse | null>(null); | ||||
| const [showScanDialog, setShowScanDialog] = useState(false); | const [showScanDialog, setShowScanDialog] = useState(false); | ||||
| const autoSubmitTimerRef = useRef<NodeJS.Timeout | null>(null); | const autoSubmitTimerRef = useRef<NodeJS.Timeout | null>(null); | ||||
| const [openTimeDialog, setOpenTimeDialog] = useState(false); | |||||
| const [editingLineId, setEditingLineId] = useState<number | null>(null); | |||||
| const [timeValues, setTimeValues] = useState({ | |||||
| durationInMinutes: 0, | |||||
| prepTimeInMinutes: 0, | |||||
| postProdTimeInMinutes: 0, | |||||
| }); | |||||
| // 产出表单 | |||||
| const [outputData, setOutputData] = useState({ | const [outputData, setOutputData] = useState({ | ||||
| byproductName: "", | byproductName: "", | ||||
| byproductQty: "", | byproductQty: "", | ||||
| @@ -110,43 +128,122 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||||
| setSelectedLineId(null); | setSelectedLineId(null); | ||||
| setShowOutputPage(false); | setShowOutputPage(false); | ||||
| }; | }; | ||||
| useEffect(() => { | |||||
| onBackRef.current = onBack; | |||||
| }, [onBack]); | |||||
| // 获取 process 和 lines 数据 | // 获取 process 和 lines 数据 | ||||
| const fetchProcessDetail = useCallback(async () => { | const fetchProcessDetail = useCallback(async () => { | ||||
| console.log(" fetchProcessDetail CALLED", { jobOrderId, timestamp: new Date().toISOString() }); | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| console.log(`🔍 Loading process detail for JobOrderId: ${jobOrderId}`); | |||||
| console.log(` Loading process detail for JobOrderId: ${jobOrderId}`); | |||||
| // 使用 fetchProductProcessesByJobOrderId 获取基础数据 | |||||
| const processesWithLines = await fetchProductProcessesByJobOrderId(jobOrderId); | const processesWithLines = await fetchProductProcessesByJobOrderId(jobOrderId); | ||||
| if (!processesWithLines || processesWithLines.length === 0) { | if (!processesWithLines || processesWithLines.length === 0) { | ||||
| throw new Error("No processes found for this job order"); | throw new Error("No processes found for this job order"); | ||||
| } | } | ||||
| // 如果有多个 process,取第一个(或者可以根据需要选择) | |||||
| const currentProcess = processesWithLines[0]; | const currentProcess = processesWithLines[0]; | ||||
| setProcessData(currentProcess); | setProcessData(currentProcess); | ||||
| // 使用 productProcessLines 字段(API 返回的字段名) | |||||
| const lines = currentProcess.productProcessLines || []; | const lines = currentProcess.productProcessLines || []; | ||||
| setLines(lines); | setLines(lines); | ||||
| console.log(" Process data loaded:", currentProcess); | |||||
| console.log(" Lines loaded:", lines); | |||||
| linesRef.current = lines; | |||||
| console.log(" Process data loaded:", currentProcess); | |||||
| console.log(" Lines loaded:", lines); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("❌ Error loading process detail:", error); | |||||
| //alert(`无法加载 Job Order ID ${jobOrderId} 的生产流程。该记录可能不存在。`); | |||||
| onBack(); | |||||
| console.error(" Error loading process detail:", error); | |||||
| onBackRef.current(); | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| }, [jobOrderId, onBack]); | |||||
| }, [jobOrderId]); | |||||
| const handleOpenTimeDialog = useCallback((lineId: number) => { | |||||
| console.log("🔓 handleOpenTimeDialog CALLED", { lineId, timestamp: new Date().toISOString() }); | |||||
| // 直接使用 linesRef.current,避免触发 setLines | |||||
| const line = linesRef.current.find(l => l.id === lineId); | |||||
| if (line) { | |||||
| console.log(" Found line:", line); | |||||
| setEditingLineId(lineId); | |||||
| setTimeValues({ | |||||
| durationInMinutes: line.durationInMinutes || 0, | |||||
| prepTimeInMinutes: line.prepTimeInMinutes || 0, | |||||
| postProdTimeInMinutes: line.postProdTimeInMinutes || 0, | |||||
| }); | |||||
| setOpenTimeDialog(true); | |||||
| console.log(" Dialog opened"); | |||||
| } else { | |||||
| console.warn(" Line not found:", lineId); | |||||
| } | |||||
| }, []); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| fetchProcessDetail(); | |||||
| fetchProcessDetailRef.current = fetchProcessDetail; | |||||
| }, [fetchProcessDetail]); | }, [fetchProcessDetail]); | ||||
| const handleCloseTimeDialog = useCallback(() => { | |||||
| console.log("🔒 handleCloseTimeDialog CALLED", { timestamp: new Date().toISOString() }); | |||||
| setOpenTimeDialog(false); | |||||
| setEditingLineId(null); | |||||
| setTimeValues({ | |||||
| durationInMinutes: 0, | |||||
| prepTimeInMinutes: 0, | |||||
| postProdTimeInMinutes: 0, | |||||
| }); | |||||
| console.log(" Dialog closed"); | |||||
| }, []); | |||||
| const handleConfirmTimeUpdate = useCallback(async () => { | |||||
| console.log("💾 handleConfirmTimeUpdate CALLED", { editingLineId, timeValues, timestamp: new Date().toISOString() }); | |||||
| if (!editingLineId) return; | |||||
| try { | |||||
| const request: UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest = { | |||||
| productProcessLineId: editingLineId, | |||||
| processingTime: timeValues.durationInMinutes, | |||||
| setupTime: timeValues.prepTimeInMinutes, | |||||
| changeoverTime: timeValues.postProdTimeInMinutes, | |||||
| }; | |||||
| await updateProductProcessLineProcessingTimeSetupTimeChangeoverTime(editingLineId, request); | |||||
| await fetchProcessDetail(); | |||||
| handleCloseTimeDialog(); | |||||
| } catch (error) { | |||||
| console.error("Error updating time:", error); | |||||
| alert(t("update failed")); | |||||
| } | |||||
| }, [editingLineId, timeValues, fetchProcessDetail, handleCloseTimeDialog, t]); | |||||
| useEffect(() => { | |||||
| console.log("🔄 useEffect [jobOrderId] TRIGGERED", { | |||||
| jobOrderId, | |||||
| timestamp: new Date().toISOString() | |||||
| }); | |||||
| if (fetchProcessDetailRef.current) { | |||||
| fetchProcessDetailRef.current(); | |||||
| } | |||||
| }, [jobOrderId]); | |||||
| // 添加监听 openTimeDialog 变化的 useEffect | |||||
| useEffect(() => { | |||||
| console.log(" openTimeDialog changed:", { openTimeDialog, timestamp: new Date().toISOString() }); | |||||
| }, [openTimeDialog]); | |||||
| // 添加监听 timeValues 变化的 useEffect | |||||
| useEffect(() => { | |||||
| console.log(" timeValues changed:", { timeValues, timestamp: new Date().toISOString() }); | |||||
| }, [timeValues]); | |||||
| // 添加监听 lines 变化的 useEffect | |||||
| useEffect(() => { | |||||
| console.log(" lines changed:", { count: lines.length, lines, timestamp: new Date().toISOString() }); | |||||
| }, [lines]); | |||||
| // 添加监听 editingLineId 变化的 useEffect | |||||
| useEffect(() => { | |||||
| console.log(" editingLineId changed:", { editingLineId, timestamp: new Date().toISOString() }); | |||||
| }, [editingLineId]); | |||||
| const handlePassLine = useCallback(async (lineId: number) => { | const handlePassLine = useCallback(async (lineId: number) => { | ||||
| try { | try { | ||||
| @@ -158,7 +255,16 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||||
| alert(t("Failed to pass line. Please try again.")); | alert(t("Failed to pass line. Please try again.")); | ||||
| } | } | ||||
| }, [fetchProcessDetail, t]); | }, [fetchProcessDetail, t]); | ||||
| const handleCreateNewLine = useCallback(async (lineId: number) => { | |||||
| try { | |||||
| await newProductProcessLine(lineId); | |||||
| // 刷新数据 | |||||
| await fetchProcessDetail(); | |||||
| } catch (error) { | |||||
| console.error("Error creating new line:", error); | |||||
| alert(t("Failed to create new line. Please try again.")); | |||||
| } | |||||
| }, [fetchProcessDetail, t]); | |||||
| // 提交产出数据 | // 提交产出数据 | ||||
| const processQrCode = useCallback((qrValue: string, lineId: number) => { | const processQrCode = useCallback((qrValue: string, lineId: number) => { | ||||
| // 设备快捷格式:{2fitesteXXX} - XXX 直接作为设备代码 | // 设备快捷格式:{2fitesteXXX} - XXX 直接作为设备代码 | ||||
| @@ -257,7 +363,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| try { | try { | ||||
| const lineDetail = lineDetailForScan || await fetchProductProcessLineDetail(lineId); | const lineDetail = lineDetailForScan || await fetchProductProcessLineDetail(lineId); | ||||
| // ✅ 统一使用一个最终的 equipmentCode(优先用 scannedEquipmentCode,其次用 scannedEquipmentTypeSubTypeEquipmentNo) | |||||
| // 统一使用一个最终的 equipmentCode(优先用 scannedEquipmentCode,其次用 scannedEquipmentTypeSubTypeEquipmentNo) | |||||
| const effectiveEquipmentCode = | const effectiveEquipmentCode = | ||||
| scannedEquipmentCode ?? null; | scannedEquipmentCode ?? null; | ||||
| @@ -340,7 +446,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| setProcessedQrCodes(new Set()); | setProcessedQrCodes(new Set()); | ||||
| setScannedOperatorId(null); | setScannedOperatorId(null); | ||||
| setScannedEquipmentId(null); | setScannedEquipmentId(null); | ||||
| setScannedStaffNo(null); // ✅ Add this | |||||
| setScannedStaffNo(null); // Add this | |||||
| setScannedEquipmentCode(null); | setScannedEquipmentCode(null); | ||||
| setIsAutoSubmitting(false); // 添加:重置自动提交状态 | setIsAutoSubmitting(false); // 添加:重置自动提交状态 | ||||
| setLineDetailForScan(null); | setLineDetailForScan(null); | ||||
| @@ -366,7 +472,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| setIsManualScanning(false); | setIsManualScanning(false); | ||||
| setIsAutoSubmitting(false); | setIsAutoSubmitting(false); | ||||
| setScannedStaffNo(null); // ✅ Add this | |||||
| setScannedStaffNo(null); // Add this | |||||
| setScannedEquipmentCode(null); | setScannedEquipmentCode(null); | ||||
| stopScan(); | stopScan(); | ||||
| resetScan(); | resetScan(); | ||||
| @@ -392,7 +498,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| isManualScanning, | isManualScanning, | ||||
| }); | }); | ||||
| // ✅ Update condition to check for either equipmentTypeSubTypeEquipmentNo OR equipmentDetailId | |||||
| // Update condition to check for either equipmentTypeSubTypeEquipmentNo OR equipmentDetailId | |||||
| if ( | if ( | ||||
| scanningLineId && | scanningLineId && | ||||
| scannedStaffNo !== null && | scannedStaffNo !== null && | ||||
| @@ -455,6 +561,13 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| }; | }; | ||||
| const selectedLine = lines.find(l => l.id === selectedLineId); | const selectedLine = lines.find(l => l.id === selectedLineId); | ||||
| // 添加组件卸载日志 | |||||
| useEffect(() => { | |||||
| return () => { | |||||
| console.log("🗑️ ProductionProcessDetail UNMOUNTING"); | |||||
| }; | |||||
| }, []); | |||||
| if (loading) { | if (loading) { | ||||
| return ( | return ( | ||||
| <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}> | <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}> | ||||
| @@ -474,188 +587,230 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| {!isExecutingLine ? ( | {!isExecutingLine ? ( | ||||
| /* ========== 步骤列表视图 ========== */ | /* ========== 步骤列表视图 ========== */ | ||||
| <TableContainer> | <TableContainer> | ||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Seq")}</TableCell> | |||||
| <TableCell>{t("Step Name")}</TableCell> | |||||
| <TableCell>{t("Description")}</TableCell> | |||||
| <TableCell>{t("EquipmentType-EquipmentName-Code")}</TableCell> | |||||
| <TableCell>{t("Operator")}</TableCell> | |||||
| <TableCell>{t("Assume End Time")}</TableCell> | |||||
| <TableCell> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("Time Information(mins)")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell align="center">{t("Status")}</TableCell> | |||||
| {!fromJosave&&(<TableCell align="center">{t("Action")}</TableCell>)} | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {lines.map((line) => { | |||||
| const status = (line as any).status || ''; | |||||
| const statusLower = status.toLowerCase(); | |||||
| const equipmentName = line.equipment_name || "-"; | |||||
| const isCompleted = statusLower === 'completed'; | |||||
| const isInProgress = statusLower === 'inprogress' || statusLower === 'in progress'; | |||||
| const isPaused = statusLower === 'paused'; | |||||
| const isPending = statusLower === 'pending' || status === ''; | |||||
| const isPass = statusLower === 'pass'; | |||||
| const isPassDisabled = isCompleted || isPass; | |||||
| return ( | |||||
| <TableRow key={line.id}> | |||||
| <TableCell>{line.seqNo}</TableCell> | |||||
| <TableCell> | |||||
| <Typography fontWeight={500}>{line.name}</Typography> | |||||
| </TableCell> | |||||
| <TableCell><Typography fontWeight={500}>{line.description || "-"}</Typography></TableCell> | |||||
| <TableCell><Typography fontWeight={500}>{line.equipmentDetailCode||equipmentName}</Typography></TableCell> | |||||
| <TableCell><Typography fontWeight={500}>{line.operatorName}</Typography></TableCell> | |||||
| <TableCell> | |||||
| <Typography fontWeight={500}> | |||||
| {line.startTime && line.durationInMinutes | |||||
| ? dayjs(line.startTime) | |||||
| .add(line.durationInMinutes, 'minute') | |||||
| .format('MM-DD HH:mm') | |||||
| : '-'} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="body2" > | |||||
| {t("Processing Time")}: {line.durationInMinutes}{t("mins")} | |||||
| </Typography> | |||||
| <Typography variant="body2" > | |||||
| {t("Setup Time")}: {line.prepTimeInMinutes} {t("mins")} | |||||
| </Typography> | |||||
| <Typography variant="body2" > | |||||
| {t("Changeover Time")}: {line.postProdTimeInMinutes} {t("mins")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| {isCompleted ? ( | |||||
| <Chip label={t("Completed")} color="success" size="small" | |||||
| onClick={async () => { | |||||
| setSelectedLineId(line.id); | |||||
| setShowOutputPage(false); // 不显示输出页面 | |||||
| setIsExecutingLine(true); | |||||
| await fetchProcessDetail(); | |||||
| }} | |||||
| /> | |||||
| ) : isInProgress ? ( | |||||
| <Chip label={t("In Progress")} color="primary" size="small" | |||||
| onClick={async () => { | |||||
| setSelectedLineId(line.id); | |||||
| setShowOutputPage(false); // 不显示输出页面 | |||||
| setIsExecutingLine(true); | |||||
| await fetchProcessDetail(); | |||||
| }} /> | |||||
| ) : isPending ? ( | |||||
| <Chip label={t("Pending")} color="default" size="small" /> | |||||
| ) : isPaused ? ( | |||||
| <Chip label={t("Paused")} color="warning" size="small" /> | |||||
| ) : isPass ? ( | |||||
| <Chip label={t("Pass")} color="success" size="small" /> | |||||
| ) : ( | |||||
| <Chip label={t("Unknown")} color="error" size="small" /> | |||||
| ) | |||||
| } | |||||
| </TableCell> | |||||
| {!fromJosave&&( | |||||
| <TableCell align="center"> | |||||
| <Stack direction="row" spacing={1} justifyContent="center"> | |||||
| {statusLower === 'pending' ? ( | |||||
| <> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| startIcon={<PlayArrowIcon />} | |||||
| onClick={() => handleStartLineWithScan(line.id)} | |||||
| > | |||||
| {t("Start")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| color="success" | |||||
| onClick={() => handlePassLine(line.id)} | |||||
| disabled={isPassDisabled} | |||||
| > | |||||
| {t("Pass")} | |||||
| </Button> | |||||
| </> | |||||
| ) : statusLower === 'in_progress' || statusLower === 'in progress' || statusLower === 'paused' ? ( | |||||
| <> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| startIcon={<CheckCircleIcon />} | |||||
| onClick={async () => { | |||||
| setSelectedLineId(line.id); | |||||
| setShowOutputPage(false); | |||||
| setIsExecutingLine(true); | |||||
| await fetchProcessDetail(); | |||||
| }} | |||||
| > | |||||
| {t("View")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| color="success" | |||||
| onClick={() => handlePassLine(line.id)} | |||||
| disabled={isPassDisabled} | |||||
| > | |||||
| {t("Pass")} | |||||
| </Button> | |||||
| </> | |||||
| ) : ( | |||||
| <> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| onClick={async() => { | |||||
| setSelectedLineId(line.id); | |||||
| setIsExecutingLine(true); | |||||
| await fetchProcessDetail(); | |||||
| }} | |||||
| > | |||||
| {t("View")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| color="success" | |||||
| onClick={() => handlePassLine(line.id)} | |||||
| disabled={isPassDisabled} | |||||
| > | |||||
| {t("Pass")} | |||||
| </Button> | |||||
| </> | |||||
| )} | |||||
| </Stack> | |||||
| </TableCell> | |||||
| )} | |||||
| </TableRow> | |||||
| ); | |||||
| })} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t(" ")}</TableCell> | |||||
| <TableCell>{t("Seq")}</TableCell> | |||||
| <TableCell>{t("Step Name")}</TableCell> | |||||
| <TableCell>{t("Description")}</TableCell> | |||||
| <TableCell>{t("EquipmentType-EquipmentName-Code")}</TableCell> | |||||
| <TableCell>{t("Operator")}</TableCell> | |||||
| <TableCell>{t("Assume End Time")}</TableCell> | |||||
| <TableCell> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="body2" sx={{ fontWeight: 500 }}> | |||||
| {t("Time Information(mins)")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell align="center">{t("Status")}</TableCell> | |||||
| {!fromJosave&&(<TableCell align="center">{t("Action")}</TableCell>)} | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {lines.map((line) => { | |||||
| const status = (line as any).status || ''; | |||||
| const statusLower = status.toLowerCase(); | |||||
| const equipmentName = line.equipment_name || "-"; | |||||
| const isCompleted = statusLower === 'completed'; | |||||
| const isInProgress = statusLower === 'inprogress' || statusLower === 'in progress'; | |||||
| const isPaused = statusLower === 'paused'; | |||||
| const isPending = statusLower === 'pending' || status === ''; | |||||
| const isPass = statusLower === 'pass'; | |||||
| const isPassDisabled = isCompleted || isPass; | |||||
| return ( | |||||
| <TableRow key={line.id}> | |||||
| <TableCell> | |||||
| <Fab | |||||
| size="small" | |||||
| color="primary" | |||||
| aria-label={t("Create New Line")} | |||||
| onClick={() => handleCreateNewLine(line.id)} | |||||
| sx={{ | |||||
| width: 32, | |||||
| height: 32, | |||||
| minHeight: 32, | |||||
| boxShadow: 1, | |||||
| '&:hover': { boxShadow: 3 }, | |||||
| }} | |||||
| > | |||||
| <AddIcon fontSize="small" /> | |||||
| </Fab> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Typography variant="body2" textAlign="center">{line.seqNo}</Typography> | |||||
| </Stack> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2" fontWeight={500}>{line.name}</Typography> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2" fontWeight={500} maxWidth={200} sx={{ wordBreak: 'break-word', whiteSpace: 'normal', lineHeight: 1.5 }}>{line.description || "-"}</Typography> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2" fontWeight={500}>{line.equipmentDetailCode||equipmentName}</Typography> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2" fontWeight={500}>{line.operatorName}</Typography> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2" fontWeight={500}> | |||||
| {line.startTime && line.durationInMinutes | |||||
| ? dayjs(line.startTime) | |||||
| .add(line.durationInMinutes, 'minute') | |||||
| .format('MM-DD HH:mm') | |||||
| : '-'} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> | |||||
| <Typography variant="body2"> | |||||
| {t("Processing Time")}: {line.durationInMinutes || 0}{t("mins")} | |||||
| </Typography> | |||||
| {processData?.jobOrderStatus === "planning" && ( | |||||
| <IconButton | |||||
| size="small" | |||||
| onClick={() => { | |||||
| console.log("🖱️ Edit button clicked for line:", line.id); | |||||
| handleOpenTimeDialog(line.id); | |||||
| }} | |||||
| sx={{ padding: 0.5 }} | |||||
| > | |||||
| <EditIcon fontSize="small" /> | |||||
| </IconButton> | |||||
| )} | |||||
| </Box> | |||||
| <Typography variant="body2"> | |||||
| {t("Setup Time")}: {line.prepTimeInMinutes || 0} {t("mins")} | |||||
| </Typography> | |||||
| <Typography variant="body2"> | |||||
| {t("Changeover Time")}: {line.postProdTimeInMinutes || 0} {t("mins")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| {isCompleted ? ( | |||||
| <Chip label={t("Completed")} color="success" size="small" | |||||
| onClick={async () => { | |||||
| setSelectedLineId(line.id); | |||||
| setShowOutputPage(false); | |||||
| setIsExecutingLine(true); | |||||
| await fetchProcessDetail(); | |||||
| }} | |||||
| /> | |||||
| ) : isInProgress ? ( | |||||
| <Chip label={t("In Progress")} color="primary" size="small" | |||||
| onClick={async () => { | |||||
| setSelectedLineId(line.id); | |||||
| setShowOutputPage(false); | |||||
| setIsExecutingLine(true); | |||||
| await fetchProcessDetail(); | |||||
| }} /> | |||||
| ) : isPending ? ( | |||||
| <Chip label={t("Pending")} color="default" size="small" /> | |||||
| ) : isPaused ? ( | |||||
| <Chip label={t("Paused")} color="warning" size="small" /> | |||||
| ) : isPass ? ( | |||||
| <Chip label={t("Pass")} color="success" size="small" /> | |||||
| ) : ( | |||||
| <Chip label={t("Unknown")} color="error" size="small" /> | |||||
| ) | |||||
| } | |||||
| </TableCell> | |||||
| {!fromJosave&&( | |||||
| <TableCell align="center"> | |||||
| <Stack direction="row" spacing={1} justifyContent="center"> | |||||
| {statusLower === 'pending' ? ( | |||||
| <> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| startIcon={<PlayArrowIcon />} | |||||
| onClick={() => handleStartLineWithScan(line.id)} | |||||
| > | |||||
| {t("Start")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| color="success" | |||||
| onClick={() => handlePassLine(line.id)} | |||||
| disabled={isPassDisabled} | |||||
| > | |||||
| {t("Pass")} | |||||
| </Button> | |||||
| </> | |||||
| ) : statusLower === 'in_progress' || statusLower === 'in progress' || statusLower === 'paused' ? ( | |||||
| <> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| startIcon={<CheckCircleIcon />} | |||||
| onClick={async () => { | |||||
| setSelectedLineId(line.id); | |||||
| setShowOutputPage(false); | |||||
| setIsExecutingLine(true); | |||||
| await fetchProcessDetail(); | |||||
| }} | |||||
| > | |||||
| {t("View")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| color="success" | |||||
| onClick={() => handlePassLine(line.id)} | |||||
| disabled={isPassDisabled} | |||||
| > | |||||
| {t("Pass")} | |||||
| </Button> | |||||
| </> | |||||
| ) : ( | |||||
| <> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| onClick={async() => { | |||||
| setSelectedLineId(line.id); | |||||
| setIsExecutingLine(true); | |||||
| await fetchProcessDetail(); | |||||
| }} | |||||
| > | |||||
| {t("View")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| color="success" | |||||
| onClick={() => handlePassLine(line.id)} | |||||
| disabled={isPassDisabled} | |||||
| > | |||||
| {t("Pass")} | |||||
| </Button> | |||||
| </> | |||||
| )} | |||||
| </Stack> | |||||
| </TableCell> | |||||
| )} | |||||
| </TableRow> | |||||
| ); | |||||
| })} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| ) : ( | ) : ( | ||||
| /* ========== 步骤执行视图 ========== */ | /* ========== 步骤执行视图 ========== */ | ||||
| <ProductionProcessStepExecution | <ProductionProcessStepExecution | ||||
| lineId={selectedLineId} | lineId={selectedLineId} | ||||
| onBack={handleBackFromStep} | onBack={handleBackFromStep} | ||||
| processData={processData} // ✅ 添加 | |||||
| allLines={lines} // ✅ 添加 | |||||
| jobOrderId={jobOrderId} // ✅ 添加 | |||||
| processData={processData} // 添加 | |||||
| allLines={lines} // 添加 | |||||
| jobOrderId={jobOrderId} // 添加 | |||||
| /> | /> | ||||
| )} | )} | ||||
| </Paper> | </Paper> | ||||
| @@ -703,13 +858,14 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| </Stack> | </Stack> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={() => { | |||||
| <Button type="button" onClick={() => { | |||||
| handleStopScan(); | handleStopScan(); | ||||
| setShowScanDialog(false); | setShowScanDialog(false); | ||||
| }}> | }}> | ||||
| {t("Cancel")} | {t("Cancel")} | ||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| type="button" | |||||
| variant="contained" | variant="contained" | ||||
| onClick={() => scanningLineId && handleSubmitScanAndStart(scanningLineId)} | onClick={() => scanningLineId && handleSubmitScanAndStart(scanningLineId)} | ||||
| disabled={!scannedStaffNo } | disabled={!scannedStaffNo } | ||||
| @@ -718,6 +874,102 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| <Dialog | |||||
| open={openTimeDialog} | |||||
| onClose={handleCloseTimeDialog} // 直接传递函数,不要包装 | |||||
| fullWidth | |||||
| maxWidth="sm" | |||||
| > | |||||
| <DialogTitle>{t("Update Time Information")}</DialogTitle> | |||||
| <DialogContent> | |||||
| <Stack spacing={2} sx={{ mt: 1 }}> | |||||
| <TextField | |||||
| label={t("Processing Time (mins)")} | |||||
| type="number" | |||||
| fullWidth | |||||
| value={timeValues.durationInMinutes} | |||||
| onChange={(e) => { | |||||
| console.log("⌨️ Processing Time onChange:", { | |||||
| value: e.target.value, | |||||
| openTimeDialog, | |||||
| editingLineId, | |||||
| timestamp: new Date().toISOString() | |||||
| }); | |||||
| const value = e.target.value === '' ? 0 : parseInt(e.target.value) || 0; | |||||
| setTimeValues(prev => ({ | |||||
| ...prev, | |||||
| durationInMinutes: Math.max(0, value) | |||||
| })); | |||||
| }} | |||||
| inputProps={{ | |||||
| min: 0, | |||||
| step: 1 | |||||
| }} | |||||
| /> | |||||
| <TextField | |||||
| label={t("Setup Time (mins)")} | |||||
| type="number" | |||||
| fullWidth | |||||
| value={timeValues.prepTimeInMinutes} | |||||
| onChange={(e) => { | |||||
| console.log("⌨️ Setup Time onChange:", { | |||||
| value: e.target.value, | |||||
| openTimeDialog, | |||||
| editingLineId, | |||||
| timestamp: new Date().toISOString() | |||||
| }); | |||||
| const value = e.target.value === '' ? 0 : parseInt(e.target.value) || 0; | |||||
| setTimeValues(prev => ({ | |||||
| ...prev, | |||||
| prepTimeInMinutes: Math.max(0, value) | |||||
| })); | |||||
| }} | |||||
| inputProps={{ | |||||
| min: 0, | |||||
| step: 1 | |||||
| }} | |||||
| /> | |||||
| <TextField | |||||
| label={t("Changeover Time (mins)")} | |||||
| type="number" | |||||
| fullWidth | |||||
| value={timeValues.postProdTimeInMinutes} | |||||
| onChange={(e) => { | |||||
| console.log("⌨️ Changeover Time onChange:", { | |||||
| value: e.target.value, | |||||
| openTimeDialog, | |||||
| editingLineId, | |||||
| timestamp: new Date().toISOString() | |||||
| }); | |||||
| const value = e.target.value === '' ? 0 : parseInt(e.target.value) || 0; | |||||
| setTimeValues(prev => ({ | |||||
| ...prev, | |||||
| postProdTimeInMinutes: Math.max(0, value) | |||||
| })); | |||||
| }} | |||||
| inputProps={{ | |||||
| min: 0, | |||||
| step: 1 | |||||
| }} | |||||
| /> | |||||
| </Stack> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button | |||||
| type="button" | |||||
| onClick={handleCloseTimeDialog} | |||||
| > | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button | |||||
| type="button" | |||||
| variant="contained" | |||||
| onClick={handleConfirmTimeUpdate} | |||||
| > | |||||
| {t("Save")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -23,8 +23,10 @@ import { | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { fetchProductProcessesByJobOrderId ,deleteJobOrder, updateProductProcessPriority, updateJoPlanStart} from "@/app/api/jo/actions"; | |||||
| import { fetchProductProcessesByJobOrderId ,deleteJobOrder, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine} from "@/app/api/jo/actions"; | |||||
| import ProductionProcessDetail from "./ProductionProcessDetail"; | import ProductionProcessDetail from "./ProductionProcessDetail"; | ||||
| import { BomCombo } from "@/app/api/bom"; | |||||
| import { fetchBomCombo } from "@/app/api/bom/index"; | |||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { OUTPUT_DATE_FORMAT, integerFormatter, arrayToDateString } from "@/app/utils/formatUtil"; | import { OUTPUT_DATE_FORMAT, integerFormatter, arrayToDateString } from "@/app/utils/formatUtil"; | ||||
| import StyledDataGrid from "../StyledDataGrid/StyledDataGrid"; | import StyledDataGrid from "../StyledDataGrid/StyledDataGrid"; | ||||
| @@ -79,7 +81,11 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp | |||||
| const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false); | const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false); | ||||
| const [openPlanStartDialog, setOpenPlanStartDialog] = useState(false); | const [openPlanStartDialog, setOpenPlanStartDialog] = useState(false); | ||||
| const [planStartDate, setPlanStartDate] = useState<dayjs.Dayjs | null>(null); | const [planStartDate, setPlanStartDate] = useState<dayjs.Dayjs | null>(null); | ||||
| const [openReqQtyDialog, setOpenReqQtyDialog] = useState(false); | |||||
| const [reqQtyMultiplier, setReqQtyMultiplier] = useState<number>(1); | |||||
| const [selectedBomForReqQty, setSelectedBomForReqQty] = useState<BomCombo | null>(null); | |||||
| const [bomCombo, setBomCombo] = useState<BomCombo[]>([]); | |||||
| const fetchData = useCallback(async () => { | const fetchData = useCallback(async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| @@ -97,6 +103,61 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp | |||||
| } | } | ||||
| }, [jobOrderId]); | }, [jobOrderId]); | ||||
| // 4. 添加处理函数(约第 166 行后) | |||||
| const handleOpenReqQtyDialog = useCallback(async () => { | |||||
| if (!processData || !processData.outputQty || !processData.outputQtyUom) { | |||||
| alert(t("BOM data not available")); | |||||
| return; | |||||
| } | |||||
| const baseOutputQty = processData.bomBaseQty; | |||||
| const currentMultiplier = baseOutputQty > 0 | |||||
| ? Math.round(processData.outputQty / baseOutputQty) | |||||
| : 1; | |||||
| const bomData = { | |||||
| id: processData.bomId || 0, | |||||
| value: processData.bomId || 0, | |||||
| label: processData.bomDescription || "", | |||||
| outputQty: baseOutputQty, | |||||
| outputQtyUom: processData.outputQtyUom, | |||||
| description: processData.bomDescription || "" | |||||
| }; | |||||
| setSelectedBomForReqQty(bomData); | |||||
| setReqQtyMultiplier(currentMultiplier); | |||||
| setOpenReqQtyDialog(true); | |||||
| }, [processData, t]); | |||||
| const handleCloseReqQtyDialog = useCallback(() => { | |||||
| setOpenReqQtyDialog(false); | |||||
| setSelectedBomForReqQty(null); | |||||
| setReqQtyMultiplier(1); | |||||
| }, []); | |||||
| const handleUpdateReqQty = useCallback(async (jobOrderId: number, newReqQty: number) => { | |||||
| try { | |||||
| const response = await updateJoReqQty({ | |||||
| id: jobOrderId, | |||||
| reqQty: Math.round(newReqQty) | |||||
| }); | |||||
| if (response) { | |||||
| await fetchData(); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("Error updating reqQty:", error); | |||||
| alert(t("update failed")); | |||||
| } | |||||
| }, [fetchData, t]); | |||||
| const handleConfirmReqQty = useCallback(async () => { | |||||
| if (!jobOrderId || !selectedBomForReqQty) return; | |||||
| const newReqQty = reqQtyMultiplier * selectedBomForReqQty.outputQty; | |||||
| await handleUpdateReqQty(jobOrderId, newReqQty); | |||||
| setOpenReqQtyDialog(false); | |||||
| setSelectedBomForReqQty(null); | |||||
| setReqQtyMultiplier(1); | |||||
| }, [jobOrderId, selectedBomForReqQty, reqQtyMultiplier, handleUpdateReqQty]); | |||||
| // 获取库存数据 | // 获取库存数据 | ||||
| useEffect(() => { | useEffect(() => { | ||||
| const fetchInventoryData = async () => { | const fetchInventoryData = async () => { | ||||
| @@ -302,6 +363,15 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||||
| fullWidth | fullWidth | ||||
| disabled={true} | disabled={true} | ||||
| value={processData?.outputQty + "(" + processData?.outputQtyUom + ")" || ""} | value={processData?.outputQty + "(" + processData?.outputQtyUom + ")" || ""} | ||||
| InputProps={{ | |||||
| endAdornment: (processData?.jobOrderStatus === "planning" ? ( | |||||
| <InputAdornment position="end"> | |||||
| <IconButton size="small" onClick={handleOpenReqQtyDialog}> | |||||
| <EditIcon fontSize="small" /> | |||||
| </IconButton> | |||||
| </InputAdornment> | |||||
| ) : null), | |||||
| }} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| @@ -681,7 +751,88 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| <Dialog | |||||
| open={openReqQtyDialog} | |||||
| onClose={handleCloseReqQtyDialog} | |||||
| fullWidth | |||||
| maxWidth="sm" | |||||
| > | |||||
| <DialogTitle>{t("Update Required Quantity")}</DialogTitle> | |||||
| <DialogContent> | |||||
| <Stack spacing={2} sx={{ mt: 1 }}> | |||||
| <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> | |||||
| <TextField | |||||
| label={t("Base Qty")} | |||||
| fullWidth | |||||
| type="number" | |||||
| variant="outlined" | |||||
| value={selectedBomForReqQty?.outputQty || 0} | |||||
| disabled | |||||
| InputProps={{ | |||||
| endAdornment: selectedBomForReqQty?.outputQtyUom ? ( | |||||
| <InputAdornment position="end"> | |||||
| <Typography variant="body2" sx={{ color: "text.secondary" }}> | |||||
| {selectedBomForReqQty.outputQtyUom} | |||||
| </Typography> | |||||
| </InputAdornment> | |||||
| ) : null | |||||
| }} | |||||
| sx={{ flex: 1 }} | |||||
| /> | |||||
| <Typography variant="body1" sx={{ color: "text.secondary" }}> | |||||
| × | |||||
| </Typography> | |||||
| <TextField | |||||
| label={t("Batch Count")} | |||||
| fullWidth | |||||
| type="number" | |||||
| variant="outlined" | |||||
| value={reqQtyMultiplier} | |||||
| onChange={(e) => { | |||||
| const val = e.target.value === "" ? 1 : Math.max(1, Math.floor(Number(e.target.value))); | |||||
| setReqQtyMultiplier(val); | |||||
| }} | |||||
| inputProps={{ | |||||
| min: 1, | |||||
| step: 1 | |||||
| }} | |||||
| sx={{ flex: 1 }} | |||||
| /> | |||||
| <Typography variant="body1" sx={{ color: "text.secondary" }}> | |||||
| = | |||||
| </Typography> | |||||
| <TextField | |||||
| label={t("Req. Qty")} | |||||
| fullWidth | |||||
| variant="outlined" | |||||
| type="number" | |||||
| value={selectedBomForReqQty ? (reqQtyMultiplier * selectedBomForReqQty.outputQty) : ""} | |||||
| disabled | |||||
| InputProps={{ | |||||
| endAdornment: selectedBomForReqQty?.outputQtyUom ? ( | |||||
| <InputAdornment position="end"> | |||||
| <Typography variant="body2" sx={{ color: "text.secondary" }}> | |||||
| {selectedBomForReqQty.outputQtyUom} | |||||
| </Typography> | |||||
| </InputAdornment> | |||||
| ) : null | |||||
| }} | |||||
| sx={{ flex: 1 }} | |||||
| /> | |||||
| </Box> | |||||
| </Stack> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={handleCloseReqQtyDialog}>{t("Cancel")}</Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={handleConfirmReqQty} | |||||
| disabled={!selectedBomForReqQty || reqQtyMultiplier < 1} | |||||
| > | |||||
| {t("Save")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </Box> | </Box> | ||||
| </Box> | </Box> | ||||
| @@ -987,6 +987,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| lineId={lineId} | lineId={lineId} | ||||
| bomDescription={processData?.bomDescription} | bomDescription={processData?.bomDescription} | ||||
| isLastLine={shouldShowBagForm} | isLastLine={shouldShowBagForm} | ||||
| submitedBagRecord={lineDetail?.submitedBagRecord} | |||||
| onRefresh={handleRefreshLineDetail} | onRefresh={handleRefreshLineDetail} | ||||
| /> | /> | ||||
| )} | )} | ||||
| @@ -0,0 +1,194 @@ | |||||
| "use client"; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| Card, | |||||
| CardContent, | |||||
| CardActions, | |||||
| Stack, | |||||
| Typography, | |||||
| Chip, | |||||
| CircularProgress, | |||||
| TablePagination, | |||||
| Grid, | |||||
| LinearProgress, | |||||
| } from "@mui/material"; | |||||
| import { useState, useCallback, useEffect } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { | |||||
| getApproverStockTakeRecords, | |||||
| AllPickedStockTakeListReponse, | |||||
| } from "@/app/api/stockTake/actions"; | |||||
| import dayjs from "dayjs"; | |||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||||
| const PER_PAGE = 6; | |||||
| interface ApproverCardListProps { | |||||
| onCardClick: (session: AllPickedStockTakeListReponse) => void; | |||||
| } | |||||
| const ApproverCardList: React.FC<ApproverCardListProps> = ({ onCardClick }) => { | |||||
| const { t } = useTranslation(["inventory", "common"]); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]); | |||||
| const [page, setPage] = useState(0); | |||||
| const [creating, setCreating] = useState(false); | |||||
| const fetchStockTakeSessions = useCallback(async () => { | |||||
| setLoading(true); | |||||
| try { | |||||
| const data = await getApproverStockTakeRecords(); | |||||
| setStockTakeSessions(Array.isArray(data) ? data : []); | |||||
| setPage(0); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setStockTakeSessions([]); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }, []); | |||||
| useEffect(() => { | |||||
| fetchStockTakeSessions(); | |||||
| }, [fetchStockTakeSessions]); | |||||
| const startIdx = page * PER_PAGE; | |||||
| const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | |||||
| const getStatusColor = (status: string | null) => { | |||||
| if (!status) return "default"; | |||||
| const statusLower = status.toLowerCase(); | |||||
| if (statusLower === "completed") return "success"; | |||||
| if (statusLower === "in_progress" || statusLower === "processing") return "primary"; | |||||
| if (statusLower === "no_cycle") return "default"; | |||||
| if (statusLower === "approving") return "info"; | |||||
| return "warning"; | |||||
| }; | |||||
| const getCompletionRate = (session: AllPickedStockTakeListReponse): number => { | |||||
| if (session.totalInventoryLotNumber === 0) return 0; | |||||
| return Math.round((session.currentStockTakeItemNumber / session.totalInventoryLotNumber) * 100); | |||||
| }; | |||||
| if (loading) { | |||||
| return ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| return ( | |||||
| <Box> | |||||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Total Sections")}: {stockTakeSessions.length} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Grid container spacing={2}> | |||||
| {paged.map((session) => { | |||||
| const statusColor = getStatusColor(session.status); | |||||
| const lastStockTakeDate = session.lastStockTakeDate | |||||
| ? dayjs(session.lastStockTakeDate).format(OUTPUT_DATE_FORMAT) | |||||
| : "-"; | |||||
| const completionRate = getCompletionRate(session); | |||||
| const isDisabled = session.status === null; | |||||
| return ( | |||||
| <Grid key={session.id} item xs={12} sm={6} md={4}> | |||||
| <Card | |||||
| sx={{ | |||||
| minHeight: 200, | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| border: "1px solid", | |||||
| borderColor: statusColor === "success" ? "success.main" : "primary.main", | |||||
| cursor: isDisabled ? "not-allowed" : "pointer", | |||||
| opacity: isDisabled ? 0.6 : 1, | |||||
| "&:hover": { | |||||
| boxShadow: isDisabled ? 0 : 4, | |||||
| }, | |||||
| }} | |||||
| onClick={() => { | |||||
| if (!isDisabled && session.status !== null) { | |||||
| onCardClick(session); | |||||
| } | |||||
| }} | |||||
| > | |||||
| <CardContent sx={{ pb: 1, flexGrow: 1 }}> | |||||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}> | |||||
| <Typography variant="subtitle1" fontWeight={600}> | |||||
| {t("Section")}: {session.stockTakeSession} | |||||
| </Typography> | |||||
| {session.status ? ( | |||||
| <Chip size="small" label={t(session.status)} color={statusColor as any} /> | |||||
| ) : ( | |||||
| <Chip size="small" label={t(" ")} color="default" /> | |||||
| )} | |||||
| </Stack> | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||||
| {t("Last Stock Take Date")}: {lastStockTakeDate || "-"} | |||||
| </Typography> | |||||
| {session.totalInventoryLotNumber > 0 && ( | |||||
| <Box sx={{ mt: 2 }}> | |||||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.5 }}> | |||||
| <Typography variant="body2" fontWeight={600}> | |||||
| {t("Progress")} | |||||
| </Typography> | |||||
| <Typography variant="body2" fontWeight={600}> | |||||
| {completionRate}% | |||||
| </Typography> | |||||
| </Stack> | |||||
| <LinearProgress | |||||
| variant="determinate" | |||||
| value={completionRate} | |||||
| sx={{ height: 8, borderRadius: 1 }} | |||||
| /> | |||||
| </Box> | |||||
| )} | |||||
| </CardContent> | |||||
| <CardActions sx={{ pt: 0.5 }}> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| disabled={isDisabled} | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| if (!isDisabled) { | |||||
| onCardClick(session); | |||||
| } | |||||
| }} | |||||
| > | |||||
| {t("View Details")} | |||||
| </Button> | |||||
| </CardActions> | |||||
| </Card> | |||||
| </Grid> | |||||
| ); | |||||
| })} | |||||
| </Grid> | |||||
| {stockTakeSessions.length > 0 && ( | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={stockTakeSessions.length} | |||||
| page={page} | |||||
| rowsPerPage={PER_PAGE} | |||||
| onPageChange={(e, p) => setPage(p)} | |||||
| rowsPerPageOptions={[PER_PAGE]} | |||||
| /> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default ApproverCardList; | |||||
| @@ -0,0 +1,467 @@ | |||||
| "use client"; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| Stack, | |||||
| Typography, | |||||
| Chip, | |||||
| CircularProgress, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Paper, | |||||
| TextField, | |||||
| Radio, | |||||
| } from "@mui/material"; | |||||
| import { useState, useCallback, useEffect, useRef } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { | |||||
| AllPickedStockTakeListReponse, | |||||
| getInventoryLotDetailsBySection, | |||||
| InventoryLotDetailResponse, | |||||
| saveApproverStockTakeRecord, | |||||
| SaveApproverStockTakeRecordRequest, | |||||
| BatchSaveApproverStockTakeRecordRequest, | |||||
| batchSaveApproverStockTakeRecords, | |||||
| updateStockTakeRecordStatusToNotMatch, | |||||
| } from "@/app/api/stockTake/actions"; | |||||
| import { useSession } from "next-auth/react"; | |||||
| import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import dayjs from "dayjs"; | |||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||||
| interface ApproverStockTakeProps { | |||||
| selectedSession: AllPickedStockTakeListReponse; | |||||
| onBack: () => void; | |||||
| onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | |||||
| } | |||||
| type QtySelectionType = "first" | "second" | "approver"; | |||||
| const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| selectedSession, | |||||
| onBack, | |||||
| onSnackbar, | |||||
| }) => { | |||||
| const { t } = useTranslation(["inventory", "common"]); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||||
| const [loadingDetails, setLoadingDetails] = useState(false); | |||||
| // 每个记录的选择状态,key 为 detail.id | |||||
| const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | |||||
| const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | |||||
| const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({}); | |||||
| const [saving, setSaving] = useState(false); | |||||
| const [batchSaving, setBatchSaving] = useState(false); | |||||
| const [updatingStatus, setUpdatingStatus] = useState(false); | |||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||||
| const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | |||||
| useEffect(() => { | |||||
| const loadDetails = async () => { | |||||
| setLoadingDetails(true); | |||||
| try { | |||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setInventoryLotDetails([]); | |||||
| } finally { | |||||
| setLoadingDetails(false); | |||||
| } | |||||
| }; | |||||
| loadDetails(); | |||||
| }, [selectedSession]); | |||||
| const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | |||||
| if (!selectedSession || !currentUserId) { | |||||
| return; | |||||
| } | |||||
| const selection = qtySelection[detail.id] || "first"; | |||||
| let finalQty: number; | |||||
| let finalBadQty: number; | |||||
| if (selection === "first") { | |||||
| if (!detail.firstStockTakeQty || detail.firstStockTakeQty === 0) { | |||||
| onSnackbar(t("First QTY is not available"), "error"); | |||||
| return; | |||||
| } | |||||
| finalQty = detail.firstStockTakeQty; | |||||
| finalBadQty = detail.firstBadQty || 0; | |||||
| } else if (selection === "second") { | |||||
| if (!detail.secondStockTakeQty || detail.secondStockTakeQty === 0) { | |||||
| onSnackbar(t("Second QTY is not available"), "error"); | |||||
| return; | |||||
| } | |||||
| finalQty = detail.secondStockTakeQty; | |||||
| finalBadQty = detail.secondBadQty || 0; | |||||
| } else { | |||||
| // Approver input | |||||
| const approverQtyValue = approverQty[detail.id]; | |||||
| const approverBadQtyValue = approverBadQty[detail.id]; | |||||
| if (!approverQtyValue || !approverBadQtyValue) { | |||||
| onSnackbar(t("Please enter Approver QTY and Bad QTY"), "error"); | |||||
| return; | |||||
| } | |||||
| finalQty = parseFloat(approverQtyValue); | |||||
| finalBadQty = parseFloat(approverBadQtyValue); | |||||
| } | |||||
| setSaving(true); | |||||
| try { | |||||
| const request: SaveApproverStockTakeRecordRequest = { | |||||
| stockTakeRecordId: detail.stockTakeRecordId || null, | |||||
| qty: finalQty, | |||||
| badQty: finalBadQty, | |||||
| approverId: currentUserId, | |||||
| approverQty: selection === "approver" ? finalQty : null, | |||||
| approverBadQty: selection === "approver" ? finalBadQty : null, | |||||
| }; | |||||
| await saveApproverStockTakeRecord( | |||||
| request, | |||||
| selectedSession.stockTakeId | |||||
| ); | |||||
| onSnackbar(t("Approver stock take record saved successfully"), "success"); | |||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| } catch (e: any) { | |||||
| console.error("Save approver stock take record error:", e); | |||||
| let errorMessage = t("Failed to save approver stock take record"); | |||||
| if (e?.message) { | |||||
| errorMessage = e.message; | |||||
| } else if (e?.response) { | |||||
| try { | |||||
| const errorData = await e.response.json(); | |||||
| errorMessage = errorData.message || errorData.error || errorMessage; | |||||
| } catch { | |||||
| // ignore | |||||
| } | |||||
| } | |||||
| onSnackbar(errorMessage, "error"); | |||||
| } finally { | |||||
| setSaving(false); | |||||
| } | |||||
| }, [selectedSession, qtySelection, approverQty, approverBadQty, t, currentUserId, onSnackbar]); | |||||
| const handleUpdateStatusToNotMatch = useCallback(async (detail: InventoryLotDetailResponse) => { | |||||
| if (!detail.stockTakeRecordId) { | |||||
| onSnackbar(t("Stock take record ID is required"), "error"); | |||||
| return; | |||||
| } | |||||
| setUpdatingStatus(true); | |||||
| try { | |||||
| await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId); | |||||
| onSnackbar(t("Stock take record status updated to not match"), "success"); | |||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| } catch (e: any) { | |||||
| console.error("Update stock take record status error:", e); | |||||
| let errorMessage = t("Failed to update stock take record status"); | |||||
| if (e?.message) { | |||||
| errorMessage = e.message; | |||||
| } else if (e?.response) { | |||||
| try { | |||||
| const errorData = await e.response.json(); | |||||
| errorMessage = errorData.message || errorData.error || errorMessage; | |||||
| } catch { | |||||
| // ignore | |||||
| } | |||||
| } | |||||
| onSnackbar(errorMessage, "error"); | |||||
| } finally { | |||||
| setUpdatingStatus(false); | |||||
| } | |||||
| }, [selectedSession, t, onSnackbar]); | |||||
| const handleBatchSubmitAll = useCallback(async () => { | |||||
| if (!selectedSession || !currentUserId) { | |||||
| console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId'); | |||||
| return; | |||||
| } | |||||
| console.log('handleBatchSubmitAll: Starting batch approver save...'); | |||||
| setBatchSaving(true); | |||||
| try { | |||||
| const request: BatchSaveApproverStockTakeRecordRequest = { | |||||
| stockTakeId: selectedSession.stockTakeId, | |||||
| stockTakeSection: selectedSession.stockTakeSession, | |||||
| approverId: currentUserId, | |||||
| }; | |||||
| const result = await batchSaveApproverStockTakeRecords(request); | |||||
| console.log('handleBatchSubmitAll: Result:', result); | |||||
| onSnackbar( | |||||
| t("Batch approver save completed: {{success}} success, {{errors}} errors", { | |||||
| success: result.successCount, | |||||
| errors: result.errorCount, | |||||
| }), | |||||
| result.errorCount > 0 ? "warning" : "success" | |||||
| ); | |||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| } catch (e: any) { | |||||
| console.error("handleBatchSubmitAll: Error:", e); | |||||
| let errorMessage = t("Failed to batch save approver stock take records"); | |||||
| if (e?.message) { | |||||
| errorMessage = e.message; | |||||
| } else if (e?.response) { | |||||
| try { | |||||
| const errorData = await e.response.json(); | |||||
| errorMessage = errorData.message || errorData.error || errorMessage; | |||||
| } catch { | |||||
| // ignore | |||||
| } | |||||
| } | |||||
| onSnackbar(errorMessage, "error"); | |||||
| } finally { | |||||
| setBatchSaving(false); | |||||
| } | |||||
| }, [selectedSession, t, currentUserId, onSnackbar]); | |||||
| useEffect(() => { | |||||
| handleBatchSubmitAllRef.current = handleBatchSubmitAll; | |||||
| }, [handleBatchSubmitAll]); | |||||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||||
| // Only allow editing if there's a first stock take qty | |||||
| if (!detail.firstStockTakeQty || detail.firstStockTakeQty === 0) { | |||||
| return true; | |||||
| } | |||||
| return false; | |||||
| }, []); | |||||
| return ( | |||||
| <Box> | |||||
| <Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}> | |||||
| {t("Back to List")} | |||||
| </Button> | |||||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||||
| {t("Stock Take Section")}: {selectedSession.stockTakeSession} | |||||
| </Typography> | |||||
| {loadingDetails ? ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ) : ( | |||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||||
| <TableCell>{t("Item")}</TableCell> | |||||
| <TableCell>{t("Stock Take Qty")}</TableCell> | |||||
| <TableCell>{t("Remark")}</TableCell> | |||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Record Status")}</TableCell> | |||||
| <TableCell>{t("Action")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {inventoryLotDetails.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={7} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| inventoryLotDetails.map((detail) => { | |||||
| const submitDisabled = isSubmitDisabled(detail); | |||||
| const hasFirst = detail.firstStockTakeQty != null && detail.firstStockTakeQty > 0; | |||||
| const hasSecond = detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0; | |||||
| const selection = qtySelection[detail.id] || "first"; | |||||
| return ( | |||||
| <TableRow key={detail.id}> | |||||
| <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell> | |||||
| <TableCell sx={{ | |||||
| maxWidth: 150, | |||||
| wordBreak: 'break-word', | |||||
| whiteSpace: 'normal', | |||||
| lineHeight: 1.5 | |||||
| }}> | |||||
| <Stack spacing={0.5}> | |||||
| <Box>{detail.itemCode || "-"} {detail.itemName || "-"}</Box> | |||||
| <Box>{detail.lotNo || "-"}</Box> | |||||
| <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | |||||
| <Box><Chip size="small" label={t(detail.status)} color="default" /></Box> | |||||
| </Stack> | |||||
| </TableCell> | |||||
| <TableCell sx={{ minWidth: 300 }}> | |||||
| {detail.finalQty != null ? ( | |||||
| // 提交后只显示差异行 | |||||
| <Stack spacing={0.5}> | |||||
| <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'primary.main' }}> | |||||
| {t("Difference")}: {detail.finalQty?.toFixed(2) || "0.00"} - {(detail.availableQty || 0).toFixed(2)} = {((detail.finalQty || 0) - (detail.availableQty || 0)).toFixed(2)} | |||||
| </Typography> | |||||
| </Stack> | |||||
| ) : ( | |||||
| <Stack spacing={1}> | |||||
| {/* 第一行:First Qty(默认选中) */} | |||||
| {hasFirst && ( | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Radio | |||||
| size="small" | |||||
| checked={selection === "first"} | |||||
| onChange={() => setQtySelection({ ...qtySelection, [detail.id]: "first" })} | |||||
| /> | |||||
| <Typography variant="body2"> | |||||
| {t("First")}: {(detail.firstStockTakeQty??0)+(detail.firstBadQty??0) || "0.00"} ({detail.firstBadQty??0}) | |||||
| </Typography> | |||||
| </Stack> | |||||
| )} | |||||
| {/* 第二行:Second Qty(如果存在) */} | |||||
| {hasSecond && ( | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Radio | |||||
| size="small" | |||||
| checked={selection === "second"} | |||||
| onChange={() => setQtySelection({ ...qtySelection, [detail.id]: "second" })} | |||||
| /> | |||||
| <Typography variant="body2"> | |||||
| {t("Second")}: {(detail.secondStockTakeQty??0)+(detail.secondBadQty??0) || "0.00"} ({detail.secondBadQty??0}) | |||||
| </Typography> | |||||
| </Stack> | |||||
| )} | |||||
| {/* 第三行:Approver Input(仅在 second qty 存在时显示) */} | |||||
| {hasSecond && ( | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Radio | |||||
| size="small" | |||||
| checked={selection === "approver"} | |||||
| onChange={() => setQtySelection({ ...qtySelection, [detail.id]: "approver" })} | |||||
| /> | |||||
| <Typography variant="body2">{t("Approver Input")}:</Typography> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={approverQty[detail.id] || ""} | |||||
| onChange={(e) => setApproverQty({ ...approverQty, [detail.id]: e.target.value })} | |||||
| sx={{ width: 100 }} | |||||
| disabled={selection !== "approver"} | |||||
| /> | |||||
| <Typography variant="body2">-</Typography> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={approverBadQty[detail.id] || ""} | |||||
| onChange={(e) => setApproverBadQty({ ...approverBadQty, [detail.id]: e.target.value })} | |||||
| sx={{ width: 100 }} | |||||
| disabled={selection !== "approver"} | |||||
| /> | |||||
| </Stack> | |||||
| )} | |||||
| {/* 差异行:显示 selected qty - bookqty = result */} | |||||
| {(() => { | |||||
| let selectedQty = 0; | |||||
| if (selection === "first") { | |||||
| selectedQty = detail.firstStockTakeQty || 0; | |||||
| } else if (selection === "second") { | |||||
| selectedQty = detail.secondStockTakeQty || 0; | |||||
| } else if (selection === "approver") { | |||||
| selectedQty = parseFloat(approverQty[detail.id] || "0") || 0; | |||||
| } | |||||
| const bookQty = detail.availableQty || 0; | |||||
| const difference = selectedQty - bookQty; | |||||
| return ( | |||||
| <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'primary.main' }}> | |||||
| {t("Difference")}: {selectedQty.toFixed(2)} - {bookQty.toFixed(2)} = {difference.toFixed(2)} | |||||
| </Typography> | |||||
| ); | |||||
| })()} | |||||
| </Stack> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2"> | |||||
| {detail.remarks || "-"} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| <TableCell> | |||||
| {detail.stockTakeRecordStatus === "pass" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" /> | |||||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" /> | |||||
| ) : ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus || "")} color="default" /> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {detail.stockTakeRecordId && detail.stockTakeRecordStatus !== "notMatch" && ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| color="warning" | |||||
| onClick={() => handleUpdateStatusToNotMatch(detail)} | |||||
| disabled={updatingStatus || detail.stockTakeRecordStatus === "completed"} | |||||
| > | |||||
| {t("ReStockTake")} | |||||
| </Button> | |||||
| )} | |||||
| {detail.finalQty == null && ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => handleSaveApproverStockTake(detail)} | |||||
| disabled={saving || submitDisabled || detail.stockTakeRecordStatus === "completed"} | |||||
| > | |||||
| {t("Save")} | |||||
| </Button> | |||||
| )} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default ApproverStockTake; | |||||
| @@ -0,0 +1,211 @@ | |||||
| "use client"; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| Card, | |||||
| CardContent, | |||||
| CardActions, | |||||
| Stack, | |||||
| Typography, | |||||
| Chip, | |||||
| CircularProgress, | |||||
| TablePagination, | |||||
| Grid, | |||||
| LinearProgress, | |||||
| } from "@mui/material"; | |||||
| import { useState, useCallback, useEffect } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { | |||||
| getStockTakeRecords, | |||||
| AllPickedStockTakeListReponse, | |||||
| createStockTakeForSections, | |||||
| } from "@/app/api/stockTake/actions"; | |||||
| import dayjs from "dayjs"; | |||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||||
| const PER_PAGE = 6; | |||||
| interface PickerCardListProps { | |||||
| onCardClick: (session: AllPickedStockTakeListReponse) => void; | |||||
| onReStockTakeClick: (session: AllPickedStockTakeListReponse) => void; | |||||
| } | |||||
| const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockTakeClick }) => { | |||||
| const { t } = useTranslation(["inventory", "common"]); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]); | |||||
| const [page, setPage] = useState(0); | |||||
| const [creating, setCreating] = useState(false); | |||||
| const fetchStockTakeSessions = useCallback(async () => { | |||||
| setLoading(true); | |||||
| try { | |||||
| const data = await getStockTakeRecords(); | |||||
| setStockTakeSessions(Array.isArray(data) ? data : []); | |||||
| setPage(0); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setStockTakeSessions([]); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }, []); | |||||
| useEffect(() => { | |||||
| fetchStockTakeSessions(); | |||||
| }, [fetchStockTakeSessions]); | |||||
| const startIdx = page * PER_PAGE; | |||||
| const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | |||||
| const handleCreateStockTake = useCallback(async () => { | |||||
| setCreating(true); | |||||
| try { | |||||
| const result = await createStockTakeForSections(); | |||||
| const createdCount = Object.values(result).filter(msg => msg.startsWith("Created:")).length; | |||||
| const skippedCount = Object.values(result).filter(msg => msg.startsWith("Skipped:")).length; | |||||
| const errorCount = Object.values(result).filter(msg => msg.startsWith("Error:")).length; | |||||
| let message = `${t("Created")}: ${createdCount}, ${t("Skipped")}: ${skippedCount}`; | |||||
| if (errorCount > 0) { | |||||
| message += `, ${t("Errors")}: ${errorCount}`; | |||||
| } | |||||
| console.log(message); | |||||
| await fetchStockTakeSessions(); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| } finally { | |||||
| setCreating(false); | |||||
| } | |||||
| }, [fetchStockTakeSessions, t]); | |||||
| const getStatusColor = (status: string) => { | |||||
| const statusLower = status.toLowerCase(); | |||||
| if (statusLower === "completed") return "success"; | |||||
| if (statusLower === "in_progress" || statusLower === "processing") return "primary"; | |||||
| if (statusLower === "approving") return "info"; | |||||
| if (statusLower === "no_cycle") return "default"; | |||||
| return "warning"; | |||||
| }; | |||||
| const getCompletionRate = (session: AllPickedStockTakeListReponse): number => { | |||||
| if (session.totalInventoryLotNumber === 0) return 0; | |||||
| return Math.round((session.currentStockTakeItemNumber / session.totalInventoryLotNumber) * 100); | |||||
| }; | |||||
| if (loading) { | |||||
| return ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| return ( | |||||
| <Box> | |||||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Total Sections")}: {stockTakeSessions.length} | |||||
| </Typography> | |||||
| <Button | |||||
| variant="contained" | |||||
| color="primary" | |||||
| onClick={handleCreateStockTake} | |||||
| disabled={creating} | |||||
| > | |||||
| {creating ? <CircularProgress size={20} /> : t("Create Stock Take for All Sections")} | |||||
| </Button> | |||||
| </Box> | |||||
| <Grid container spacing={2}> | |||||
| {paged.map((session) => { | |||||
| const statusColor = getStatusColor(session.status || ""); | |||||
| const lastStockTakeDate = session.lastStockTakeDate | |||||
| ? dayjs(session.lastStockTakeDate).format(OUTPUT_DATE_FORMAT) | |||||
| : "-"; | |||||
| const completionRate = getCompletionRate(session); | |||||
| return ( | |||||
| <Grid key={session.id} item xs={12} sm={6} md={4}> | |||||
| <Card | |||||
| sx={{ | |||||
| minHeight: 200, | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| border: "1px solid", | |||||
| borderColor: statusColor === "success" ? "success.main" : "primary.main", | |||||
| }} | |||||
| > | |||||
| <CardContent sx={{ pb: 1, flexGrow: 1 }}> | |||||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}> | |||||
| <Typography variant="subtitle1" fontWeight={600}> | |||||
| {t("Section")}: {session.stockTakeSession} | |||||
| </Typography> | |||||
| <Chip size="small" label={t(session.status || "")} color={statusColor as any} /> | |||||
| </Stack> | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||||
| {t("Last Stock Take Date")}: {lastStockTakeDate || "-"} | |||||
| </Typography> | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Stock Taker")}: {session.stockTakerName}</Typography> | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Total Item Number")}: {session.totalItemNumber}</Typography> | |||||
| {session.totalInventoryLotNumber > 0 && ( | |||||
| <Box sx={{ mt: 2 }}> | |||||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.5 }}> | |||||
| <Typography variant="body2" fontWeight={600}> | |||||
| {t("Progress")} | |||||
| </Typography> | |||||
| <Typography variant="body2" fontWeight={600}> | |||||
| {completionRate}% | |||||
| </Typography> | |||||
| </Stack> | |||||
| <LinearProgress | |||||
| variant="determinate" | |||||
| value={completionRate} | |||||
| sx={{ height: 8, borderRadius: 1 }} | |||||
| /> | |||||
| </Box> | |||||
| )} | |||||
| </CardContent> | |||||
| <CardActions sx={{ pt: 0.5 }}> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| onClick={() => onCardClick(session)} | |||||
| > | |||||
| {t("View Details")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| onClick={() => onReStockTakeClick(session)} | |||||
| > | |||||
| {t("View ReStockTake")} | |||||
| </Button> | |||||
| </CardActions> | |||||
| </Card> | |||||
| </Grid> | |||||
| ); | |||||
| })} | |||||
| </Grid> | |||||
| {stockTakeSessions.length > 0 && ( | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={stockTakeSessions.length} | |||||
| page={page} | |||||
| rowsPerPage={PER_PAGE} | |||||
| onPageChange={(e, p) => setPage(p)} | |||||
| rowsPerPageOptions={[PER_PAGE]} | |||||
| /> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default PickerCardList; | |||||
| @@ -0,0 +1,543 @@ | |||||
| "use client"; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| Stack, | |||||
| Typography, | |||||
| Chip, | |||||
| CircularProgress, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Paper, | |||||
| TextField, | |||||
| } from "@mui/material"; | |||||
| import { useState, useCallback, useEffect, useRef } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { | |||||
| AllPickedStockTakeListReponse, | |||||
| getInventoryLotDetailsBySection, | |||||
| InventoryLotDetailResponse, | |||||
| saveStockTakeRecord, | |||||
| SaveStockTakeRecordRequest, | |||||
| BatchSaveStockTakeRecordRequest, | |||||
| batchSaveStockTakeRecords, | |||||
| getInventoryLotDetailsBySectionNotMatch | |||||
| } from "@/app/api/stockTake/actions"; | |||||
| import { useSession } from "next-auth/react"; | |||||
| import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import dayjs from "dayjs"; | |||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||||
| interface PickerStockTakeProps { | |||||
| selectedSession: AllPickedStockTakeListReponse; | |||||
| onBack: () => void; | |||||
| onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | |||||
| } | |||||
| const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| selectedSession, | |||||
| onBack, | |||||
| onSnackbar, | |||||
| }) => { | |||||
| const { t } = useTranslation(["inventory", "common"]); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||||
| const [loadingDetails, setLoadingDetails] = useState(false); | |||||
| // 编辑状态 | |||||
| const [editingRecord, setEditingRecord] = useState<InventoryLotDetailResponse | null>(null); | |||||
| const [firstQty, setFirstQty] = useState<string>(""); | |||||
| const [secondQty, setSecondQty] = useState<string>(""); | |||||
| const [firstBadQty, setFirstBadQty] = useState<string>(""); | |||||
| const [secondBadQty, setSecondBadQty] = useState<string>(""); | |||||
| const [remark, setRemark] = useState<string>(""); | |||||
| const [saving, setSaving] = useState(false); | |||||
| const [batchSaving, setBatchSaving] = useState(false); | |||||
| const [shortcutInput, setShortcutInput] = useState<string>(""); | |||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||||
| const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | |||||
| useEffect(() => { | |||||
| const loadDetails = async () => { | |||||
| setLoadingDetails(true); | |||||
| try { | |||||
| const details = await getInventoryLotDetailsBySectionNotMatch( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setInventoryLotDetails([]); | |||||
| } finally { | |||||
| setLoadingDetails(false); | |||||
| } | |||||
| }; | |||||
| loadDetails(); | |||||
| }, [selectedSession]); | |||||
| const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | |||||
| setEditingRecord(detail); | |||||
| setFirstQty(detail.firstStockTakeQty?.toString() || ""); | |||||
| setSecondQty(detail.secondStockTakeQty?.toString() || ""); | |||||
| setFirstBadQty(detail.firstBadQty?.toString() || ""); | |||||
| setSecondBadQty(detail.secondBadQty?.toString() || ""); | |||||
| setRemark(detail.remarks || ""); | |||||
| }, []); | |||||
| const handleCancelEdit = useCallback(() => { | |||||
| setEditingRecord(null); | |||||
| setFirstQty(""); | |||||
| setSecondQty(""); | |||||
| setFirstBadQty(""); | |||||
| setSecondBadQty(""); | |||||
| setRemark(""); | |||||
| }, []); | |||||
| const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | |||||
| if (!selectedSession || !currentUserId) { | |||||
| return; | |||||
| } | |||||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | |||||
| const qty = isFirstSubmit ? firstQty : secondQty; | |||||
| const badQty = isFirstSubmit ? firstBadQty : secondBadQty; | |||||
| if (!qty || !badQty) { | |||||
| onSnackbar( | |||||
| isFirstSubmit | |||||
| ? t("Please enter QTY and Bad QTY") | |||||
| : t("Please enter Second QTY and Bad QTY"), | |||||
| "error" | |||||
| ); | |||||
| return; | |||||
| } | |||||
| setSaving(true); | |||||
| try { | |||||
| const request: SaveStockTakeRecordRequest = { | |||||
| stockTakeRecordId: detail.stockTakeRecordId || null, | |||||
| inventoryLotLineId: detail.id, | |||||
| qty: parseFloat(qty), | |||||
| badQty: parseFloat(badQty), | |||||
| remark: isSecondSubmit ? (remark || null) : null, | |||||
| }; | |||||
| console.log('handleSaveStockTake: request:', request); | |||||
| console.log('handleSaveStockTake: selectedSession.stockTakeId:', selectedSession.stockTakeId); | |||||
| console.log('handleSaveStockTake: currentUserId:', currentUserId); | |||||
| await saveStockTakeRecord( | |||||
| request, | |||||
| selectedSession.stockTakeId, | |||||
| currentUserId | |||||
| ); | |||||
| onSnackbar(t("Stock take record saved successfully"), "success"); | |||||
| handleCancelEdit(); | |||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| } catch (e: any) { | |||||
| console.error("Save stock take record error:", e); | |||||
| let errorMessage = t("Failed to save stock take record"); | |||||
| if (e?.message) { | |||||
| errorMessage = e.message; | |||||
| } else if (e?.response) { | |||||
| try { | |||||
| const errorData = await e.response.json(); | |||||
| errorMessage = errorData.message || errorData.error || errorMessage; | |||||
| } catch { | |||||
| // ignore | |||||
| } | |||||
| } | |||||
| onSnackbar(errorMessage, "error"); | |||||
| } finally { | |||||
| setSaving(false); | |||||
| } | |||||
| }, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar]); | |||||
| const handleBatchSubmitAll = useCallback(async () => { | |||||
| if (!selectedSession || !currentUserId) { | |||||
| console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId'); | |||||
| return; | |||||
| } | |||||
| console.log('handleBatchSubmitAll: Starting batch save...'); | |||||
| setBatchSaving(true); | |||||
| try { | |||||
| const request: BatchSaveStockTakeRecordRequest = { | |||||
| stockTakeId: selectedSession.stockTakeId, | |||||
| stockTakeSection: selectedSession.stockTakeSession, | |||||
| stockTakerId: currentUserId, | |||||
| }; | |||||
| const result = await batchSaveStockTakeRecords(request); | |||||
| console.log('handleBatchSubmitAll: Result:', result); | |||||
| onSnackbar( | |||||
| t("Batch save completed: {{success}} success, {{errors}} errors", { | |||||
| success: result.successCount, | |||||
| errors: result.errorCount, | |||||
| }), | |||||
| result.errorCount > 0 ? "warning" : "success" | |||||
| ); | |||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| } catch (e: any) { | |||||
| console.error("handleBatchSubmitAll: Error:", e); | |||||
| let errorMessage = t("Failed to batch save stock take records"); | |||||
| if (e?.message) { | |||||
| errorMessage = e.message; | |||||
| } else if (e?.response) { | |||||
| try { | |||||
| const errorData = await e.response.json(); | |||||
| errorMessage = errorData.message || errorData.error || errorMessage; | |||||
| } catch { | |||||
| // ignore | |||||
| } | |||||
| } | |||||
| onSnackbar(errorMessage, "error"); | |||||
| } finally { | |||||
| setBatchSaving(false); | |||||
| } | |||||
| }, [selectedSession, t, currentUserId, onSnackbar]); | |||||
| useEffect(() => { | |||||
| handleBatchSubmitAllRef.current = handleBatchSubmitAll; | |||||
| }, [handleBatchSubmitAll]); | |||||
| useEffect(() => { | |||||
| const handleKeyPress = (e: KeyboardEvent) => { | |||||
| const target = e.target as HTMLElement; | |||||
| if (target && ( | |||||
| target.tagName === 'INPUT' || | |||||
| target.tagName === 'TEXTAREA' || | |||||
| target.isContentEditable | |||||
| )) { | |||||
| return; | |||||
| } | |||||
| if (e.ctrlKey || e.metaKey || e.altKey) { | |||||
| return; | |||||
| } | |||||
| if (e.key.length === 1) { | |||||
| setShortcutInput(prev => { | |||||
| const newInput = prev + e.key; | |||||
| if (newInput === '{2fitestall}') { | |||||
| console.log('✅ Shortcut {2fitestall} detected!'); | |||||
| setTimeout(() => { | |||||
| if (handleBatchSubmitAllRef.current) { | |||||
| console.log('Calling handleBatchSubmitAll...'); | |||||
| handleBatchSubmitAllRef.current().catch(err => { | |||||
| console.error('Error in handleBatchSubmitAll:', err); | |||||
| }); | |||||
| } else { | |||||
| console.error('handleBatchSubmitAllRef.current is null'); | |||||
| } | |||||
| }, 0); | |||||
| return ""; | |||||
| } | |||||
| if (newInput.length > 15) { | |||||
| return ""; | |||||
| } | |||||
| if (newInput.length > 0 && !newInput.startsWith('{')) { | |||||
| return ""; | |||||
| } | |||||
| if (newInput.length > 5 && !newInput.startsWith('{2fi')) { | |||||
| return ""; | |||||
| } | |||||
| return newInput; | |||||
| }); | |||||
| } else if (e.key === 'Backspace') { | |||||
| setShortcutInput(prev => prev.slice(0, -1)); | |||||
| } else if (e.key === 'Escape') { | |||||
| setShortcutInput(""); | |||||
| } | |||||
| }; | |||||
| window.addEventListener('keydown', handleKeyPress); | |||||
| return () => { | |||||
| window.removeEventListener('keydown', handleKeyPress); | |||||
| }; | |||||
| }, []); | |||||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||||
| if (detail.stockTakeRecordStatus === "pass") { | |||||
| return true; | |||||
| } | |||||
| return false; | |||||
| }, []); | |||||
| return ( | |||||
| <Box> | |||||
| <Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}> | |||||
| {t("Back to List")} | |||||
| </Button> | |||||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||||
| {t("Stock Take Section")}: {selectedSession.stockTakeSession} | |||||
| </Typography> | |||||
| {/* | |||||
| {shortcutInput && ( | |||||
| <Box sx={{ mb: 2, p: 1.5, bgcolor: 'info.light', borderRadius: 1, border: '1px solid', borderColor: 'info.main' }}> | |||||
| <Typography variant="body2" color="info.dark" fontWeight={500}> | |||||
| {t("Shortcut Input")}: <strong style={{ fontFamily: 'monospace', fontSize: '1.1em' }}>{shortcutInput}</strong> | |||||
| </Typography> | |||||
| </Box> | |||||
| )} | |||||
| */} | |||||
| {loadingDetails ? ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ) : ( | |||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||||
| <TableCell>{t("Item")}</TableCell> | |||||
| {/*<TableCell>{t("Item Name")}</TableCell>*/} | |||||
| {/*<TableCell>{t("Lot No")}</TableCell>*/} | |||||
| <TableCell>{t("Expiry Date")}</TableCell> | |||||
| <TableCell>{t("Qty")}</TableCell> | |||||
| <TableCell>{t("Bad Qty")}</TableCell> | |||||
| {/*{inventoryLotDetails.some(d => editingRecord?.id === d.id) && (*/} | |||||
| <TableCell>{t("Remark")}</TableCell> | |||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Status")}</TableCell> | |||||
| <TableCell>{t("Record Status")}</TableCell> | |||||
| <TableCell>{t("Action")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {inventoryLotDetails.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={12} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| inventoryLotDetails.map((detail) => { | |||||
| const isEditing = editingRecord?.id === detail.id; | |||||
| const submitDisabled = isSubmitDisabled(detail); | |||||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | |||||
| return ( | |||||
| <TableRow key={detail.id}> | |||||
| <TableCell>{detail.warehouseCode || "-"}</TableCell> | |||||
| <TableCell sx={{ | |||||
| maxWidth: 100, | |||||
| wordBreak: 'break-word', | |||||
| whiteSpace: 'normal', | |||||
| lineHeight: 1.5 | |||||
| }}>{detail.itemCode || "-"}{detail.lotNo || "-"}{detail.itemName ? ` - ${detail.itemName}` : ""}</TableCell> | |||||
| {/* | |||||
| <TableCell | |||||
| sx={{ | |||||
| maxWidth: 200, | |||||
| wordBreak: 'break-word', | |||||
| whiteSpace: 'normal', | |||||
| lineHeight: 1.5 | |||||
| }} | |||||
| > | |||||
| {detail.itemName || "-"} | |||||
| </TableCell>*/} | |||||
| {/*<TableCell>{detail.lotNo || "-"}</TableCell>*/} | |||||
| <TableCell> | |||||
| {detail.expiryDate | |||||
| ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) | |||||
| : "-"} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Stack spacing={0.5}> | |||||
| {isEditing && isFirstSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={firstQty} | |||||
| onChange={(e) => setFirstQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.firstStockTakeQty ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("First")}: {detail.firstStockTakeQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {isEditing && isSecondSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondQty} | |||||
| onChange={(e) => setSecondQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.secondStockTakeQty ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("Second")}: {detail.secondStockTakeQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {!detail.firstStockTakeQty && !detail.secondStockTakeQty && !isEditing && ( | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| - | |||||
| </Typography> | |||||
| )} | |||||
| </Stack> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Stack spacing={0.5}> | |||||
| {isEditing && isFirstSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={firstBadQty} | |||||
| onChange={(e) => setFirstBadQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.firstBadQty ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("First")}: {detail.firstBadQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {isEditing && isSecondSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondBadQty} | |||||
| onChange={(e) => setSecondBadQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.secondBadQty ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("Second")}: {detail.secondBadQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {!detail.firstBadQty && !detail.secondBadQty && !isEditing && ( | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| - | |||||
| </Typography> | |||||
| )} | |||||
| </Stack> | |||||
| </TableCell> | |||||
| <TableCell sx={{ width: 180 }}> | |||||
| {isEditing && isSecondSubmit ? ( | |||||
| <> | |||||
| <Typography variant="body2">{t("Remark")}</Typography> | |||||
| <TextField | |||||
| size="small" | |||||
| value={remark} | |||||
| onChange={(e) => setRemark(e.target.value)} | |||||
| sx={{ width: 150 }} | |||||
| // If you want a single-line input, remove multiline/rows: | |||||
| // multiline | |||||
| // rows={2} | |||||
| /> | |||||
| </> | |||||
| ) : ( | |||||
| <Typography variant="body2"> | |||||
| {detail.remarks || "-"} | |||||
| </Typography> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| <TableCell> | |||||
| {detail.status ? ( | |||||
| <Chip size="small" label={t(detail.status)} color="default" /> | |||||
| ) : ( | |||||
| "-" | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {detail.stockTakeRecordStatus === "pass" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" /> | |||||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" /> | |||||
| ) : ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus || "")} color="default" /> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {isEditing ? ( | |||||
| <Stack direction="row" spacing={1}> | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => handleSaveStockTake(detail)} | |||||
| disabled={saving || submitDisabled} | |||||
| > | |||||
| {t("Save")} | |||||
| </Button> | |||||
| <Button | |||||
| size="small" | |||||
| onClick={handleCancelEdit} | |||||
| > | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| </Stack> | |||||
| ) : ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| onClick={() => handleStartEdit(detail)} | |||||
| disabled={submitDisabled} | |||||
| > | |||||
| {!detail.stockTakeRecordId | |||||
| ? t("Input") | |||||
| : detail.stockTakeRecordStatus === "notMatch" | |||||
| ? t("Input") | |||||
| : t("View")} | |||||
| </Button> | |||||
| )} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default PickerStockTake; | |||||
| @@ -0,0 +1,542 @@ | |||||
| "use client"; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| Stack, | |||||
| Typography, | |||||
| Chip, | |||||
| CircularProgress, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Paper, | |||||
| TextField, | |||||
| } from "@mui/material"; | |||||
| import { useState, useCallback, useEffect, useRef } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { | |||||
| AllPickedStockTakeListReponse, | |||||
| getInventoryLotDetailsBySection, | |||||
| InventoryLotDetailResponse, | |||||
| saveStockTakeRecord, | |||||
| SaveStockTakeRecordRequest, | |||||
| BatchSaveStockTakeRecordRequest, | |||||
| batchSaveStockTakeRecords, | |||||
| } from "@/app/api/stockTake/actions"; | |||||
| import { useSession } from "next-auth/react"; | |||||
| import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import dayjs from "dayjs"; | |||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||||
| interface PickerStockTakeProps { | |||||
| selectedSession: AllPickedStockTakeListReponse; | |||||
| onBack: () => void; | |||||
| onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | |||||
| } | |||||
| const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| selectedSession, | |||||
| onBack, | |||||
| onSnackbar, | |||||
| }) => { | |||||
| const { t } = useTranslation(["inventory", "common"]); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||||
| const [loadingDetails, setLoadingDetails] = useState(false); | |||||
| // 编辑状态 | |||||
| const [editingRecord, setEditingRecord] = useState<InventoryLotDetailResponse | null>(null); | |||||
| const [firstQty, setFirstQty] = useState<string>(""); | |||||
| const [secondQty, setSecondQty] = useState<string>(""); | |||||
| const [firstBadQty, setFirstBadQty] = useState<string>(""); | |||||
| const [secondBadQty, setSecondBadQty] = useState<string>(""); | |||||
| const [remark, setRemark] = useState<string>(""); | |||||
| const [saving, setSaving] = useState(false); | |||||
| const [batchSaving, setBatchSaving] = useState(false); | |||||
| const [shortcutInput, setShortcutInput] = useState<string>(""); | |||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||||
| const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | |||||
| useEffect(() => { | |||||
| const loadDetails = async () => { | |||||
| setLoadingDetails(true); | |||||
| try { | |||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setInventoryLotDetails([]); | |||||
| } finally { | |||||
| setLoadingDetails(false); | |||||
| } | |||||
| }; | |||||
| loadDetails(); | |||||
| }, [selectedSession]); | |||||
| const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | |||||
| setEditingRecord(detail); | |||||
| setFirstQty(detail.firstStockTakeQty?.toString() || ""); | |||||
| setSecondQty(detail.secondStockTakeQty?.toString() || ""); | |||||
| setFirstBadQty(detail.firstBadQty?.toString() || ""); | |||||
| setSecondBadQty(detail.secondBadQty?.toString() || ""); | |||||
| setRemark(detail.remarks || ""); | |||||
| }, []); | |||||
| const handleCancelEdit = useCallback(() => { | |||||
| setEditingRecord(null); | |||||
| setFirstQty(""); | |||||
| setSecondQty(""); | |||||
| setFirstBadQty(""); | |||||
| setSecondBadQty(""); | |||||
| setRemark(""); | |||||
| }, []); | |||||
| const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | |||||
| if (!selectedSession || !currentUserId) { | |||||
| return; | |||||
| } | |||||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | |||||
| const qty = isFirstSubmit ? firstQty : secondQty; | |||||
| const badQty = isFirstSubmit ? firstBadQty : secondBadQty; | |||||
| if (!qty || !badQty) { | |||||
| onSnackbar( | |||||
| isFirstSubmit | |||||
| ? t("Please enter QTY and Bad QTY") | |||||
| : t("Please enter Second QTY and Bad QTY"), | |||||
| "error" | |||||
| ); | |||||
| return; | |||||
| } | |||||
| setSaving(true); | |||||
| try { | |||||
| const request: SaveStockTakeRecordRequest = { | |||||
| stockTakeRecordId: detail.stockTakeRecordId || null, | |||||
| inventoryLotLineId: detail.id, | |||||
| qty: parseFloat(qty), | |||||
| badQty: parseFloat(badQty), | |||||
| remark: isSecondSubmit ? (remark || null) : null, | |||||
| }; | |||||
| console.log('handleSaveStockTake: request:', request); | |||||
| console.log('handleSaveStockTake: selectedSession.stockTakeId:', selectedSession.stockTakeId); | |||||
| console.log('handleSaveStockTake: currentUserId:', currentUserId); | |||||
| await saveStockTakeRecord( | |||||
| request, | |||||
| selectedSession.stockTakeId, | |||||
| currentUserId | |||||
| ); | |||||
| onSnackbar(t("Stock take record saved successfully"), "success"); | |||||
| handleCancelEdit(); | |||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| } catch (e: any) { | |||||
| console.error("Save stock take record error:", e); | |||||
| let errorMessage = t("Failed to save stock take record"); | |||||
| if (e?.message) { | |||||
| errorMessage = e.message; | |||||
| } else if (e?.response) { | |||||
| try { | |||||
| const errorData = await e.response.json(); | |||||
| errorMessage = errorData.message || errorData.error || errorMessage; | |||||
| } catch { | |||||
| // ignore | |||||
| } | |||||
| } | |||||
| onSnackbar(errorMessage, "error"); | |||||
| } finally { | |||||
| setSaving(false); | |||||
| } | |||||
| }, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar]); | |||||
| const handleBatchSubmitAll = useCallback(async () => { | |||||
| if (!selectedSession || !currentUserId) { | |||||
| console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId'); | |||||
| return; | |||||
| } | |||||
| console.log('handleBatchSubmitAll: Starting batch save...'); | |||||
| setBatchSaving(true); | |||||
| try { | |||||
| const request: BatchSaveStockTakeRecordRequest = { | |||||
| stockTakeId: selectedSession.stockTakeId, | |||||
| stockTakeSection: selectedSession.stockTakeSession, | |||||
| stockTakerId: currentUserId, | |||||
| }; | |||||
| const result = await batchSaveStockTakeRecords(request); | |||||
| console.log('handleBatchSubmitAll: Result:', result); | |||||
| onSnackbar( | |||||
| t("Batch save completed: {{success}} success, {{errors}} errors", { | |||||
| success: result.successCount, | |||||
| errors: result.errorCount, | |||||
| }), | |||||
| result.errorCount > 0 ? "warning" : "success" | |||||
| ); | |||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| } catch (e: any) { | |||||
| console.error("handleBatchSubmitAll: Error:", e); | |||||
| let errorMessage = t("Failed to batch save stock take records"); | |||||
| if (e?.message) { | |||||
| errorMessage = e.message; | |||||
| } else if (e?.response) { | |||||
| try { | |||||
| const errorData = await e.response.json(); | |||||
| errorMessage = errorData.message || errorData.error || errorMessage; | |||||
| } catch { | |||||
| // ignore | |||||
| } | |||||
| } | |||||
| onSnackbar(errorMessage, "error"); | |||||
| } finally { | |||||
| setBatchSaving(false); | |||||
| } | |||||
| }, [selectedSession, t, currentUserId, onSnackbar]); | |||||
| useEffect(() => { | |||||
| handleBatchSubmitAllRef.current = handleBatchSubmitAll; | |||||
| }, [handleBatchSubmitAll]); | |||||
| useEffect(() => { | |||||
| const handleKeyPress = (e: KeyboardEvent) => { | |||||
| const target = e.target as HTMLElement; | |||||
| if (target && ( | |||||
| target.tagName === 'INPUT' || | |||||
| target.tagName === 'TEXTAREA' || | |||||
| target.isContentEditable | |||||
| )) { | |||||
| return; | |||||
| } | |||||
| if (e.ctrlKey || e.metaKey || e.altKey) { | |||||
| return; | |||||
| } | |||||
| if (e.key.length === 1) { | |||||
| setShortcutInput(prev => { | |||||
| const newInput = prev + e.key; | |||||
| if (newInput === '{2fitestall}') { | |||||
| console.log('✅ Shortcut {2fitestall} detected!'); | |||||
| setTimeout(() => { | |||||
| if (handleBatchSubmitAllRef.current) { | |||||
| console.log('Calling handleBatchSubmitAll...'); | |||||
| handleBatchSubmitAllRef.current().catch(err => { | |||||
| console.error('Error in handleBatchSubmitAll:', err); | |||||
| }); | |||||
| } else { | |||||
| console.error('handleBatchSubmitAllRef.current is null'); | |||||
| } | |||||
| }, 0); | |||||
| return ""; | |||||
| } | |||||
| if (newInput.length > 15) { | |||||
| return ""; | |||||
| } | |||||
| if (newInput.length > 0 && !newInput.startsWith('{')) { | |||||
| return ""; | |||||
| } | |||||
| if (newInput.length > 5 && !newInput.startsWith('{2fi')) { | |||||
| return ""; | |||||
| } | |||||
| return newInput; | |||||
| }); | |||||
| } else if (e.key === 'Backspace') { | |||||
| setShortcutInput(prev => prev.slice(0, -1)); | |||||
| } else if (e.key === 'Escape') { | |||||
| setShortcutInput(""); | |||||
| } | |||||
| }; | |||||
| window.addEventListener('keydown', handleKeyPress); | |||||
| return () => { | |||||
| window.removeEventListener('keydown', handleKeyPress); | |||||
| }; | |||||
| }, []); | |||||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||||
| if (detail.stockTakeRecordStatus === "pass") { | |||||
| return true; | |||||
| } | |||||
| return false; | |||||
| }, []); | |||||
| return ( | |||||
| <Box> | |||||
| <Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}> | |||||
| {t("Back to List")} | |||||
| </Button> | |||||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||||
| {t("Stock Take Section")}: {selectedSession.stockTakeSession} | |||||
| </Typography> | |||||
| {/* | |||||
| {shortcutInput && ( | |||||
| <Box sx={{ mb: 2, p: 1.5, bgcolor: 'info.light', borderRadius: 1, border: '1px solid', borderColor: 'info.main' }}> | |||||
| <Typography variant="body2" color="info.dark" fontWeight={500}> | |||||
| {t("Shortcut Input")}: <strong style={{ fontFamily: 'monospace', fontSize: '1.1em' }}>{shortcutInput}</strong> | |||||
| </Typography> | |||||
| </Box> | |||||
| )} | |||||
| */} | |||||
| {loadingDetails ? ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ) : ( | |||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||||
| <TableCell>{t("Item")}</TableCell> | |||||
| {/*<TableCell>{t("Item Name")}</TableCell>*/} | |||||
| {/*<TableCell>{t("Lot No")}</TableCell>*/} | |||||
| <TableCell>{t("Expiry Date")}</TableCell> | |||||
| <TableCell>{t("Qty")}</TableCell> | |||||
| <TableCell>{t("Bad Qty")}</TableCell> | |||||
| {/*{inventoryLotDetails.some(d => editingRecord?.id === d.id) && (*/} | |||||
| <TableCell>{t("Remark")}</TableCell> | |||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Status")}</TableCell> | |||||
| <TableCell>{t("Record Status")}</TableCell> | |||||
| <TableCell>{t("Action")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {inventoryLotDetails.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={12} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| inventoryLotDetails.map((detail) => { | |||||
| const isEditing = editingRecord?.id === detail.id; | |||||
| const submitDisabled = isSubmitDisabled(detail); | |||||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | |||||
| return ( | |||||
| <TableRow key={detail.id}> | |||||
| <TableCell>{detail.warehouseCode || "-"}</TableCell> | |||||
| <TableCell sx={{ | |||||
| maxWidth: 100, | |||||
| wordBreak: 'break-word', | |||||
| whiteSpace: 'normal', | |||||
| lineHeight: 1.5 | |||||
| }}>{detail.itemCode || "-"}{detail.lotNo || "-"}{detail.itemName ? ` - ${detail.itemName}` : ""}</TableCell> | |||||
| {/* | |||||
| <TableCell | |||||
| sx={{ | |||||
| maxWidth: 200, | |||||
| wordBreak: 'break-word', | |||||
| whiteSpace: 'normal', | |||||
| lineHeight: 1.5 | |||||
| }} | |||||
| > | |||||
| {detail.itemName || "-"} | |||||
| </TableCell>*/} | |||||
| {/*<TableCell>{detail.lotNo || "-"}</TableCell>*/} | |||||
| <TableCell> | |||||
| {detail.expiryDate | |||||
| ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) | |||||
| : "-"} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Stack spacing={0.5}> | |||||
| {isEditing && isFirstSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={firstQty} | |||||
| onChange={(e) => setFirstQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.firstStockTakeQty ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("First")}: {detail.firstStockTakeQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {isEditing && isSecondSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondQty} | |||||
| onChange={(e) => setSecondQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.secondStockTakeQty ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("Second")}: {detail.secondStockTakeQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {!detail.firstStockTakeQty && !detail.secondStockTakeQty && !isEditing && ( | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| - | |||||
| </Typography> | |||||
| )} | |||||
| </Stack> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Stack spacing={0.5}> | |||||
| {isEditing && isFirstSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={firstBadQty} | |||||
| onChange={(e) => setFirstBadQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.firstBadQty ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("First")}: {detail.firstBadQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {isEditing && isSecondSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondBadQty} | |||||
| onChange={(e) => setSecondBadQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.secondBadQty ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("Second")}: {detail.secondBadQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {!detail.firstBadQty && !detail.secondBadQty && !isEditing && ( | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| - | |||||
| </Typography> | |||||
| )} | |||||
| </Stack> | |||||
| </TableCell> | |||||
| <TableCell sx={{ width: 180 }}> | |||||
| {isEditing && isSecondSubmit ? ( | |||||
| <> | |||||
| <Typography variant="body2">{t("Remark")}</Typography> | |||||
| <TextField | |||||
| size="small" | |||||
| value={remark} | |||||
| onChange={(e) => setRemark(e.target.value)} | |||||
| sx={{ width: 150 }} | |||||
| // If you want a single-line input, remove multiline/rows: | |||||
| // multiline | |||||
| // rows={2} | |||||
| /> | |||||
| </> | |||||
| ) : ( | |||||
| <Typography variant="body2"> | |||||
| {detail.remarks || "-"} | |||||
| </Typography> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| <TableCell> | |||||
| {detail.status ? ( | |||||
| <Chip size="small" label={t(detail.status)} color="default" /> | |||||
| ) : ( | |||||
| "-" | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {detail.stockTakeRecordStatus === "pass" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" /> | |||||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" /> | |||||
| ) : ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus || "")} color="default" /> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {isEditing ? ( | |||||
| <Stack direction="row" spacing={1}> | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => handleSaveStockTake(detail)} | |||||
| disabled={saving || submitDisabled} | |||||
| > | |||||
| {t("Save")} | |||||
| </Button> | |||||
| <Button | |||||
| size="small" | |||||
| onClick={handleCancelEdit} | |||||
| > | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| </Stack> | |||||
| ) : ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| onClick={() => handleStartEdit(detail)} | |||||
| disabled={submitDisabled} | |||||
| > | |||||
| {!detail.stockTakeRecordId | |||||
| ? t("Input") | |||||
| : detail.stockTakeRecordStatus === "notMatch" | |||||
| ? t("Input") | |||||
| : t("View")} | |||||
| </Button> | |||||
| )} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default PickerStockTake; | |||||
| @@ -40,7 +40,7 @@ const StockTakeManagement: React.FC = () => { | |||||
| return ( | return ( | ||||
| <Box sx={{ width: "100%" }}> | <Box sx={{ width: "100%" }}> | ||||
| <Typography variant="h4" sx={{ mb: 3 }}> | <Typography variant="h4" sx={{ mb: 3 }}> | ||||
| {t("Inventory Exception Management")} | |||||
| {t("Stock Take Management")} | |||||
| </Typography> | </Typography> | ||||
| <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | ||||
| @@ -1,430 +1,115 @@ | |||||
| "use client"; | "use client"; | ||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| Card, | |||||
| CardContent, | |||||
| Stack, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| TextField, | |||||
| Typography, | |||||
| Paper, | |||||
| Alert, | |||||
| Dialog, | |||||
| DialogTitle, | |||||
| DialogContent, | |||||
| DialogActions, | |||||
| } from "@mui/material"; | |||||
| import { useState, useMemo, useCallback } from "react"; | |||||
| import { Box, Tab, Tabs, Snackbar, Alert } from "@mui/material"; | |||||
| import { useState, useCallback } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import SearchBox, { Criterion } from "../SearchBox"; | |||||
| import SearchResults, { Column } from "../SearchResults"; | |||||
| import { defaultPagingController } from "../SearchResults/SearchResults"; | |||||
| // Fake data types | |||||
| interface Floor { | |||||
| id: number; | |||||
| code: string; | |||||
| name: string; | |||||
| warehouseCode: string; | |||||
| warehouseName: string; | |||||
| } | |||||
| interface Zone { | |||||
| id: number; | |||||
| floorId: number; | |||||
| code: string; | |||||
| name: string; | |||||
| description: string; | |||||
| } | |||||
| interface InventoryLotLineForStockTake { | |||||
| id: number; | |||||
| zoneId: number; | |||||
| itemCode: string; | |||||
| itemName: string; | |||||
| lotNo: string; | |||||
| location: string; | |||||
| systemQty: number; | |||||
| countedQty?: number; | |||||
| variance?: number; | |||||
| uom: string; | |||||
| } | |||||
| // Fake data | |||||
| const fakeFloors: Floor[] = [ | |||||
| { id: 1, code: "F1", name: "1st Floor", warehouseCode: "WH001", warehouseName: "Main Warehouse" }, | |||||
| { id: 2, code: "F2", name: "2nd Floor", warehouseCode: "WH001", warehouseName: "Main Warehouse" }, | |||||
| { id: 3, code: "F3", name: "3rd Floor", warehouseCode: "WH001", warehouseName: "Main Warehouse" }, | |||||
| ]; | |||||
| const fakeZones: Zone[] = [ | |||||
| { id: 1, floorId: 1, code: "Z-A", name: "Zone A", description: "Row 1-5" }, | |||||
| { id: 2, floorId: 1, code: "Z-B", name: "Zone B", description: "Row 6-10" }, | |||||
| { id: 3, floorId: 2, code: "Z-C", name: "Zone C", description: "Row 1-5" }, | |||||
| { id: 4, floorId: 2, code: "Z-D", name: "Zone D", description: "Row 6-10" }, | |||||
| { id: 5, floorId: 3, code: "Z-E", name: "Zone E", description: "Row 1-5" }, | |||||
| ]; | |||||
| const fakeLots: InventoryLotLineForStockTake[] = [ | |||||
| { id: 1, zoneId: 1, itemCode: "M001", itemName: "Material A", lotNo: "LOT-2024-001", location: "A-01-01", systemQty: 100, uom: "PCS" }, | |||||
| { id: 2, zoneId: 1, itemCode: "M002", itemName: "Material B", lotNo: "LOT-2024-002", location: "A-01-02", systemQty: 50, uom: "PCS" }, | |||||
| { id: 3, zoneId: 1, itemCode: "M003", itemName: "Material C", lotNo: "LOT-2024-003", location: "A-01-03", systemQty: 75, uom: "KG" }, | |||||
| { id: 4, zoneId: 2, itemCode: "M004", itemName: "Material D", lotNo: "LOT-2024-004", location: "B-01-01", systemQty: 200, uom: "PCS" }, | |||||
| { id: 5, zoneId: 2, itemCode: "M005", itemName: "Material E", lotNo: "LOT-2024-005", location: "B-01-02", systemQty: 150, uom: "KG" }, | |||||
| ]; | |||||
| type FloorSearchQuery = { | |||||
| floorCode: string; | |||||
| floorName: string; | |||||
| warehouseCode: string; | |||||
| }; | |||||
| type FloorSearchParamNames = keyof FloorSearchQuery; | |||||
| import { AllPickedStockTakeListReponse } from "@/app/api/stockTake/actions"; | |||||
| import PickerCardList from "./PickerCardList"; | |||||
| import ApproverCardList from "./ApproverCardList"; | |||||
| import PickerStockTake from "./PickerStockTake"; | |||||
| import PickerReStockTake from "./PickerReStockTake"; | |||||
| import ApproverStockTake from "./ApproverStockTake"; | |||||
| const StockTakeTab: React.FC = () => { | const StockTakeTab: React.FC = () => { | ||||
| const { t } = useTranslation(["inventory"]); | |||||
| // Search states for floors | |||||
| const defaultFloorInputs = useMemo(() => ({ | |||||
| floorCode: "", | |||||
| floorName: "", | |||||
| warehouseCode: "", | |||||
| }), []); | |||||
| const [floorInputs, setFloorInputs] = useState<Record<FloorSearchParamNames, string>>(defaultFloorInputs); | |||||
| // Selection states | |||||
| const [selectedFloor, setSelectedFloor] = useState<Floor | null>(null); | |||||
| const [selectedZone, setSelectedZone] = useState<Zone | null>(null); | |||||
| // Paging controllers | |||||
| const [floorsPagingController, setFloorsPagingController] = useState(defaultPagingController); | |||||
| const [zonesPagingController, setZonesPagingController] = useState(defaultPagingController); | |||||
| const [lotsPagingController, setLotsPagingController] = useState(defaultPagingController); | |||||
| // Stock take dialog | |||||
| const [stockTakeDialogOpen, setStockTakeDialogOpen] = useState(false); | |||||
| const [selectedLot, setSelectedLot] = useState<InventoryLotLineForStockTake | null>(null); | |||||
| const [countedQty, setCountedQty] = useState<number>(0); | |||||
| const [remark, setRemark] = useState(""); | |||||
| // Filtered data | |||||
| const filteredFloors = useMemo(() => { | |||||
| return fakeFloors.filter(floor => { | |||||
| if (floorInputs.floorCode && !floor.code.toLowerCase().includes(floorInputs.floorCode.toLowerCase())) { | |||||
| return false; | |||||
| } | |||||
| if (floorInputs.floorName && !floor.name.toLowerCase().includes(floorInputs.floorName.toLowerCase())) { | |||||
| return false; | |||||
| } | |||||
| if (floorInputs.warehouseCode && !floor.warehouseCode.toLowerCase().includes(floorInputs.warehouseCode.toLowerCase())) { | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| }); | |||||
| }, [floorInputs]); | |||||
| const filteredZones = useMemo(() => { | |||||
| if (!selectedFloor) return []; | |||||
| return fakeZones.filter(zone => zone.floorId === selectedFloor.id); | |||||
| }, [selectedFloor]); | |||||
| const filteredLots = useMemo(() => { | |||||
| if (!selectedZone) return []; | |||||
| return fakeLots.filter(lot => lot.zoneId === selectedZone.id); | |||||
| }, [selectedZone]); | |||||
| // Search criteria | |||||
| const floorSearchCriteria: Criterion<FloorSearchParamNames>[] = useMemo( | |||||
| () => [ | |||||
| { label: t("Floor Code"), paramName: "floorCode", type: "text" }, | |||||
| { label: t("Floor Name"), paramName: "floorName", type: "text" }, | |||||
| { label: t("Warehouse Code"), paramName: "warehouseCode", type: "text" }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| // Handlers | |||||
| const handleFloorSearch = useCallback((query: Record<FloorSearchParamNames, string>) => { | |||||
| setFloorInputs(() => query); | |||||
| setFloorsPagingController(() => defaultPagingController); | |||||
| }, []); | |||||
| const handleFloorReset = useCallback(() => { | |||||
| setFloorInputs(() => defaultFloorInputs); | |||||
| setFloorsPagingController(() => defaultPagingController); | |||||
| setSelectedFloor(null); | |||||
| setSelectedZone(null); | |||||
| }, [defaultFloorInputs]); | |||||
| const handleFloorClick = useCallback((floor: Floor) => { | |||||
| setSelectedFloor(floor); | |||||
| setSelectedZone(null); | |||||
| setZonesPagingController(() => defaultPagingController); | |||||
| setLotsPagingController(() => defaultPagingController); | |||||
| const { t } = useTranslation(["inventory", "common"]); | |||||
| const [tabValue, setTabValue] = useState(0); | |||||
| const [selectedSession, setSelectedSession] = useState<AllPickedStockTakeListReponse | null>(null); | |||||
| const [viewMode, setViewMode] = useState<"details" | "reStockTake">("details"); | |||||
| const [snackbar, setSnackbar] = useState<{ | |||||
| open: boolean; | |||||
| message: string; | |||||
| severity: "success" | "error" | "warning" | |||||
| }>({ | |||||
| open: false, | |||||
| message: "", | |||||
| severity: "success", | |||||
| }); | |||||
| const handleCardClick = useCallback((session: AllPickedStockTakeListReponse) => { | |||||
| setSelectedSession(session); | |||||
| setViewMode("details"); | |||||
| }, []); | }, []); | ||||
| const handleZoneClick = useCallback((zone: Zone) => { | |||||
| setSelectedZone(zone); | |||||
| setLotsPagingController(() => defaultPagingController); | |||||
| const handleReStockTakeClick = useCallback((session: AllPickedStockTakeListReponse) => { | |||||
| setSelectedSession(session); | |||||
| setViewMode("reStockTake"); | |||||
| }, []); | }, []); | ||||
| const handleStockTakeClick = useCallback((lot: InventoryLotLineForStockTake) => { | |||||
| setSelectedLot(lot); | |||||
| setCountedQty(lot.countedQty || lot.systemQty); | |||||
| setRemark(""); | |||||
| setStockTakeDialogOpen(true); | |||||
| const handleBackToList = useCallback(() => { | |||||
| setSelectedSession(null); | |||||
| setViewMode("details"); | |||||
| }, []); | }, []); | ||||
| const handleStockTakeSubmit = useCallback(() => { | |||||
| if (!selectedLot) return; | |||||
| const variance = countedQty - selectedLot.systemQty; | |||||
| // Here you would call the API to submit stock take | |||||
| console.log("Stock Take Submitted:", { | |||||
| lotId: selectedLot.id, | |||||
| systemQty: selectedLot.systemQty, | |||||
| countedQty: countedQty, | |||||
| variance: variance, | |||||
| remark: remark, | |||||
| const handleSnackbar = useCallback((message: string, severity: "success" | "error" | "warning") => { | |||||
| setSnackbar({ | |||||
| open: true, | |||||
| message, | |||||
| severity, | |||||
| }); | }); | ||||
| alert(`${t("Stock take recorded successfully!")}\n${t("Variance")}: ${variance > 0 ? '+' : ''}${variance}`); | |||||
| // Update the lot with counted qty (in real app, this would come from backend) | |||||
| selectedLot.countedQty = countedQty; | |||||
| selectedLot.variance = variance; | |||||
| setStockTakeDialogOpen(false); | |||||
| setSelectedLot(null); | |||||
| }, [selectedLot, countedQty, remark, t]); | |||||
| const handleDialogClose = useCallback(() => { | |||||
| setStockTakeDialogOpen(false); | |||||
| setSelectedLot(null); | |||||
| }, []); | }, []); | ||||
| // Floor columns | |||||
| const floorColumns: Column<Floor>[] = useMemo( | |||||
| () => [ | |||||
| { name: "code", label: t("Floor Code") }, | |||||
| { name: "name", label: t("Floor Name") }, | |||||
| { | |||||
| name: "warehouseCode", | |||||
| label: t("Warehouse"), | |||||
| renderCell: (params) => `${params.warehouseCode} - ${params.warehouseName}`, | |||||
| }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| // Zone columns | |||||
| const zoneColumns: Column<Zone>[] = useMemo( | |||||
| () => [ | |||||
| { name: "code", label: t("Zone Code") }, | |||||
| { name: "name", label: t("Zone Name") }, | |||||
| { name: "description", label: t("Description") }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| // Lot columns | |||||
| const lotColumns: Column<InventoryLotLineForStockTake>[] = useMemo( | |||||
| () => [ | |||||
| { name: "itemCode", label: t("Item Code") }, | |||||
| { name: "itemName", label: t("Item Name") }, | |||||
| { name: "lotNo", label: t("Lot No") }, | |||||
| { name: "location", label: t("Location") }, | |||||
| { name: "systemQty", label: t("System Qty"), align: "right", type: "integer" }, | |||||
| { | |||||
| name: "countedQty", | |||||
| label: t("Counted Qty"), | |||||
| align: "right", | |||||
| renderCell: (params) => params.countedQty || "-", | |||||
| }, | |||||
| { | |||||
| name: "variance", | |||||
| label: t("Variance"), | |||||
| align: "right", | |||||
| renderCell: (params) => { | |||||
| if (params.variance === undefined) return "-"; | |||||
| const variance = params.variance; | |||||
| return ( | |||||
| <Typography | |||||
| variant="body2" | |||||
| sx={{ | |||||
| color: variance === 0 ? "inherit" : variance > 0 ? "success.main" : "error.main", | |||||
| fontWeight: variance !== 0 ? "bold" : "normal", | |||||
| }} | |||||
| > | |||||
| {variance > 0 ? `+${variance}` : variance} | |||||
| </Typography> | |||||
| ); | |||||
| }, | |||||
| }, | |||||
| { name: "uom", label: t("UOM") }, | |||||
| { | |||||
| name: "id", | |||||
| label: t("Action"), | |||||
| renderCell: (params) => ( | |||||
| <Button size="small" variant="outlined" onClick={() => handleStockTakeClick(params)}> | |||||
| {t("Stock Take")} | |||||
| </Button> | |||||
| ), | |||||
| }, | |||||
| ], | |||||
| [t, handleStockTakeClick], | |||||
| ); | |||||
| if (selectedSession) { | |||||
| return ( | |||||
| <Box> | |||||
| {tabValue === 0 ? ( | |||||
| viewMode === "reStockTake" ? ( | |||||
| <PickerReStockTake | |||||
| selectedSession={selectedSession} | |||||
| onBack={handleBackToList} | |||||
| onSnackbar={handleSnackbar} | |||||
| /> | |||||
| ) : ( | |||||
| <PickerStockTake | |||||
| selectedSession={selectedSession} | |||||
| onBack={handleBackToList} | |||||
| onSnackbar={handleSnackbar} | |||||
| /> | |||||
| ) | |||||
| ) : ( | |||||
| <ApproverStockTake | |||||
| selectedSession={selectedSession} | |||||
| onBack={handleBackToList} | |||||
| onSnackbar={handleSnackbar} | |||||
| /> | |||||
| )} | |||||
| <Snackbar | |||||
| open={snackbar.open} | |||||
| autoHideDuration={6000} | |||||
| onClose={() => setSnackbar({ ...snackbar, open: false })} | |||||
| > | |||||
| <Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.severity}> | |||||
| {snackbar.message} | |||||
| </Alert> | |||||
| </Snackbar> | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| return ( | return ( | ||||
| <Box> | <Box> | ||||
| <Alert severity="info" sx={{ mb: 3 }}> | |||||
| {t("This is a demo with fake data. API integration pending.")} | |||||
| </Alert> | |||||
| {/* Step 1: Select Floor */} | |||||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||||
| {t("Step 1: Select Floor")} | |||||
| </Typography> | |||||
| <SearchBox | |||||
| criteria={floorSearchCriteria} | |||||
| onSearch={handleFloorSearch} | |||||
| onReset={handleFloorReset} | |||||
| /> | |||||
| <SearchResults<Floor> | |||||
| items={filteredFloors} | |||||
| columns={floorColumns} | |||||
| pagingController={floorsPagingController} | |||||
| setPagingController={setFloorsPagingController} | |||||
| totalCount={filteredFloors.length} | |||||
| onRowClick={handleFloorClick} | |||||
| /> | |||||
| {/* Step 2: Select Zone */} | |||||
| {selectedFloor && ( | |||||
| <> | |||||
| <Typography variant="h6" sx={{ mt: 4, mb: 2 }}> | |||||
| {t("Step 2: Select Zone")} - {selectedFloor.name} | |||||
| </Typography> | |||||
| <SearchResults<Zone> | |||||
| items={filteredZones} | |||||
| columns={zoneColumns} | |||||
| pagingController={zonesPagingController} | |||||
| setPagingController={setZonesPagingController} | |||||
| totalCount={filteredZones.length} | |||||
| onRowClick={handleZoneClick} | |||||
| /> | |||||
| </> | |||||
| )} | |||||
| {/* Step 3: Stock Take */} | |||||
| {selectedZone && ( | |||||
| <> | |||||
| <Typography variant="h6" sx={{ mt: 4, mb: 2 }}> | |||||
| {t("Step 3: Perform Stock Take")} - {selectedZone.name} | |||||
| </Typography> | |||||
| <SearchResults<InventoryLotLineForStockTake> | |||||
| items={filteredLots} | |||||
| columns={lotColumns} | |||||
| pagingController={lotsPagingController} | |||||
| setPagingController={setLotsPagingController} | |||||
| totalCount={filteredLots.length} | |||||
| /> | |||||
| </> | |||||
| <Tabs value={tabValue} onChange={(e, newValue) => setTabValue(newValue)} sx={{ mb: 2 }}> | |||||
| <Tab label={t("Picker")} /> | |||||
| <Tab label={t("Approver")} /> | |||||
| </Tabs> | |||||
| {tabValue === 0 ? ( | |||||
| <PickerCardList | |||||
| onCardClick={handleCardClick} | |||||
| onReStockTakeClick={handleReStockTakeClick} | |||||
| /> | |||||
| ) : ( | |||||
| <ApproverCardList onCardClick={handleCardClick} /> | |||||
| )} | )} | ||||
| {/* Stock Take Dialog */} | |||||
| <Dialog open={stockTakeDialogOpen} onClose={handleDialogClose} maxWidth="sm" fullWidth> | |||||
| <DialogTitle>{t("Stock Take")}</DialogTitle> | |||||
| <DialogContent> | |||||
| {selectedLot && ( | |||||
| <Stack spacing={3} sx={{ mt: 2 }}> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Item")} | |||||
| </Typography> | |||||
| <Typography variant="body1"> | |||||
| {selectedLot.itemCode} - {selectedLot.itemName} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Lot No")} | |||||
| </Typography> | |||||
| <Typography variant="body1">{selectedLot.lotNo}</Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Location")} | |||||
| </Typography> | |||||
| <Typography variant="body1">{selectedLot.location}</Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("System Qty")} | |||||
| </Typography> | |||||
| <Typography variant="body1"> | |||||
| {selectedLot.systemQty} {selectedLot.uom} | |||||
| </Typography> | |||||
| </Box> | |||||
| <TextField | |||||
| label={t("Counted Qty")} | |||||
| type="number" | |||||
| value={countedQty} | |||||
| onChange={(e) => setCountedQty(parseInt(e.target.value) || 0)} | |||||
| fullWidth | |||||
| autoFocus | |||||
| /> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Variance")} | |||||
| </Typography> | |||||
| <Typography | |||||
| variant="h6" | |||||
| sx={{ | |||||
| color: | |||||
| countedQty - selectedLot.systemQty === 0 | |||||
| ? "inherit" | |||||
| : countedQty - selectedLot.systemQty > 0 | |||||
| ? "success.main" | |||||
| : "error.main", | |||||
| }} | |||||
| > | |||||
| {countedQty - selectedLot.systemQty > 0 ? "+" : ""} | |||||
| {countedQty - selectedLot.systemQty} | |||||
| </Typography> | |||||
| </Box> | |||||
| <TextField | |||||
| label={t("Remark")} | |||||
| multiline | |||||
| rows={3} | |||||
| value={remark} | |||||
| onChange={(e) => setRemark(e.target.value)} | |||||
| fullWidth | |||||
| /> | |||||
| </Stack> | |||||
| )} | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={handleDialogClose}>{t("Cancel")}</Button> | |||||
| <Button onClick={handleStockTakeSubmit} variant="contained" color="primary"> | |||||
| {t("Submit Stock Take")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| <Snackbar | |||||
| open={snackbar.open} | |||||
| autoHideDuration={6000} | |||||
| onClose={() => setSnackbar({ ...snackbar, open: false })} | |||||
| > | |||||
| <Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.severity}> | |||||
| {snackbar.message} | |||||
| </Alert> | |||||
| </Snackbar> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||