| @@ -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} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Job Order")} | |||
| {t("Search Job Order/ Create Job Order")} | |||
| </Typography> | |||
| </Stack> | |||
| <I18nProvider namespaces={["jo", "common", "purchaseOrder", "dashboard","common"]}> {/* TODO: Improve */} | |||
| @@ -44,4 +44,78 @@ export const getBagInfo = cache(async () => { | |||
| 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 | |||
| } | |||
| 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 ( | |||
| pickOrderId: number, | |||
| @@ -250,6 +262,7 @@ export interface ProductProcessWithLinesResponse { | |||
| bomDescription: string; | |||
| jobType: string; | |||
| isDark: string; | |||
| bomBaseQty: number; | |||
| isDense: number; | |||
| isFloat: string; | |||
| timeSequence: number; | |||
| @@ -262,6 +275,7 @@ export interface ProductProcessWithLinesResponse { | |||
| outputQty: number; | |||
| outputQtyUom: string; | |||
| productionPriority: number; | |||
| submitedBagRecord?: boolean; | |||
| jobOrderLines: JobOrderLineInfo[]; | |||
| productProcessLines: ProductProcessLineResponse[]; | |||
| @@ -417,6 +431,7 @@ export interface JobOrderProcessLineDetailResponse { | |||
| stopTime: string | number[]; | |||
| totalPausedTimeMs?: number; // API 返回的是数组格式 | |||
| status: string; | |||
| submitedBagRecord: boolean; | |||
| outputFromProcessQty: number; | |||
| outputFromProcessUom: string; | |||
| 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 | |||
| export const fetchProductProcessLines = cache(async (processId: number) => { | |||
| return serverFetchJson<ProductProcessLineResponse[]>( | |||
| @@ -1129,4 +1151,20 @@ export const passProductProcessLine = async (lineId: number) => { | |||
| 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"; | |||
| import { serverFetchString } from "@/app/utils/fetchUtil"; | |||
| import { cache } from 'react'; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; // 改为 serverFetchJson | |||
| 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) => { | |||
| const importStockTake = await serverFetchString<string>( | |||
| const importStockTake = await serverFetchJson<string>( | |||
| `${BASE_API_URL}/stockTake/import`, | |||
| { | |||
| method: "POST", | |||
| @@ -12,4 +93,208 @@ export const importStockTake = async (data: FormData) => { | |||
| }, | |||
| ); | |||
| 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" | |||
| 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 { useTranslation } from "react-i18next"; | |||
| import { Criterion } from "../SearchBox"; | |||
| @@ -12,7 +12,7 @@ import { useRouter } from "next/navigation"; | |||
| import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; | |||
| import { StockInLineInput } from "@/app/api/stockIn"; | |||
| 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 JoCreateFormModal from "./JoCreateFormModal"; | |||
| 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 [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 response = await fetch(`/api/jo/detail?id=${id}`); | |||
| if (!response.ok) { | |||
| @@ -111,32 +113,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| 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 inventory = inventoryData.find(inventory => | |||
| 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) | |||
| }, | |||
| ], [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)) { | |||
| setPlanStartDate(arrayToDayjs(jo.planStart)); | |||
| setEditPlanStartDate(arrayToDayjs(jo.planStart)); | |||
| } 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>[]>( | |||
| () => [ | |||
| { | |||
| name: "planStart", | |||
| label: t("Estimated Production Date"), | |||
| @@ -196,19 +225,20 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| renderCell: (row) => { | |||
| return ( | |||
| <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> | |||
| ); | |||
| } | |||
| @@ -220,16 +250,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| return ( | |||
| <Stack direction="row" alignItems="center" spacing={1}> | |||
| <span>{integerFormatter.format(row.productionPriority)}</span> | |||
| <IconButton | |||
| size="small" | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| handleOpenPriorityDialog(row); | |||
| }} | |||
| sx={{ padding: '4px' }} | |||
| > | |||
| <EditIcon fontSize="small" /> | |||
| </IconButton> | |||
| </Stack> | |||
| ); | |||
| } | |||
| @@ -239,12 +260,11 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| label: t("Code"), | |||
| flex: 2 | |||
| }, | |||
| { | |||
| name: "item", | |||
| label: `${t("Item Name")}`, | |||
| 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", | |||
| headerAlign: "right", | |||
| 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> | |||
| } | |||
| }, | |||
| { | |||
| name: "jobTypeName", | |||
| label: t("Job Type"), | |||
| renderCell: (row) => { | |||
| return row.jobTypeName ? t(row.jobTypeName) : '-' | |||
| } | |||
| }, | |||
| { | |||
| name: "id", | |||
| 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( | |||
| async ( | |||
| pagingController: { pageNum: number; pageSize: number }, | |||
| @@ -333,7 +350,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| if (response && response.records) { | |||
| console.log("newPageFetch - setting filteredJos with", response.records.length, "records"); | |||
| setTotalCount(response.total); | |||
| // 后端已经按 id DESC 排序,不需要再次排序 | |||
| setFilteredJos(response.records); | |||
| console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id); | |||
| } 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 response = await updateProductProcessPriority(productProcessId, productionPriority) | |||
| if (response) { | |||
| // 刷新数据 | |||
| await newPageFetch(pagingController, inputs); | |||
| } | |||
| }, [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(() => { | |||
| newPageFetch(pagingController, inputs); | |||
| }, [newPageFetch, pagingController, inputs]); | |||
| @@ -378,7 +446,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| const res = await createStockInLine(postData); | |||
| console.log(`%c Created Stock In Line`, "color:lime", res); | |||
| msg(t("update success")); | |||
| // 重置为默认输入,让 useEffect 自动触发 | |||
| setInputs(defaultInputs); | |||
| setPagingController(defaultPagingController); | |||
| } | |||
| @@ -427,7 +494,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| const closeNewModal = useCallback(() => { | |||
| setOpenModal(false); | |||
| setInputs(defaultInputs); | |||
| setPagingController(defaultPagingController); | |||
| }, [defaultInputs]); | |||
| @@ -440,7 +506,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| jobTypeName: query.jobTypeName && query.jobTypeName !== "All" ? query.jobTypeName : "" | |||
| }; | |||
| setInputs({ | |||
| code: transformedQuery.code, | |||
| itemName: transformedQuery.itemName, | |||
| @@ -452,38 +517,11 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| setPagingController(defaultPagingController); | |||
| }, [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(() => { | |||
| setInputs(defaultInputs); | |||
| setPagingController(defaultPagingController); | |||
| }, [defaultInputs]) | |||
| const onOpenCreateJoModal = useCallback(() => { | |||
| setIsCreateJoModalOpen(() => true) | |||
| }, []) | |||
| @@ -526,7 +564,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| jobTypes={jobTypes} | |||
| onClose={onCloseCreateJoModal} | |||
| onSearch={() => { | |||
| setInputs({ ...defaultInputs }); // 创建新对象,确保引用变化 | |||
| setInputs({ ...defaultInputs }); | |||
| setPagingController(defaultPagingController); | |||
| }} | |||
| /> | |||
| @@ -538,66 +576,128 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| inputDetail={modalInfo} | |||
| 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 | |||
| maxWidth="xs" | |||
| maxWidth="sm" | |||
| > | |||
| <DialogTitle>{t("Update Estimated Production Date")}</DialogTitle> | |||
| <DialogTitle>{t("Edit Job Order")}</DialogTitle> | |||
| <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> | |||
| <DialogActions> | |||
| <Button onClick={handleClosePlanStartDialog}>{t("Cancel")}</Button> | |||
| <Button onClick={handleCloseEditDialog}>{t("Cancel")}</Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={handleConfirmPlanStart} | |||
| disabled={!planStartDate} | |||
| onClick={handleConfirmEdit} | |||
| disabled={!editPlanStartDate || !editBomForReqQty} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </> | |||
| } | |||
| export default JoSearch; | |||
| @@ -39,6 +39,7 @@ interface BagConsumptionFormProps { | |||
| lineId: number; | |||
| bomDescription?: string; | |||
| isLastLine: boolean; | |||
| submitedBagRecord?: boolean; | |||
| onRefresh?: () => void; | |||
| } | |||
| @@ -47,6 +48,7 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({ | |||
| lineId, | |||
| bomDescription, | |||
| isLastLine, | |||
| submitedBagRecord, | |||
| onRefresh, | |||
| }) => { | |||
| const { t } = useTranslation(["common", "jo"]); | |||
| @@ -59,8 +61,12 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({ | |||
| // 判断是否显示表单 | |||
| const shouldShow = useMemo(() => { | |||
| // 如果 submitedBagRecord 为 true,则不显示表单 | |||
| if (submitedBagRecord === true) { | |||
| return false; | |||
| } | |||
| return bomDescription === "FG" && isLastLine; | |||
| }, [bomDescription, isLastLine]); | |||
| }, [bomDescription, isLastLine, submitedBagRecord]); | |||
| // 加载 Bag 列表 | |||
| useEffect(() => { | |||
| @@ -1,5 +1,8 @@ | |||
| "use client"; | |||
| 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 { | |||
| Box, | |||
| Button, | |||
| @@ -21,6 +24,7 @@ import { | |||
| DialogTitle, | |||
| DialogContent, | |||
| DialogActions, | |||
| IconButton | |||
| } from "@mui/material"; | |||
| import QrCodeIcon from '@mui/icons-material/QrCode'; | |||
| import { useTranslation } from "react-i18next"; | |||
| @@ -40,9 +44,12 @@ import { | |||
| ProductProcessLineInfoResponse, | |||
| startProductProcessLine, | |||
| fetchProductProcessesByJobOrderId, | |||
| ProductProcessWithLinesResponse, // ✅ 添加 | |||
| ProductProcessWithLinesResponse, // 添加 | |||
| ProductProcessLineResponse, | |||
| passProductProcessLine, | |||
| newProductProcessLine, | |||
| updateProductProcessLineProcessingTimeSetupTimeChangeoverTime, | |||
| UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest, | |||
| } from "@/app/api/jo/actions"; | |||
| import { updateProductProcessLineStatus } from "@/app/api/jo/actions"; | |||
| @@ -61,16 +68,21 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||
| onBack, | |||
| fromJosave, | |||
| }) => { | |||
| console.log(" ProductionProcessDetail RENDER", { jobOrderId, fromJosave }); | |||
| const { t } = useTranslation("common"); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||
| 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 linesRef = useRef<ProductProcessLineResponse[]>([]); | |||
| const onBackRef = useRef(onBack); | |||
| const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| // 选中的 line 和执行状态 | |||
| const [selectedLineId, setSelectedLineId] = useState<number | null>(null); | |||
| const [isExecutingLine, setIsExecutingLine] = useState(false); | |||
| @@ -88,8 +100,14 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||
| const [lineDetailForScan, setLineDetailForScan] = useState<JobOrderProcessLineDetailResponse | null>(null); | |||
| const [showScanDialog, setShowScanDialog] = useState(false); | |||
| 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({ | |||
| byproductName: "", | |||
| byproductQty: "", | |||
| @@ -110,43 +128,122 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||
| setSelectedLineId(null); | |||
| setShowOutputPage(false); | |||
| }; | |||
| useEffect(() => { | |||
| onBackRef.current = onBack; | |||
| }, [onBack]); | |||
| // 获取 process 和 lines 数据 | |||
| const fetchProcessDetail = useCallback(async () => { | |||
| console.log(" fetchProcessDetail CALLED", { jobOrderId, timestamp: new Date().toISOString() }); | |||
| setLoading(true); | |||
| try { | |||
| console.log(`🔍 Loading process detail for JobOrderId: ${jobOrderId}`); | |||
| console.log(` Loading process detail for JobOrderId: ${jobOrderId}`); | |||
| // 使用 fetchProductProcessesByJobOrderId 获取基础数据 | |||
| const processesWithLines = await fetchProductProcessesByJobOrderId(jobOrderId); | |||
| if (!processesWithLines || processesWithLines.length === 0) { | |||
| throw new Error("No processes found for this job order"); | |||
| } | |||
| // 如果有多个 process,取第一个(或者可以根据需要选择) | |||
| const currentProcess = processesWithLines[0]; | |||
| setProcessData(currentProcess); | |||
| // 使用 productProcessLines 字段(API 返回的字段名) | |||
| const lines = currentProcess.productProcessLines || []; | |||
| 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) { | |||
| console.error("❌ Error loading process detail:", error); | |||
| //alert(`无法加载 Job Order ID ${jobOrderId} 的生产流程。该记录可能不存在。`); | |||
| onBack(); | |||
| console.error(" Error loading process detail:", error); | |||
| onBackRef.current(); | |||
| } finally { | |||
| 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(() => { | |||
| fetchProcessDetail(); | |||
| fetchProcessDetailRef.current = 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) => { | |||
| try { | |||
| @@ -158,7 +255,16 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({ | |||
| alert(t("Failed to pass line. Please try again.")); | |||
| } | |||
| }, [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) => { | |||
| // 设备快捷格式:{2fitesteXXX} - XXX 直接作为设备代码 | |||
| @@ -257,7 +363,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| try { | |||
| const lineDetail = lineDetailForScan || await fetchProductProcessLineDetail(lineId); | |||
| // ✅ 统一使用一个最终的 equipmentCode(优先用 scannedEquipmentCode,其次用 scannedEquipmentTypeSubTypeEquipmentNo) | |||
| // 统一使用一个最终的 equipmentCode(优先用 scannedEquipmentCode,其次用 scannedEquipmentTypeSubTypeEquipmentNo) | |||
| const effectiveEquipmentCode = | |||
| scannedEquipmentCode ?? null; | |||
| @@ -340,7 +446,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| setProcessedQrCodes(new Set()); | |||
| setScannedOperatorId(null); | |||
| setScannedEquipmentId(null); | |||
| setScannedStaffNo(null); // ✅ Add this | |||
| setScannedStaffNo(null); // Add this | |||
| setScannedEquipmentCode(null); | |||
| setIsAutoSubmitting(false); // 添加:重置自动提交状态 | |||
| setLineDetailForScan(null); | |||
| @@ -366,7 +472,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| setIsManualScanning(false); | |||
| setIsAutoSubmitting(false); | |||
| setScannedStaffNo(null); // ✅ Add this | |||
| setScannedStaffNo(null); // Add this | |||
| setScannedEquipmentCode(null); | |||
| stopScan(); | |||
| resetScan(); | |||
| @@ -392,7 +498,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| isManualScanning, | |||
| }); | |||
| // ✅ Update condition to check for either equipmentTypeSubTypeEquipmentNo OR equipmentDetailId | |||
| // Update condition to check for either equipmentTypeSubTypeEquipmentNo OR equipmentDetailId | |||
| if ( | |||
| scanningLineId && | |||
| scannedStaffNo !== null && | |||
| @@ -455,6 +561,13 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| }; | |||
| const selectedLine = lines.find(l => l.id === selectedLineId); | |||
| // 添加组件卸载日志 | |||
| useEffect(() => { | |||
| return () => { | |||
| console.log("🗑️ ProductionProcessDetail UNMOUNTING"); | |||
| }; | |||
| }, []); | |||
| if (loading) { | |||
| return ( | |||
| <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}> | |||
| @@ -474,188 +587,230 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| {!isExecutingLine ? ( | |||
| /* ========== 步骤列表视图 ========== */ | |||
| <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 | |||
| lineId={selectedLineId} | |||
| onBack={handleBackFromStep} | |||
| processData={processData} // ✅ 添加 | |||
| allLines={lines} // ✅ 添加 | |||
| jobOrderId={jobOrderId} // ✅ 添加 | |||
| processData={processData} // 添加 | |||
| allLines={lines} // 添加 | |||
| jobOrderId={jobOrderId} // 添加 | |||
| /> | |||
| )} | |||
| </Paper> | |||
| @@ -703,13 +858,14 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => { | |||
| <Button type="button" onClick={() => { | |||
| handleStopScan(); | |||
| setShowScanDialog(false); | |||
| }}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| type="button" | |||
| variant="contained" | |||
| onClick={() => scanningLineId && handleSubmitScanAndStart(scanningLineId)} | |||
| disabled={!scannedStaffNo } | |||
| @@ -718,6 +874,102 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| </Button> | |||
| </DialogActions> | |||
| </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> | |||
| ); | |||
| }; | |||
| @@ -23,8 +23,10 @@ import { | |||
| } from "@mui/material"; | |||
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | |||
| 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 { BomCombo } from "@/app/api/bom"; | |||
| import { fetchBomCombo } from "@/app/api/bom/index"; | |||
| import dayjs from "dayjs"; | |||
| import { OUTPUT_DATE_FORMAT, integerFormatter, arrayToDateString } from "@/app/utils/formatUtil"; | |||
| import StyledDataGrid from "../StyledDataGrid/StyledDataGrid"; | |||
| @@ -79,7 +81,11 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp | |||
| const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false); | |||
| const [openPlanStartDialog, setOpenPlanStartDialog] = useState(false); | |||
| 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 () => { | |||
| setLoading(true); | |||
| @@ -97,6 +103,61 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp | |||
| } | |||
| }, [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(() => { | |||
| const fetchInventoryData = async () => { | |||
| @@ -302,6 +363,15 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| fullWidth | |||
| disabled={true} | |||
| 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> | |||
| @@ -681,7 +751,88 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| </Button> | |||
| </DialogActions> | |||
| </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> | |||
| @@ -987,6 +987,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| lineId={lineId} | |||
| bomDescription={processData?.bomDescription} | |||
| isLastLine={shouldShowBagForm} | |||
| submitedBagRecord={lineDetail?.submitedBagRecord} | |||
| 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 ( | |||
| <Box sx={{ width: "100%" }}> | |||
| <Typography variant="h4" sx={{ mb: 3 }}> | |||
| {t("Inventory Exception Management")} | |||
| {t("Stock Take Management")} | |||
| </Typography> | |||
| <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | |||
| @@ -1,430 +1,115 @@ | |||
| "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 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 { 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 ( | |||
| <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> | |||
| ); | |||
| }; | |||