| @@ -0,0 +1,25 @@ | |||||
| import SearchPage from "@/components/StockRecord/index"; | |||||
| import { getServerI18n } from "@/i18n"; | |||||
| import { I18nProvider } from "@/i18n"; | |||||
| import { Metadata } from "next"; | |||||
| import { Suspense } from "react"; | |||||
| export const metadata: Metadata = { | |||||
| title: "Stock Record", | |||||
| }; | |||||
| const SearchView: React.FC = async () => { | |||||
| const { t } = await getServerI18n("inventory"); | |||||
| return ( | |||||
| <> | |||||
| <I18nProvider namespaces={["inventory", "common"]}> | |||||
| <Suspense fallback={<SearchPage.Loading />}> | |||||
| <SearchPage /> | |||||
| </Suspense> | |||||
| </I18nProvider> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default SearchView; | |||||
| @@ -724,6 +724,7 @@ export const fetchAllJoborderProductProcessInfo = cache(async () => { | |||||
| } | } | ||||
| ); | ); | ||||
| }); | }); | ||||
| /* | /* | ||||
| export const updateProductProcessLineQty = async (request: UpdateProductProcessLineQtyRequest) => { | export const updateProductProcessLineQty = async (request: UpdateProductProcessLineQtyRequest) => { | ||||
| return serverFetchJson<UpdateProductProcessLineQtyResponse>( | return serverFetchJson<UpdateProductProcessLineQtyResponse>( | ||||
| @@ -1167,4 +1168,59 @@ export const updateProductProcessLineProcessingTimeSetupTimeChangeoverTime = asy | |||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| } | } | ||||
| ); | ); | ||||
| }; | |||||
| }; | |||||
| export interface MaterialPickStatusItem { | |||||
| id: number; | |||||
| pickOrderId: number | null; | |||||
| pickOrderCode: string | null; | |||||
| jobOrderId: number | null; | |||||
| jobOrderCode: string | null; | |||||
| itemId: number | null; | |||||
| itemCode: string | null; | |||||
| itemName: string | null; | |||||
| jobOrderQty: number | null; | |||||
| uom: string | null; | |||||
| pickStartTime: string | null; // ISO datetime string | |||||
| pickEndTime: string | null; // ISO datetime string | |||||
| numberOfItemsToPick: number; | |||||
| numberOfItemsWithIssue: number; | |||||
| pickStatus: string | null; | |||||
| } | |||||
| export const fetchMaterialPickStatus = cache(async (): Promise<MaterialPickStatusItem[]> => { | |||||
| return await serverFetchJson<MaterialPickStatusItem[]>( | |||||
| `${BASE_API_URL}/jo/material-pick-status`, | |||||
| { | |||||
| method: "GET", | |||||
| } | |||||
| ); | |||||
| }) | |||||
| export interface ProcessStatusInfo { | |||||
| startTime?: string | null; | |||||
| endTime?: string | null; | |||||
| equipmentCode?: string | null; | |||||
| isRequired: boolean; | |||||
| } | |||||
| export interface JobProcessStatusResponse { | |||||
| jobOrderId: number; | |||||
| jobOrderCode: string; | |||||
| itemCode: string; | |||||
| itemName: string; | |||||
| planEndTime?: string | null; | |||||
| processes: ProcessStatusInfo[]; | |||||
| } | |||||
| // 添加API调用函数 | |||||
| export const fetchJobProcessStatus = cache(async () => { | |||||
| return serverFetchJson<JobProcessStatusResponse[]>( | |||||
| `${BASE_API_URL}/product-process/Demo/JobProcessStatus`, | |||||
| { | |||||
| method: "GET", | |||||
| next: { tags: ["jobProcessStatus"] }, | |||||
| } | |||||
| ); | |||||
| }); | |||||
| ; | |||||
| @@ -3,6 +3,11 @@ | |||||
| import { cache } from 'react'; | import { cache } from 'react'; | ||||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; // 改为 serverFetchJson | import { serverFetchJson } from "@/app/utils/fetchUtil"; // 改为 serverFetchJson | ||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| export interface RecordsRes<T> { | |||||
| records: T[]; | |||||
| total: number; | |||||
| } | |||||
| export interface InventoryLotDetailResponse { | export interface InventoryLotDetailResponse { | ||||
| id: number; | id: number; | ||||
| inventoryLotId: number; | inventoryLotId: number; | ||||
| @@ -39,30 +44,34 @@ export interface InventoryLotDetailResponse { | |||||
| export const getInventoryLotDetailsBySection = async ( | export const getInventoryLotDetailsBySection = async ( | ||||
| stockTakeSection: string, | stockTakeSection: string, | ||||
| stockTakeId?: number | null | |||||
| stockTakeId?: number | null, | |||||
| pageNum?: number, | |||||
| pageSize?: number | |||||
| ) => { | ) => { | ||||
| console.log('🌐 [API] getInventoryLotDetailsBySection called with:', { | console.log('🌐 [API] getInventoryLotDetailsBySection called with:', { | ||||
| stockTakeSection, | stockTakeSection, | ||||
| stockTakeId | |||||
| stockTakeId, | |||||
| pageNum, | |||||
| pageSize | |||||
| }); | }); | ||||
| const encodedSection = encodeURIComponent(stockTakeSection); | const encodedSection = encodeURIComponent(stockTakeSection); | ||||
| let url = `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsBySection?stockTakeSection=${encodedSection}`; | |||||
| let url = `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsBySection?stockTakeSection=${encodedSection}&pageNum=${pageNum}&pageSize=${pageSize}`; | |||||
| if (stockTakeId != null && stockTakeId > 0) { | if (stockTakeId != null && stockTakeId > 0) { | ||||
| url += `&stockTakeId=${stockTakeId}`; | url += `&stockTakeId=${stockTakeId}`; | ||||
| } | } | ||||
| console.log(' [API] Full URL:', url); | console.log(' [API] Full URL:', url); | ||||
| const details = await serverFetchJson<InventoryLotDetailResponse[]>( | |||||
| const response = await serverFetchJson<RecordsRes<InventoryLotDetailResponse>>( | |||||
| url, | url, | ||||
| { | { | ||||
| method: "GET", | method: "GET", | ||||
| }, | }, | ||||
| ); | ); | ||||
| console.log('[API] Response received:', details); | |||||
| return details; | |||||
| console.log('[API] Response received:', response); | |||||
| return response; | |||||
| } | } | ||||
| export interface SaveStockTakeRecordRequest { | export interface SaveStockTakeRecordRequest { | ||||
| stockTakeRecordId?: number | null; | stockTakeRecordId?: number | null; | ||||
| @@ -100,6 +109,7 @@ export const importStockTake = async (data: FormData) => { | |||||
| } | } | ||||
| export const getStockTakeRecords = async () => { | export const getStockTakeRecords = async () => { | ||||
| const stockTakeRecords = await serverFetchJson<AllPickedStockTakeListReponse[]>( // 改为 serverFetchJson | const stockTakeRecords = await serverFetchJson<AllPickedStockTakeListReponse[]>( // 改为 serverFetchJson | ||||
| `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList`, | `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList`, | ||||
| { | { | ||||
| @@ -277,28 +287,86 @@ export const updateStockTakeRecordStatusToNotMatch = async ( | |||||
| export const getInventoryLotDetailsBySectionNotMatch = async ( | export const getInventoryLotDetailsBySectionNotMatch = async ( | ||||
| stockTakeSection: string, | stockTakeSection: string, | ||||
| stockTakeId?: number | null | |||||
| stockTakeId?: number | null, | |||||
| pageNum: number = 0, | |||||
| pageSize: number = 10 | |||||
| ) => { | ) => { | ||||
| console.log('🌐 [API] getInventoryLotDetailsBySectionNotMatch called with:', { | |||||
| stockTakeSection, | |||||
| stockTakeId | |||||
| }); | |||||
| const encodedSection = encodeURIComponent(stockTakeSection); | const encodedSection = encodeURIComponent(stockTakeSection); | ||||
| let url = `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsBySectionNotMatch?stockTakeSection=${encodedSection}`; | |||||
| let url = `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsBySectionNotMatch?stockTakeSection=${encodedSection}&pageNum=${pageNum}`; | |||||
| // Only add pageSize if it's not "all" (which would be a large number) | |||||
| if (pageSize < 100000) { | |||||
| url += `&pageSize=${pageSize}`; | |||||
| } | |||||
| // If pageSize is large (meaning "all"), don't send it - backend will return all | |||||
| if (stockTakeId != null && stockTakeId > 0) { | if (stockTakeId != null && stockTakeId > 0) { | ||||
| url += `&stockTakeId=${stockTakeId}`; | url += `&stockTakeId=${stockTakeId}`; | ||||
| } | } | ||||
| console.log(' [API] Full URL:', url); | |||||
| const details = await serverFetchJson<InventoryLotDetailResponse[]>( | |||||
| const response = await serverFetchJson<RecordsRes<InventoryLotDetailResponse>>( | |||||
| url, | url, | ||||
| { | { | ||||
| method: "GET", | method: "GET", | ||||
| }, | }, | ||||
| ); | ); | ||||
| console.log('[API] Response received:', details); | |||||
| return details; | |||||
| return response; | |||||
| } | } | ||||
| export interface SearchStockTransactionRequest { | |||||
| startDate: string | null; | |||||
| endDate: string | null; | |||||
| itemCode: string | null; | |||||
| itemName: string | null; | |||||
| type: string | null; | |||||
| pageNum: number; | |||||
| pageSize: number; | |||||
| } | |||||
| export interface StockTransactionResponse { | |||||
| id: number; | |||||
| transactionType: string; | |||||
| itemId: number; | |||||
| itemCode: string | null; | |||||
| itemName: string | null; | |||||
| balanceQty: number | null; | |||||
| qty: number; | |||||
| type: string | null; | |||||
| status: string; | |||||
| transactionDate: string | null; | |||||
| date: string | null; // 添加这个字段 | |||||
| lotNo: string | null; | |||||
| stockInId: number | null; | |||||
| stockOutId: number | null; | |||||
| remarks: string | null; | |||||
| } | |||||
| export interface StockTransactionListResponse { | |||||
| records: RecordsRes<StockTransactionResponse>; | |||||
| } | |||||
| export const searchStockTransactions = cache(async (request: SearchStockTransactionRequest) => { | |||||
| // 构建查询字符串 | |||||
| const params = new URLSearchParams(); | |||||
| if (request.itemCode) params.append("itemCode", request.itemCode); | |||||
| if (request.itemName) params.append("itemName", request.itemName); | |||||
| if (request.type) params.append("type", request.type); | |||||
| if (request.startDate) params.append("startDate", request.startDate); | |||||
| if (request.endDate) params.append("endDate", request.endDate); | |||||
| params.append("pageNum", String(request.pageNum || 0)); | |||||
| params.append("pageSize", String(request.pageSize || 100)); | |||||
| const queryString = params.toString(); | |||||
| const url = `${BASE_API_URL}/stockTakeRecord/searchStockTransactions${queryString ? `?${queryString}` : ''}`; | |||||
| const response = await serverFetchJson<RecordsRes<StockTransactionResponse>>( | |||||
| url, | |||||
| { | |||||
| method: "GET", | |||||
| next: { tags: ["Stock Transaction List"] }, | |||||
| } | |||||
| ); | |||||
| // 确保返回正确的格式 | |||||
| return response?.records || []; | |||||
| }); | |||||
| @@ -37,6 +37,7 @@ import { | |||||
| import { fetchPrinterCombo } from "@/app/api/settings/printer"; | import { fetchPrinterCombo } from "@/app/api/settings/printer"; | ||||
| import { PrinterCombo } from "@/app/api/settings/printer"; | import { PrinterCombo } from "@/app/api/settings/printer"; | ||||
| import JoPickOrderDetail from "./JoPickOrderDetail"; | import JoPickOrderDetail from "./JoPickOrderDetail"; | ||||
| import MaterialPickStatusTable from "./MaterialPickStatusTable"; | |||||
| interface Props { | interface Props { | ||||
| pickOrders: PickOrderResult[]; | pickOrders: PickOrderResult[]; | ||||
| printerCombo: PrinterCombo[]; | printerCombo: PrinterCombo[]; | ||||
| @@ -489,6 +490,7 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => { | |||||
| <Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable"> | <Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable"> | ||||
| <Tab label={t("Jo Pick Order Detail")} iconPosition="end" /> | <Tab label={t("Jo Pick Order Detail")} iconPosition="end" /> | ||||
| <Tab label={t("Complete Job Order Record")} iconPosition="end" /> | <Tab label={t("Complete Job Order Record")} iconPosition="end" /> | ||||
| <Tab label={t("Material Pick Status")} iconPosition="end" /> | |||||
| </Tabs> | </Tabs> | ||||
| </Box> | </Box> | ||||
| @@ -503,6 +505,7 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => { | |||||
| printQty={printQty} | printQty={printQty} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {tabIndex === 2 && <MaterialPickStatusTable />} | |||||
| </Box> | </Box> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -0,0 +1,381 @@ | |||||
| "use client"; | |||||
| import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; | |||||
| import { | |||||
| Box, | |||||
| Typography, | |||||
| Card, | |||||
| CardContent, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Paper, | |||||
| CircularProgress, | |||||
| TablePagination, | |||||
| } from '@mui/material'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| import dayjs from 'dayjs'; | |||||
| import { arrayToDayjs } from '@/app/utils/formatUtil'; | |||||
| import { fetchMaterialPickStatus, MaterialPickStatusItem } from '@/app/api/jo/actions'; | |||||
| const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes in milliseconds | |||||
| const MaterialPickStatusTable: React.FC = () => { | |||||
| const { t } = useTranslation("jo"); | |||||
| const [data, setData] = useState<MaterialPickStatusItem[]>([]); | |||||
| const [loading, setLoading] = useState<boolean>(true); | |||||
| const refreshCountRef = useRef<number>(0); | |||||
| const [paginationController, setPaginationController] = useState({ | |||||
| pageNum: 0, | |||||
| pageSize: 10, | |||||
| }); | |||||
| const loadData = useCallback(async () => { | |||||
| setLoading(true); | |||||
| try { | |||||
| const result = await fetchMaterialPickStatus(); | |||||
| // On second refresh, clear completed pick orders | |||||
| if (refreshCountRef.current >= 1) { | |||||
| // const filtered = result.filter(item => | |||||
| // item.pickStatus?.toLowerCase() !== 'completed' | |||||
| //); | |||||
| setData(result); | |||||
| } else { | |||||
| setData(result || []); | |||||
| } | |||||
| refreshCountRef.current += 1; | |||||
| } catch (error) { | |||||
| console.error('Error fetching material pick status:', error); | |||||
| setData([]); // Set empty array on error to stop loading | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }, []); // Remove refreshCount from dependencies | |||||
| useEffect(() => { | |||||
| // Initial load | |||||
| loadData(); | |||||
| // Set up auto-refresh every 10 minutes | |||||
| const interval = setInterval(() => { | |||||
| loadData(); | |||||
| }, REFRESH_INTERVAL); | |||||
| return () => clearInterval(interval); | |||||
| }, [loadData]); // Only depend on loadData, which is now stable | |||||
| const formatTime = (timeData: any): string => { | |||||
| if (!timeData) return ''; | |||||
| // Handle LocalDateTime ISO string format (e.g., "2026-01-09T18:01:54") | |||||
| if (typeof timeData === 'string') { | |||||
| // Try parsing as ISO string first (most common format from LocalDateTime) | |||||
| const parsed = dayjs(timeData); | |||||
| if (parsed.isValid()) { | |||||
| return parsed.format('HH:mm'); | |||||
| } | |||||
| // Try parsing as custom format YYYYMMDDHHmmss | |||||
| const customParsed = dayjs(timeData, 'YYYYMMDDHHmmss'); | |||||
| if (customParsed.isValid()) { | |||||
| return customParsed.format('HH:mm'); | |||||
| } | |||||
| // Try parsing as time string (HH:mm or HH:mm:ss) | |||||
| const parts = timeData.split(':'); | |||||
| if (parts.length >= 2) { | |||||
| const hour = parseInt(parts[0], 10); | |||||
| const minute = parseInt(parts[1] || '0', 10); | |||||
| if (!isNaN(hour) && !isNaN(minute)) { | |||||
| return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; | |||||
| } | |||||
| } | |||||
| } else if (Array.isArray(timeData)) { | |||||
| // Handle array format [year, month, day, hour, minute, second] | |||||
| const hour = timeData[3] ?? timeData[0] ?? 0; | |||||
| const minute = timeData[4] ?? timeData[1] ?? 0; | |||||
| return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; | |||||
| } | |||||
| return ''; | |||||
| }; | |||||
| const calculatePickTime = (startTime: any, endTime: any): number => { | |||||
| if (!startTime || !endTime) return 0; | |||||
| let start: dayjs.Dayjs; | |||||
| let end: dayjs.Dayjs; | |||||
| // Parse start time | |||||
| if (Array.isArray(startTime)) { | |||||
| // Array format: [year, month, day, hour, minute, second] | |||||
| if (startTime.length >= 5) { | |||||
| const year = startTime[0] || 0; | |||||
| const month = (startTime[1] || 1) - 1; // month is 0-indexed in JS Date | |||||
| const day = startTime[2] || 1; | |||||
| const hour = startTime[3] || 0; | |||||
| const minute = startTime[4] || 0; | |||||
| const second = startTime[5] || 0; | |||||
| // Create Date object and convert to dayjs | |||||
| const date = new Date(year, month, day, hour, minute, second); | |||||
| start = dayjs(date); | |||||
| console.log('Parsed start time:', { | |||||
| array: startTime, | |||||
| date: date.toISOString(), | |||||
| dayjs: start.format('YYYY-MM-DD HH:mm:ss'), | |||||
| isValid: start.isValid() | |||||
| }); | |||||
| } else { | |||||
| // Fallback to arrayToDayjs for shorter arrays | |||||
| start = arrayToDayjs(startTime, true); | |||||
| } | |||||
| } else if (typeof startTime === 'string') { | |||||
| // Try ISO format first | |||||
| start = dayjs(startTime); | |||||
| if (!start.isValid()) { | |||||
| // Try custom format | |||||
| start = dayjs(startTime, 'YYYYMMDDHHmmss'); | |||||
| } | |||||
| } else { | |||||
| start = dayjs(startTime); | |||||
| } | |||||
| // Parse end time | |||||
| if (Array.isArray(endTime)) { | |||||
| // Array format: [year, month, day, hour, minute, second] | |||||
| if (endTime.length >= 5) { | |||||
| const year = endTime[0] || 0; | |||||
| const month = (endTime[1] || 1) - 1; // month is 0-indexed in JS Date | |||||
| const day = endTime[2] || 1; | |||||
| const hour = endTime[3] || 0; | |||||
| const minute = endTime[4] || 0; | |||||
| const second = endTime[5] || 0; | |||||
| // Create Date object and convert to dayjs | |||||
| const date = new Date(year, month, day, hour, minute, second); | |||||
| end = dayjs(date); | |||||
| console.log('Parsed end time:', { | |||||
| array: endTime, | |||||
| date: date.toISOString(), | |||||
| dayjs: end.format('YYYY-MM-DD HH:mm:ss'), | |||||
| isValid: end.isValid() | |||||
| }); | |||||
| } else { | |||||
| // Fallback to arrayToDayjs for shorter arrays | |||||
| end = arrayToDayjs(endTime, true); | |||||
| } | |||||
| } else if (typeof endTime === 'string') { | |||||
| // Try ISO format first | |||||
| end = dayjs(endTime); | |||||
| if (!end.isValid()) { | |||||
| // Try custom format | |||||
| end = dayjs(endTime, 'YYYYMMDDHHmmss'); | |||||
| } | |||||
| } else { | |||||
| end = dayjs(endTime); | |||||
| } | |||||
| if (!start.isValid() || !end.isValid()) { | |||||
| console.warn('Invalid time values:', { | |||||
| startTime, | |||||
| endTime, | |||||
| startValid: start.isValid(), | |||||
| endValid: end.isValid(), | |||||
| startFormat: start.isValid() ? start.format() : 'invalid', | |||||
| endFormat: end.isValid() ? end.format() : 'invalid' | |||||
| }); | |||||
| return 0; | |||||
| } | |||||
| // Calculate difference in seconds first, then convert to minutes | |||||
| // This handles sub-minute differences correctly | |||||
| const diffSeconds = end.diff(start, 'second'); | |||||
| const diffMinutes = Math.ceil(diffSeconds / 60); // Round up to nearest minute | |||||
| console.log('Time calculation:', { | |||||
| start: start.format('YYYY-MM-DD HH:mm:ss'), | |||||
| end: end.format('YYYY-MM-DD HH:mm:ss'), | |||||
| diffSeconds, | |||||
| diffMinutes | |||||
| }); | |||||
| return diffMinutes > 0 ? diffMinutes : 0; | |||||
| }; | |||||
| const handlePageChange = useCallback((event: unknown, newPage: number) => { | |||||
| setPaginationController(prev => ({ | |||||
| ...prev, | |||||
| pageNum: newPage, | |||||
| })); | |||||
| }, []); | |||||
| const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||||
| const newPageSize = parseInt(event.target.value, 10); | |||||
| setPaginationController({ | |||||
| pageNum: 0, | |||||
| pageSize: newPageSize, | |||||
| }); | |||||
| }, []); | |||||
| const paginatedData = useMemo(() => { | |||||
| const startIndex = paginationController.pageNum * paginationController.pageSize; | |||||
| const endIndex = startIndex + paginationController.pageSize; | |||||
| return data.slice(startIndex, endIndex); | |||||
| }, [data, paginationController]); | |||||
| return ( | |||||
| <Card sx={{ mb: 2 }}> | |||||
| <CardContent> | |||||
| {/* Title */} | |||||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}> | |||||
| <Typography variant="h5" sx={{ fontWeight: 600 }}> | |||||
| {t("Material Pick Status")} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Box sx={{ mt: 2 }}> | |||||
| {loading ? ( | |||||
| <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ) : ( | |||||
| <> | |||||
| <TableContainer component={Paper}> | |||||
| <Table size="small" sx={{ minWidth: 650 }}> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("Pick Order No.- Job Order No.- Item")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("Job Order Qty")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("No. of Items to be Picked")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("No. of Items with Issue During Pick")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("Pick Start Time")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("Pick End Time")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell sx={{ | |||||
| }}> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("Pick Time Taken (minutes)")} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {paginatedData.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={9} align="center"> | |||||
| {t("No data available")} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| paginatedData.map((row) => { | |||||
| const pickTimeTaken = calculatePickTime(row.pickStartTime, row.pickEndTime); | |||||
| return ( | |||||
| <TableRow key={row.id}> | |||||
| <TableCell> | |||||
| <Box> {row.pickOrderCode || '-'}</Box> | |||||
| <br /> | |||||
| <Box>{row.jobOrderCode || '-'}</Box> | |||||
| <br /> | |||||
| <Box>{row.itemCode || '-'} {row.itemName || '-'}</Box> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {row.jobOrderQty !== null && row.jobOrderQty !== undefined | |||||
| ? `${row.jobOrderQty} ${row.uom || ''}` | |||||
| : '-'} | |||||
| </TableCell> | |||||
| <TableCell>{row.numberOfItemsToPick ?? 0}</TableCell> | |||||
| <TableCell>{row.numberOfItemsWithIssue ?? 0}</TableCell> | |||||
| <TableCell>{formatTime(row.pickStartTime) || '-'}</TableCell> | |||||
| <TableCell>{formatTime(row.pickEndTime) || '-'}</TableCell> | |||||
| <TableCell sx={{ | |||||
| backgroundColor: 'rgba(76, 175, 80, 0.1)', | |||||
| fontWeight: 600 | |||||
| }}> | |||||
| {pickTimeTaken > 0 ? `${pickTimeTaken} ${t("minutes")}` : '-'} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| {data.length > 0 && ( | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={data.length} | |||||
| page={paginationController.pageNum} | |||||
| rowsPerPage={paginationController.pageSize} | |||||
| onPageChange={handlePageChange} | |||||
| onRowsPerPageChange={handlePageSizeChange} | |||||
| rowsPerPageOptions={[5, 10, 15, 25]} | |||||
| labelRowsPerPage={t("Rows per page")} | |||||
| /> | |||||
| )} | |||||
| </> | |||||
| )} | |||||
| </Box> | |||||
| </CardContent> | |||||
| </Card> | |||||
| ); | |||||
| }; | |||||
| export default MaterialPickStatusTable; | |||||
| @@ -0,0 +1,329 @@ | |||||
| "use client"; | |||||
| import React, { useState, useEffect, useCallback, useRef } from 'react'; | |||||
| import { | |||||
| Box, | |||||
| Typography, | |||||
| Card, | |||||
| CardContent, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Paper, | |||||
| CircularProgress, | |||||
| } from '@mui/material'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| import dayjs from 'dayjs'; | |||||
| import { fetchJobProcessStatus, JobProcessStatusResponse } from '@/app/api/jo/actions'; | |||||
| import { arrayToDayjs } from '@/app/utils/formatUtil'; | |||||
| const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes | |||||
| const JobProcessStatus: React.FC = () => { | |||||
| const { t } = useTranslation(["common", "jo"]); | |||||
| const [data, setData] = useState<JobProcessStatusResponse[]>([]); | |||||
| const [loading, setLoading] = useState<boolean>(true); | |||||
| const refreshCountRef = useRef<number>(0); | |||||
| const [currentTime, setCurrentTime] = useState(dayjs()); | |||||
| // Update current time every second for countdown | |||||
| useEffect(() => { | |||||
| const timer = setInterval(() => { | |||||
| setCurrentTime(dayjs()); | |||||
| }, 1000); | |||||
| return () => clearInterval(timer); | |||||
| }, []); | |||||
| const loadData = useCallback(async () => { | |||||
| setLoading(true); | |||||
| try { | |||||
| const result = await fetchJobProcessStatus(); | |||||
| // On second refresh, filter out completed jobs | |||||
| if (refreshCountRef.current >= 1) { | |||||
| const filtered = result.filter(item => { | |||||
| // Check if all required processes are completed | |||||
| const allCompleted = item.processes | |||||
| .filter(p => p.isRequired) | |||||
| .every(p => p.endTime != null); | |||||
| return !allCompleted; | |||||
| }); | |||||
| setData(filtered); | |||||
| } else { | |||||
| setData(result); | |||||
| } | |||||
| refreshCountRef.current += 1; | |||||
| } catch (error) { | |||||
| console.error('Error fetching job process status:', error); | |||||
| setData([]); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }, []); | |||||
| useEffect(() => { | |||||
| loadData(); | |||||
| const interval = setInterval(() => { | |||||
| loadData(); | |||||
| }, REFRESH_INTERVAL); | |||||
| return () => clearInterval(interval); | |||||
| }, [loadData]); | |||||
| const formatTime = (timeData: any): string => { | |||||
| if (!timeData) return '-'; // 改为返回 '-' 而不是 'N/A' | |||||
| // Handle array format [year, month, day, hour, minute, second] | |||||
| if (Array.isArray(timeData)) { | |||||
| try { | |||||
| const parsed = arrayToDayjs(timeData, true); | |||||
| if (parsed.isValid()) { | |||||
| return parsed.format('HH:mm'); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error('Error parsing array time:', error); | |||||
| } | |||||
| } | |||||
| // Handle LocalDateTime ISO string format (e.g., "2026-01-09T18:01:54") | |||||
| if (typeof timeData === 'string') { | |||||
| const parsed = dayjs(timeData); | |||||
| if (parsed.isValid()) { | |||||
| return parsed.format('HH:mm'); | |||||
| } | |||||
| } | |||||
| return '-'; | |||||
| }; | |||||
| const calculateRemainingTime = (planEndTime: any): string => { | |||||
| if (!planEndTime) return '-'; | |||||
| let endTime: dayjs.Dayjs; | |||||
| // Handle array format [year, month, day, hour, minute, second] | |||||
| // 使用与 OverallTimeRemainingCard 相同的方式处理 | |||||
| if (Array.isArray(planEndTime)) { | |||||
| try { | |||||
| const [year, month, day, hour = 0, minute = 0, second = 0] = planEndTime; | |||||
| // 注意:JavaScript Date 构造函数中月份是 0-based,所以需要 month - 1 | |||||
| endTime = dayjs(new Date(year, month - 1, day, hour, minute, second)); | |||||
| console.log('Parsed planEndTime array:', { | |||||
| array: planEndTime, | |||||
| parsed: endTime.format('YYYY-MM-DD HH:mm:ss'), | |||||
| isValid: endTime.isValid() | |||||
| }); | |||||
| } catch (error) { | |||||
| console.error('Error parsing array planEndTime:', error); | |||||
| return '-'; | |||||
| } | |||||
| } else if (typeof planEndTime === 'string') { | |||||
| endTime = dayjs(planEndTime); | |||||
| console.log('Parsed planEndTime string:', { | |||||
| string: planEndTime, | |||||
| parsed: endTime.format('YYYY-MM-DD HH:mm:ss'), | |||||
| isValid: endTime.isValid() | |||||
| }); | |||||
| } else { | |||||
| return '-'; | |||||
| } | |||||
| if (!endTime.isValid()) { | |||||
| console.error('Invalid endTime:', planEndTime); | |||||
| return '-'; | |||||
| } | |||||
| const diff = endTime.diff(currentTime, 'minute'); | |||||
| console.log('Remaining time calculation:', { | |||||
| endTime: endTime.format('YYYY-MM-DD HH:mm:ss'), | |||||
| currentTime: currentTime.format('YYYY-MM-DD HH:mm:ss'), | |||||
| diffMinutes: diff | |||||
| }); | |||||
| if (diff < 0) return '0'; | |||||
| const hours = Math.floor(diff / 60); | |||||
| const minutes = diff % 60; | |||||
| return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; | |||||
| }; | |||||
| const calculateWaitTime = ( | |||||
| currentProcessEndTime: any, | |||||
| nextProcessStartTime: any, | |||||
| isLastProcess: boolean | |||||
| ): string => { | |||||
| if (isLastProcess) return '-'; | |||||
| if (!currentProcessEndTime) return '-'; | |||||
| if (nextProcessStartTime) return '0'; // Next process has started, stop counting | |||||
| let endTime: dayjs.Dayjs; | |||||
| // Handle array format | |||||
| if (Array.isArray(currentProcessEndTime)) { | |||||
| try { | |||||
| endTime = arrayToDayjs(currentProcessEndTime, true); | |||||
| } catch (error) { | |||||
| console.error('Error parsing array endTime:', error); | |||||
| return '-'; | |||||
| } | |||||
| } else if (typeof currentProcessEndTime === 'string') { | |||||
| endTime = dayjs(currentProcessEndTime); | |||||
| } else { | |||||
| return '-'; | |||||
| } | |||||
| if (!endTime.isValid()) return '-'; | |||||
| const diff = currentTime.diff(endTime, 'minute'); | |||||
| return diff > 0 ? diff.toString() : '0'; | |||||
| }; | |||||
| return ( | |||||
| <Card sx={{ mb: 2 }}> | |||||
| <CardContent> | |||||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}> | |||||
| <Typography variant="h5" sx={{ fontWeight: 600 }}> | |||||
| {t("Job Process Status", { ns: "jobProcessStatus" })} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Box sx={{ mt: 2 }}> | |||||
| {loading ? ( | |||||
| <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ) : ( | |||||
| <TableContainer component={Paper}> | |||||
| <Table size="small" sx={{ minWidth: 1200 }}> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell rowSpan={3}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("Job Order No.", { ns: "jobProcessStatus" })} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell rowSpan={3}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("FG / WIP Item", { ns: "jobProcessStatus" })} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell rowSpan={3}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("Production Time Remaining", { ns: "jobProcessStatus" })} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell colSpan={6} align="center"> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("Process Status / Time [hh:mm]", { ns: "jobProcessStatus" })} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| <TableRow> | |||||
| {[1, 2, 3, 4, 5, 6].map((num) => ( | |||||
| <TableCell key={num} align="center"> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("Process", { ns: "jobProcessStatus" })} {num} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| ))} | |||||
| </TableRow> | |||||
| <TableRow> | |||||
| {[1, 2, 3, 4, 5, 6].map((num) => ( | |||||
| <TableCell key={num} align="center"> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="caption" sx={{ fontWeight: 600 }}> | |||||
| {t("Start", { ns: "jobProcessStatus" })} | |||||
| </Typography> | |||||
| <Typography variant="caption" sx={{ fontWeight: 600 }}> | |||||
| {t("Finish", { ns: "jobProcessStatus" })} | |||||
| </Typography> | |||||
| <Typography variant="caption" sx={{ fontWeight: 600 }}> | |||||
| {t("Wait Time [minutes]", { ns: "jobProcessStatus" })} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| ))} | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {data.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={9} align="center"> | |||||
| {t("No data available")} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| data.map((row) => ( | |||||
| <TableRow key={row.jobOrderId}> | |||||
| <TableCell> | |||||
| {row.jobOrderCode || '-'} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Box>{row.itemCode || '-'}</Box> | |||||
| <Box>{row.itemName || '-'}</Box> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {calculateRemainingTime(row.planEndTime)} | |||||
| </TableCell> | |||||
| {row.processes.map((process, index) => { | |||||
| const isLastProcess = index === row.processes.length - 1 || | |||||
| !row.processes.slice(index + 1).some(p => p.isRequired); | |||||
| const nextProcess = index < row.processes.length - 1 ? row.processes[index + 1] : null; | |||||
| const waitTime = calculateWaitTime( | |||||
| process.endTime, | |||||
| nextProcess?.startTime, | |||||
| isLastProcess | |||||
| ); | |||||
| // 如果工序不是必需的,只显示一个 N/A | |||||
| if (!process.isRequired) { | |||||
| return ( | |||||
| <TableCell key={index} align="center"> | |||||
| <Typography variant="body2"> | |||||
| N/A | |||||
| </Typography> | |||||
| </TableCell> | |||||
| ); | |||||
| } | |||||
| // 如果工序是必需的,显示三行(Start、Finish、Wait Time) | |||||
| return ( | |||||
| <TableCell key={index} align="center"> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="body2">{process.equipmentCode || '-'}</Typography> | |||||
| <Typography variant="body2"> | |||||
| {formatTime(process.startTime)} | |||||
| </Typography> | |||||
| <Typography variant="body2"> | |||||
| {formatTime(process.endTime)} | |||||
| </Typography> | |||||
| <Typography variant="body2" sx={{ | |||||
| color: waitTime !== '-' && parseInt(waitTime) > 0 ? 'warning.main' : 'text.primary' | |||||
| }}> | |||||
| {waitTime} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| ); | |||||
| })} | |||||
| </TableRow> | |||||
| )) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| )} | |||||
| </Box> | |||||
| </CardContent> | |||||
| </Card> | |||||
| ); | |||||
| }; | |||||
| export default JobProcessStatus; | |||||
| @@ -8,6 +8,7 @@ import ProductionProcessDetail from "@/components/ProductionProcess/ProductionPr | |||||
| import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail"; | import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail"; | ||||
| import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan"; | import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan"; | ||||
| import FinishedQcJobOrderList from "@/components/ProductionProcess/FinishedQcJobOrderList"; | import FinishedQcJobOrderList from "@/components/ProductionProcess/FinishedQcJobOrderList"; | ||||
| import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus"; | |||||
| import { | import { | ||||
| fetchProductProcesses, | fetchProductProcesses, | ||||
| fetchProductProcessesByJobOrderId, | fetchProductProcessesByJobOrderId, | ||||
| @@ -164,6 +165,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| <Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}> | <Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}> | ||||
| <Tab label={t("Production Process")} /> | <Tab label={t("Production Process")} /> | ||||
| <Tab label={t("Finished QC Job Orders")} /> | <Tab label={t("Finished QC Job Orders")} /> | ||||
| <Tab label={t("Job Process Status")} /> | |||||
| </Tabs> | </Tabs> | ||||
| {tabIndex === 0 && ( | {tabIndex === 0 && ( | ||||
| @@ -190,6 +192,9 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| selectedPrinter={selectedPrinter} | selectedPrinter={selectedPrinter} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {tabIndex === 2 && ( | |||||
| <JobProcessStatus /> | |||||
| )} | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -0,0 +1,444 @@ | |||||
| "use client"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | |||||
| import { useCallback, useMemo, useState, useEffect, useRef } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import SearchResults, { Column } from "../SearchResults/index"; | |||||
| import { StockTransactionResponse, SearchStockTransactionRequest } from "@/app/api/stockTake/actions"; | |||||
| import { decimalFormatter } from "@/app/utils/formatUtil"; | |||||
| import { Stack, Box } from "@mui/material"; | |||||
| import { searchStockTransactions } from "@/app/api/stockTake/actions"; | |||||
| interface Props { | |||||
| dataList: StockTransactionResponse[]; | |||||
| } | |||||
| type SearchQuery = { | |||||
| itemCode?: string; | |||||
| itemName?: string; | |||||
| type?: string; | |||||
| startDate?: string; | |||||
| endDate?: string; | |||||
| }; | |||||
| // 扩展类型以包含计算字段 | |||||
| interface ExtendedStockTransaction extends StockTransactionResponse { | |||||
| formattedDate: string; | |||||
| inQty: number; | |||||
| outQty: number; | |||||
| balanceQty: number; | |||||
| } | |||||
| const SearchPage: React.FC<Props> = ({ dataList: initialDataList }) => { | |||||
| const { t } = useTranslation("inventory"); | |||||
| // 添加数据状态 | |||||
| const [dataList, setDataList] = useState<StockTransactionResponse[]>(initialDataList); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [filterArgs, setFilterArgs] = useState<Record<string, any>>({}); | |||||
| const isInitialMount = useRef(true); | |||||
| // 添加分页状态 | |||||
| const [page, setPage] = useState(0); | |||||
| const [pageSize, setPageSize] = useState<number | string>(10); | |||||
| const [pagingController, setPagingController] = useState({ pageNum: 1, pageSize: 10 }); | |||||
| const [hasSearchQuery, setHasSearchQuery] = useState(false); | |||||
| const [totalCount, setTotalCount] = useState(initialDataList.length); | |||||
| const processedData = useMemo(() => { | |||||
| // 按日期和 itemId 排序 - 优先使用 date 字段,如果没有则使用 transactionDate | |||||
| const sorted = [...dataList].sort((a, b) => { | |||||
| // 优先使用 date 字段,如果没有则使用 transactionDate 的日期部分 | |||||
| const getDateValue = (item: StockTransactionResponse): number => { | |||||
| if (item.date) { | |||||
| return new Date(item.date).getTime(); | |||||
| } | |||||
| if (item.transactionDate) { | |||||
| if (Array.isArray(item.transactionDate)) { | |||||
| const [year, month, day] = item.transactionDate; | |||||
| return new Date(year, month - 1, day).getTime(); | |||||
| } else { | |||||
| return new Date(item.transactionDate).getTime(); | |||||
| } | |||||
| } | |||||
| return 0; | |||||
| }; | |||||
| const dateA = getDateValue(a); | |||||
| const dateB = getDateValue(b); | |||||
| if (dateA !== dateB) return dateA - dateB; // 从旧到新排序 | |||||
| return a.itemId - b.itemId; | |||||
| }); | |||||
| // 计算每个 item 的累计余额 | |||||
| const balanceMap = new Map<number, number>(); // itemId -> balance | |||||
| const processed: ExtendedStockTransaction[] = []; | |||||
| sorted.forEach((item) => { | |||||
| const currentBalance = balanceMap.get(item.itemId) || 0; | |||||
| let newBalance = currentBalance; | |||||
| // 根据类型计算余额 | |||||
| if (item.transactionType === "IN") { | |||||
| newBalance = currentBalance + item.qty; | |||||
| } else if (item.transactionType === "OUT") { | |||||
| newBalance = currentBalance - item.qty; | |||||
| } | |||||
| balanceMap.set(item.itemId, newBalance); | |||||
| // 格式化日期 - 优先使用 date 字段 | |||||
| let formattedDate = ""; | |||||
| if (item.date) { | |||||
| // 如果 date 是字符串格式 "yyyy-MM-dd" | |||||
| const date = new Date(item.date); | |||||
| if (!isNaN(date.getTime())) { | |||||
| const year = date.getFullYear(); | |||||
| const month = String(date.getMonth() + 1).padStart(2, "0"); | |||||
| const day = String(date.getDate()).padStart(2, "0"); | |||||
| formattedDate = `${year}-${month}-${day}`; | |||||
| } | |||||
| } else if (item.transactionDate) { | |||||
| // 回退到 transactionDate | |||||
| if (Array.isArray(item.transactionDate)) { | |||||
| const [year, month, day] = item.transactionDate; | |||||
| formattedDate = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; | |||||
| } else if (typeof item.transactionDate === 'string') { | |||||
| const date = new Date(item.transactionDate); | |||||
| if (!isNaN(date.getTime())) { | |||||
| const year = date.getFullYear(); | |||||
| const month = String(date.getMonth() + 1).padStart(2, "0"); | |||||
| const day = String(date.getDate()).padStart(2, "0"); | |||||
| formattedDate = `${year}-${month}-${day}`; | |||||
| } | |||||
| } else { | |||||
| const date = new Date(item.transactionDate); | |||||
| if (!isNaN(date.getTime())) { | |||||
| const year = date.getFullYear(); | |||||
| const month = String(date.getMonth() + 1).padStart(2, "0"); | |||||
| const day = String(date.getDate()).padStart(2, "0"); | |||||
| formattedDate = `${year}-${month}-${day}`; | |||||
| } | |||||
| } | |||||
| } | |||||
| processed.push({ | |||||
| ...item, | |||||
| formattedDate, | |||||
| inQty: item.transactionType === "IN" ? item.qty : 0, | |||||
| outQty: item.transactionType === "OUT" ? item.qty : 0, | |||||
| balanceQty: item.balanceQty ? item.balanceQty : newBalance, | |||||
| }); | |||||
| }); | |||||
| return processed; | |||||
| }, [dataList]); | |||||
| // 修复:使用 processedData 初始化 filteredList | |||||
| const [filteredList, setFilteredList] = useState<ExtendedStockTransaction[]>(processedData); | |||||
| // 当 processedData 变化时更新 filteredList(不更新 pagingController,避免循环) | |||||
| useEffect(() => { | |||||
| setFilteredList(processedData); | |||||
| setTotalCount(processedData.length); | |||||
| // 只在初始加载时设置 pageSize | |||||
| if (isInitialMount.current && processedData.length > 0) { | |||||
| setPageSize("all"); | |||||
| setPagingController(prev => ({ ...prev, pageSize: processedData.length })); | |||||
| setPage(0); | |||||
| isInitialMount.current = false; | |||||
| } | |||||
| }, [processedData]); | |||||
| // API 调用函数(参考 PoSearch 的实现) | |||||
| // API 调用函数(参考 PoSearch 的实现) | |||||
| const newPageFetch = useCallback( | |||||
| async ( | |||||
| pagingController: Record<string, number>, | |||||
| filterArgs: Record<string, any>, | |||||
| ) => { | |||||
| setLoading(true); | |||||
| try { | |||||
| // 处理空字符串,转换为 null | |||||
| const itemCode = filterArgs.itemCode?.trim() || null; | |||||
| const itemName = filterArgs.itemName?.trim() || null; | |||||
| // 验证:至少需要 itemCode 或 itemName | |||||
| if (!itemCode && !itemName) { | |||||
| console.warn("Search requires at least itemCode or itemName"); | |||||
| setDataList([]); | |||||
| setTotalCount(0); | |||||
| return; | |||||
| } | |||||
| const params: SearchStockTransactionRequest = { | |||||
| itemCode: itemCode, | |||||
| itemName: itemName, | |||||
| type: filterArgs.type?.trim() || null, | |||||
| startDate: filterArgs.startDate || null, | |||||
| endDate: filterArgs.endDate || null, | |||||
| pageNum: pagingController.pageNum - 1 || 0, | |||||
| pageSize: pagingController.pageSize || 100, | |||||
| }; | |||||
| console.log("Search params:", params); // 添加调试日志 | |||||
| const res = await searchStockTransactions(params); | |||||
| console.log("Search response:", res); // 添加调试日志 | |||||
| if (res && Array.isArray(res)) { | |||||
| setDataList(res); | |||||
| } else { | |||||
| console.error("Invalid response format:", res); | |||||
| setDataList([]); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("Fetch error:", error); | |||||
| setDataList([]); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| // 使用 useRef 来存储上一次的值,避免不必要的 API 调用 | |||||
| const prevPagingControllerRef = useRef(pagingController); | |||||
| const prevFilterArgsRef = useRef(filterArgs); | |||||
| const hasSearchedRef = useRef(false); | |||||
| // 当 filterArgs 或 pagingController 变化时调用 API(只在真正变化时调用) | |||||
| useEffect(() => { | |||||
| // 检查是否有有效的搜索条件 | |||||
| const hasValidSearch = filterArgs.itemCode || filterArgs.itemName; | |||||
| if (!hasValidSearch) { | |||||
| // 如果没有有效搜索条件,只更新 ref,不调用 API | |||||
| if (isInitialMount.current) { | |||||
| isInitialMount.current = false; | |||||
| } | |||||
| prevFilterArgsRef.current = filterArgs; | |||||
| return; | |||||
| } | |||||
| // 检查是否真的变化了 | |||||
| const pagingChanged = | |||||
| prevPagingControllerRef.current.pageNum !== pagingController.pageNum || | |||||
| prevPagingControllerRef.current.pageSize !== pagingController.pageSize; | |||||
| const filterChanged = JSON.stringify(prevFilterArgsRef.current) !== JSON.stringify(filterArgs); | |||||
| // 如果是第一次有效搜索,或者条件/分页发生变化,则调用 API | |||||
| if (!hasSearchedRef.current || pagingChanged || filterChanged) { | |||||
| newPageFetch(pagingController, filterArgs); | |||||
| prevPagingControllerRef.current = pagingController; | |||||
| prevFilterArgsRef.current = filterArgs; | |||||
| hasSearchedRef.current = true; | |||||
| isInitialMount.current = false; | |||||
| } | |||||
| }, [newPageFetch, pagingController, filterArgs]); | |||||
| // 分页处理函数 | |||||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | |||||
| setPage(newPage); | |||||
| setPagingController(prev => ({ ...prev, pageNum: newPage + 1 })); | |||||
| }, []); | |||||
| const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |||||
| const newSize = parseInt(event.target.value, 10); | |||||
| if (newSize === -1) { | |||||
| setPageSize("all"); | |||||
| setPagingController(prev => ({ ...prev, pageSize: filteredList.length, pageNum: 1 })); | |||||
| } else if (!isNaN(newSize)) { | |||||
| setPageSize(newSize); | |||||
| setPagingController(prev => ({ ...prev, pageSize: newSize, pageNum: 1 })); | |||||
| } | |||||
| setPage(0); | |||||
| }, [filteredList.length]); | |||||
| const searchCriteria: Criterion<string>[] = useMemo( | |||||
| () => [ | |||||
| { | |||||
| label: t("Item Code"), | |||||
| paramName: "itemCode", | |||||
| type: "text", | |||||
| }, | |||||
| { | |||||
| label: t("Item Name"), | |||||
| paramName: "itemName", | |||||
| type: "text", | |||||
| }, | |||||
| { | |||||
| label: t("Type"), | |||||
| paramName: "type", | |||||
| type: "text", | |||||
| }, | |||||
| { | |||||
| label: t("Start Date"), | |||||
| paramName: "startDate", | |||||
| type: "date", | |||||
| }, | |||||
| { | |||||
| label: t("End Date"), | |||||
| paramName: "endDate", | |||||
| type: "date", | |||||
| }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| const columns = useMemo<Column<ExtendedStockTransaction>[]>( | |||||
| () => [ | |||||
| { | |||||
| name: "formattedDate" as keyof ExtendedStockTransaction, | |||||
| label: t("Date"), | |||||
| align: "left", | |||||
| }, | |||||
| { | |||||
| name: "itemCode" as keyof ExtendedStockTransaction, | |||||
| label: t("Item-lotNo"), | |||||
| align: "left", | |||||
| renderCell: (item) => ( | |||||
| <Box sx={{ | |||||
| maxWidth: 150, | |||||
| wordBreak: 'break-word', | |||||
| whiteSpace: 'normal', | |||||
| lineHeight: 1.5 | |||||
| }}> | |||||
| <Stack spacing={0.5}> | |||||
| <Box>{item.itemCode || "-"} {item.itemName || "-"}</Box> | |||||
| <Box>{item.lotNo || "-"}</Box> | |||||
| </Stack> | |||||
| </Box> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| name: "inQty" as keyof ExtendedStockTransaction, | |||||
| label: t("In Qty"), | |||||
| align: "left", | |||||
| type: "decimal", | |||||
| renderCell: (item) => ( | |||||
| <>{item.inQty > 0 ? decimalFormatter.format(item.inQty) : ""}</> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| name: "outQty" as keyof ExtendedStockTransaction, | |||||
| label: t("Out Qty"), | |||||
| align: "left", | |||||
| type: "decimal", | |||||
| renderCell: (item) => ( | |||||
| <>{item.outQty > 0 ? decimalFormatter.format(item.outQty) : ""}</> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| name: "balanceQty" as keyof ExtendedStockTransaction, | |||||
| label: t("Balance Qty"), | |||||
| align: "left", | |||||
| type: "decimal", | |||||
| }, | |||||
| { | |||||
| name: "type", | |||||
| label: t("Type"), | |||||
| align: "left", | |||||
| renderCell: (item) => { | |||||
| if (!item.type) return "-"; | |||||
| return t(item.type.toLowerCase()); | |||||
| }, | |||||
| }, | |||||
| { | |||||
| name: "status", | |||||
| label: t("Status"), | |||||
| align: "left", | |||||
| renderCell: (item) => { | |||||
| if (!item.status) return "-"; | |||||
| return t(item.status.toLowerCase()); | |||||
| }, | |||||
| }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| const handleSearch = useCallback((query: Record<string, string>) => { | |||||
| // 检查是否有搜索条件 | |||||
| const itemCode = query.itemCode?.trim(); | |||||
| const itemName = query.itemName?.trim(); | |||||
| const type = query.type?.trim(); | |||||
| const startDate = query.startDate === "Invalid Date" ? "" : query.startDate; | |||||
| const endDate = query.endDate === "Invalid Date" ? "" : query.endDate; | |||||
| // 验证:至少需要 itemCode 或 itemName | |||||
| if (!itemCode && !itemName) { | |||||
| // 可以显示提示信息 | |||||
| console.warn("Please enter at least Item Code or Item Name"); | |||||
| return; | |||||
| } | |||||
| const hasQuery = !!(itemCode || itemName || type || startDate || endDate); | |||||
| setHasSearchQuery(hasQuery); | |||||
| // 更新 filterArgs,触发 useEffect 调用 API | |||||
| setFilterArgs({ | |||||
| itemCode: itemCode || undefined, | |||||
| itemName: itemName || undefined, | |||||
| type: type || undefined, | |||||
| startDate: startDate || undefined, | |||||
| endDate: endDate || undefined, | |||||
| }); | |||||
| // 重置分页 | |||||
| setPage(0); | |||||
| setPagingController(prev => ({ ...prev, pageNum: 1 })); | |||||
| }, []); | |||||
| const handleReset = useCallback(() => { | |||||
| setHasSearchQuery(false); | |||||
| // 重置 filterArgs,触发 useEffect 调用 API | |||||
| setFilterArgs({}); | |||||
| setPage(0); | |||||
| setPagingController(prev => ({ ...prev, pageNum: 1 })); | |||||
| }, []); | |||||
| // 计算实际显示的 items(分页) | |||||
| const paginatedItems = useMemo(() => { | |||||
| if (pageSize === "all") { | |||||
| return filteredList; | |||||
| } | |||||
| const actualPageSize = typeof pageSize === 'number' ? pageSize : 10; | |||||
| const startIndex = page * actualPageSize; | |||||
| const endIndex = startIndex + actualPageSize; | |||||
| return filteredList.slice(startIndex, endIndex); | |||||
| }, [filteredList, page, pageSize]); | |||||
| // 计算传递给 SearchResults 的 pageSize(确保在选项中) | |||||
| const actualPageSizeForTable = useMemo(() => { | |||||
| if (pageSize === "all") { | |||||
| return filteredList.length; | |||||
| } | |||||
| const size = typeof pageSize === 'number' ? pageSize : 10; | |||||
| // 如果 size 不在标准选项中,使用 "all" 模式 | |||||
| if (![10, 25, 100].includes(size)) { | |||||
| return filteredList.length; | |||||
| } | |||||
| return size; | |||||
| }, [pageSize, filteredList.length]); | |||||
| return ( | |||||
| <> | |||||
| <SearchBox | |||||
| criteria={searchCriteria} | |||||
| onSearch={handleSearch} | |||||
| onReset={handleReset} | |||||
| /> | |||||
| {loading && <Box sx={{ p: 2 }}>{t("Loading...")}</Box>} | |||||
| <SearchResults<ExtendedStockTransaction> | |||||
| items={paginatedItems} | |||||
| columns={columns} | |||||
| pagingController={{ ...pagingController, pageSize: actualPageSizeForTable }} | |||||
| setPagingController={setPagingController} | |||||
| totalCount={totalCount} | |||||
| isAutoPaging={false} | |||||
| /> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default SearchPage; | |||||
| @@ -0,0 +1,26 @@ | |||||
| import GeneralLoading from "../General/GeneralLoading"; | |||||
| import SearchPage from "./SearchPage"; | |||||
| import { searchStockTransactions } from "@/app/api/stockTake/actions"; | |||||
| interface SubComponents { | |||||
| Loading: typeof GeneralLoading; | |||||
| } | |||||
| const Wrapper: React.FC & SubComponents = async () => { | |||||
| // 初始加载时使用空参数,SearchPage 会在用户搜索时调用 API | |||||
| const dataList = await searchStockTransactions({ | |||||
| startDate: null, | |||||
| endDate: null, | |||||
| itemCode: null, | |||||
| itemName: null, | |||||
| type: null, | |||||
| pageNum: 0, | |||||
| pageSize: 100, | |||||
| }); | |||||
| return <SearchPage dataList={dataList || []} />; | |||||
| }; | |||||
| Wrapper.Loading = GeneralLoading; | |||||
| export default Wrapper; | |||||
| @@ -201,23 +201,7 @@ const ApproverCardList: React.FC<ApproverCardListProps> = ({ onCardClick }) => { | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | ||||
| {t("Control Time")}: <TimeDisplay startTime={session.startTime} endTime={session.endTime} /> | {t("Control Time")}: <TimeDisplay startTime={session.startTime} endTime={session.endTime} /> | ||||
| </Typography> | </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> | </CardContent> | ||||
| <CardActions sx={{ pt: 0.5 ,justifyContent: "space-between"}}> | <CardActions sx={{ pt: 0.5 ,justifyContent: "space-between"}}> | ||||
| @@ -14,10 +14,14 @@ import { | |||||
| TableHead, | TableHead, | ||||
| TableRow, | TableRow, | ||||
| Paper, | Paper, | ||||
| Checkbox, | |||||
| TextField, | TextField, | ||||
| FormControlLabel, | |||||
| Radio, | Radio, | ||||
| TablePagination, | |||||
| ToggleButton | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useState, useCallback, useEffect, useRef } from "react"; | |||||
| import { useState, useCallback, useEffect, useRef, useMemo } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { | import { | ||||
| AllPickedStockTakeListReponse, | AllPickedStockTakeListReponse, | ||||
| @@ -52,7 +56,8 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| const [loadingDetails, setLoadingDetails] = useState(false); | const [loadingDetails, setLoadingDetails] = useState(false); | ||||
| const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(false); | |||||
| // 每个记录的选择状态,key 为 detail.id | // 每个记录的选择状态,key 为 detail.id | ||||
| const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | ||||
| const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | ||||
| @@ -60,28 +65,111 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| const [saving, setSaving] = useState(false); | const [saving, setSaving] = useState(false); | ||||
| const [batchSaving, setBatchSaving] = useState(false); | const [batchSaving, setBatchSaving] = useState(false); | ||||
| const [updatingStatus, setUpdatingStatus] = useState(false); | const [updatingStatus, setUpdatingStatus] = useState(false); | ||||
| const [page, setPage] = useState(0); | |||||
| const [pageSize, setPageSize] = useState<number | string>("all"); | |||||
| const [total, setTotal] = useState(0); | |||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | 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); | |||||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | |||||
| setPage(newPage); | |||||
| }, []); | |||||
| const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |||||
| const newSize = parseInt(event.target.value, 10); | |||||
| if (newSize === -1) { | |||||
| setPageSize("all"); | |||||
| } else if (!isNaN(newSize)) { | |||||
| setPageSize(newSize); | |||||
| } | |||||
| setPage(0); | |||||
| }, []); | |||||
| const loadDetails = useCallback(async (pageNum: number, size: number | string) => { | |||||
| setLoadingDetails(true); | |||||
| try { | |||||
| let actualSize: number; | |||||
| if (size === "all") { | |||||
| if (selectedSession.totalInventoryLotNumber > 0) { | |||||
| actualSize = selectedSession.totalInventoryLotNumber; | |||||
| } else if (total > 0) { | |||||
| actualSize = total; | |||||
| } else { | |||||
| actualSize = 10000; | |||||
| } | |||||
| } else { | |||||
| actualSize = typeof size === 'string' ? parseInt(size, 10) : size; | |||||
| } | } | ||||
| }; | |||||
| loadDetails(); | |||||
| }, [selectedSession]); | |||||
| const response = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, | |||||
| pageNum, | |||||
| actualSize | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); | |||||
| setTotal(response.total || 0); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setInventoryLotDetails([]); | |||||
| setTotal(0); | |||||
| } finally { | |||||
| setLoadingDetails(false); | |||||
| } | |||||
| }, [selectedSession, total]); | |||||
| useEffect(() => { | |||||
| loadDetails(page, pageSize); | |||||
| }, [page, pageSize, loadDetails]); | |||||
| const calculateDifference = useCallback((detail: InventoryLotDetailResponse, selection: QtySelectionType): number => { | |||||
| 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") - parseFloat(approverBadQty[detail.id] || "0")) || 0; | |||||
| } | |||||
| const bookQty = detail.availableQty || 0; | |||||
| return selectedQty - bookQty; | |||||
| }, [approverQty, approverBadQty]); | |||||
| // 3. 修改默认选择逻辑(在 loadDetails 的 useEffect 中,或创建一个新的 useEffect) | |||||
| useEffect(() => { | |||||
| // 初始化默认选择:如果 second 存在则选择 second,否则选择 first | |||||
| const newSelections: Record<number, QtySelectionType> = {}; | |||||
| inventoryLotDetails.forEach(detail => { | |||||
| if (!qtySelection[detail.id]) { | |||||
| // 如果 second 不为 null 且大于 0,默认选择 second,否则选择 first | |||||
| if (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0) { | |||||
| newSelections[detail.id] = "second"; | |||||
| } else { | |||||
| newSelections[detail.id] = "first"; | |||||
| } | |||||
| } | |||||
| }); | |||||
| if (Object.keys(newSelections).length > 0) { | |||||
| setQtySelection(prev => ({ ...prev, ...newSelections })); | |||||
| } | |||||
| }, [inventoryLotDetails]); | |||||
| // 4. 添加过滤逻辑(在渲染表格之前) | |||||
| const filteredDetails = useMemo(() => { | |||||
| if (!showOnlyWithDifference) { | |||||
| return inventoryLotDetails; | |||||
| } | |||||
| return inventoryLotDetails.filter(detail => { | |||||
| const selection = qtySelection[detail.id] || (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 ? "second" : "first"); | |||||
| const difference = calculateDifference(detail, selection); | |||||
| return difference !== 0; | |||||
| }); | |||||
| }, [inventoryLotDetails, showOnlyWithDifference, qtySelection, calculateDifference]); | |||||
| const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| return; | return; | ||||
| @@ -135,11 +223,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| onSnackbar(t("Approver stock take record saved successfully"), "success"); | 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 : []); | |||||
| await loadDetails(page, pageSize); | |||||
| } catch (e: any) { | } catch (e: any) { | ||||
| console.error("Save approver stock take record error:", e); | console.error("Save approver stock take record error:", e); | ||||
| let errorMessage = t("Failed to save approver stock take record"); | let errorMessage = t("Failed to save approver stock take record"); | ||||
| @@ -159,7 +243,8 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| } finally { | } finally { | ||||
| setSaving(false); | setSaving(false); | ||||
| } | } | ||||
| }, [selectedSession, qtySelection, approverQty, approverBadQty, t, currentUserId, onSnackbar]); | |||||
| }, [selectedSession, qtySelection, approverQty, approverBadQty, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||||
| const handleUpdateStatusToNotMatch = useCallback(async (detail: InventoryLotDetailResponse) => { | const handleUpdateStatusToNotMatch = useCallback(async (detail: InventoryLotDetailResponse) => { | ||||
| if (!detail.stockTakeRecordId) { | if (!detail.stockTakeRecordId) { | ||||
| onSnackbar(t("Stock take record ID is required"), "error"); | onSnackbar(t("Stock take record ID is required"), "error"); | ||||
| @@ -171,12 +256,6 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId); | await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId); | ||||
| onSnackbar(t("Stock take record status updated to not match"), "success"); | 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) { | } catch (e: any) { | ||||
| console.error("Update stock take record status error:", e); | console.error("Update stock take record status error:", e); | ||||
| let errorMessage = t("Failed to update stock take record status"); | let errorMessage = t("Failed to update stock take record status"); | ||||
| @@ -195,8 +274,20 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| onSnackbar(errorMessage, "error"); | onSnackbar(errorMessage, "error"); | ||||
| } finally { | } finally { | ||||
| setUpdatingStatus(false); | setUpdatingStatus(false); | ||||
| // Reload after status update - the useEffect will handle it with current page/pageSize | |||||
| // Or explicitly reload: | |||||
| setPage((currentPage) => { | |||||
| setPageSize((currentPageSize) => { | |||||
| setTimeout(() => { | |||||
| loadDetails(currentPage, currentPageSize); | |||||
| }, 0); | |||||
| return currentPageSize; | |||||
| }); | |||||
| return currentPage; | |||||
| }); | |||||
| } | } | ||||
| }, [selectedSession, t, onSnackbar]); | |||||
| }, [selectedSession, t, onSnackbar, loadDetails]); | |||||
| const handleBatchSubmitAll = useCallback(async () => { | const handleBatchSubmitAll = useCallback(async () => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId'); | console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId'); | ||||
| @@ -223,11 +314,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| result.errorCount > 0 ? "warning" : "success" | result.errorCount > 0 ? "warning" : "success" | ||||
| ); | ); | ||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| await loadDetails(page, pageSize); | |||||
| } catch (e: any) { | } catch (e: any) { | ||||
| console.error("handleBatchSubmitAll: Error:", e); | console.error("handleBatchSubmitAll: Error:", e); | ||||
| let errorMessage = t("Failed to batch save approver stock take records"); | let errorMessage = t("Failed to batch save approver stock take records"); | ||||
| @@ -247,11 +334,12 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| } finally { | } finally { | ||||
| setBatchSaving(false); | setBatchSaving(false); | ||||
| } | } | ||||
| }, [selectedSession, t, currentUserId, onSnackbar]); | |||||
| }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| handleBatchSubmitAllRef.current = handleBatchSubmitAll; | handleBatchSubmitAllRef.current = handleBatchSubmitAll; | ||||
| }, [handleBatchSubmitAll]); | }, [handleBatchSubmitAll]); | ||||
| const formatNumber = (num: number | null | undefined): string => { | const formatNumber = (num: number | null | undefined): string => { | ||||
| if (num == null) return "0.00"; | if (num == null) return "0.00"; | ||||
| return num.toLocaleString('en-US', { | return num.toLocaleString('en-US', { | ||||
| @@ -259,6 +347,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| maximumFractionDigits: 2 | maximumFractionDigits: 2 | ||||
| }); | }); | ||||
| }; | }; | ||||
| const uniqueWarehouses = Array.from( | const uniqueWarehouses = Array.from( | ||||
| new Set( | new Set( | ||||
| inventoryLotDetails | inventoryLotDetails | ||||
| @@ -266,6 +355,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| .filter(warehouse => warehouse && warehouse.trim() !== "") | .filter(warehouse => warehouse && warehouse.trim() !== "") | ||||
| ) | ) | ||||
| ).join(", "); | ).join(", "); | ||||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | ||||
| // Only allow editing if there's a first stock take qty | // Only allow editing if there's a first stock take qty | ||||
| if (!detail.firstStockTakeQty || detail.firstStockTakeQty === 0) { | if (!detail.firstStockTakeQty || detail.firstStockTakeQty === 0) { | ||||
| @@ -280,232 +370,270 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| {t("Back to List")} | {t("Back to List")} | ||||
| </Button> | </Button> | ||||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}> | <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}> | ||||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||||
| {t("Stock Take Section")}: {selectedSession.stockTakeSession} | |||||
| {uniqueWarehouses && ( | |||||
| <> {t("Warehouse")}: {uniqueWarehouses}</> | |||||
| )} | |||||
| </Typography> | |||||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||||
| {t("Stock Take Section")}: {selectedSession.stockTakeSession} | |||||
| {uniqueWarehouses && ( | |||||
| <> {t("Warehouse")}: {uniqueWarehouses}</> | |||||
| )} | |||||
| </Typography> | |||||
| <Button variant="contained" color="primary" onClick={handleBatchSubmitAll} disabled={batchSaving}> | |||||
| {t("Batch Save All")} | |||||
| </Button> | |||||
| </Stack> | |||||
| <Stack direction="row" spacing={2} alignItems="center"> | |||||
| <Button | |||||
| variant={showOnlyWithDifference ? "contained" : "outlined"} | |||||
| color="primary" | |||||
| onClick={() => setShowOnlyWithDifference(!showOnlyWithDifference)} | |||||
| startIcon={ | |||||
| <Checkbox | |||||
| checked={showOnlyWithDifference} | |||||
| onChange={(e) => setShowOnlyWithDifference(e.target.checked)} | |||||
| sx={{ p: 0, pointerEvents: 'none' }} | |||||
| /> | |||||
| } | |||||
| sx={{ textTransform: 'none' }} | |||||
| > | |||||
| {t("Only Variance")} | |||||
| </Button> | |||||
| <Button variant="contained" color="primary" onClick={handleBatchSubmitAll} disabled={batchSaving}> | |||||
| {t("Batch Save All")} | |||||
| </Button> | |||||
| </Stack> | |||||
| </Stack> | |||||
| {loadingDetails ? ( | {loadingDetails ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | ||||
| <CircularProgress /> | <CircularProgress /> | ||||
| </Box> | </Box> | ||||
| ) : ( | ) : ( | ||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available 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 ? ( | |||||
| <> | |||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={7} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||||
| <TableCell>{t("Remark")}</TableCell> | |||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Record Status")}</TableCell> | |||||
| <TableCell>{t("Action")}</TableCell> | |||||
| </TableRow> | </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"; | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {filteredDetails.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={7} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| filteredDetails.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] || (hasSecond ? "second" : "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 ? ( | |||||
| 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}> | <Stack spacing={0.5}> | ||||
| <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'primary.main' }}> | |||||
| {t("Difference")}: {formatNumber(detail.finalQty)} - {formatNumber(detail.availableQty)} = {formatNumber((detail.finalQty || 0) - (detail.availableQty || 0))} | |||||
| </Typography> | |||||
| <Box>{detail.itemCode || "-"} {detail.itemName || "-"}</Box> | |||||
| <Box>{detail.lotNo || "-"}</Box> | |||||
| <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | |||||
| </Stack> | </Stack> | ||||
| ) : ( | |||||
| <Stack spacing={1}> | |||||
| {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")}: {formatNumber((detail.firstStockTakeQty??0)+(detail.firstBadQty??0))} ({detail.firstBadQty??0}) = {formatNumber(detail.firstStockTakeQty??0)} | |||||
| </Typography> | |||||
| </Stack> | |||||
| )} | |||||
| {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")}: {formatNumber((detail.secondStockTakeQty??0)+(detail.secondBadQty??0))} ({detail.secondBadQty??0}) = {formatNumber(detail.secondStockTakeQty??0)} | |||||
| </Typography> | |||||
| </Stack> | |||||
| )} | |||||
| {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: 130, | |||||
| minWidth: 130, | |||||
| '& .MuiInputBase-input': { | |||||
| height: '1.4375em', | |||||
| padding: '4px 8px' | |||||
| } | |||||
| }} | |||||
| placeholder={t("Stock Take Qty") } | |||||
| disabled={selection !== "approver"} | |||||
| /> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={approverBadQty[detail.id] || ""} | |||||
| onChange={(e) => setApproverBadQty({ ...approverBadQty, [detail.id]: e.target.value })} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| '& .MuiInputBase-input': { | |||||
| height: '1.4375em', | |||||
| padding: '4px 8px' | |||||
| } | |||||
| }} | |||||
| placeholder={t("Bad Qty")} | |||||
| disabled={selection !== "approver"} | |||||
| /> | |||||
| <Typography variant="body2"> | |||||
| ={(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} | |||||
| </Typography> | |||||
| </Stack> | |||||
| )} | |||||
| {(() => { | |||||
| 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") - parseFloat(approverBadQty[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")}: {t("selected stock take qty")}({formatNumber(selectedQty)}) - {t("book qty")}({formatNumber(bookQty)}) = {formatNumber(difference)} | |||||
| </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" && ( | |||||
| <Box> | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| color="warning" | |||||
| onClick={() => handleUpdateStatusToNotMatch(detail)} | |||||
| disabled={updatingStatus || detail.stockTakeRecordStatus === "completed"} | |||||
| > | |||||
| {t("ReStockTake")} | |||||
| </Button> | |||||
| </Box> | |||||
| )} | |||||
| <br/> | |||||
| {detail.finalQty == null && ( | |||||
| <Box> | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => handleSaveApproverStockTake(detail)} | |||||
| disabled={saving || submitDisabled || detail.stockTakeRecordStatus === "completed"} | |||||
| > | |||||
| {t("Save")} | |||||
| </Button> | |||||
| </Box> | |||||
| )} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| </TableCell> | |||||
| <TableCell sx={{ minWidth: 300 }}> | |||||
| {detail.finalQty != null ? ( | |||||
| <Stack spacing={0.5}> | |||||
| {(() => { | |||||
| const finalDifference = (detail.finalQty || 0) - (detail.availableQty || 0); | |||||
| const differenceColor = finalDifference > 0 | |||||
| ? 'error.main' | |||||
| : finalDifference < 0 | |||||
| ? 'error.main' | |||||
| : 'success.main'; | |||||
| return ( | |||||
| <Typography variant="body2" sx={{ fontWeight: 'bold', color: differenceColor }}> | |||||
| {t("Difference")}: {formatNumber(detail.finalQty)} - {formatNumber(detail.availableQty)} = {formatNumber(finalDifference)} | |||||
| </Typography> | |||||
| ); | |||||
| })()} | |||||
| </Stack> | |||||
| ) : ( | |||||
| <Stack spacing={1}> | |||||
| {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")}: {formatNumber((detail.firstStockTakeQty??0)+(detail.firstBadQty??0))} ({detail.firstBadQty??0}) = {formatNumber(detail.firstStockTakeQty??0)} | |||||
| </Typography> | |||||
| </Stack> | |||||
| )} | |||||
| {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")}: {formatNumber((detail.secondStockTakeQty??0)+(detail.secondBadQty??0))} ({detail.secondBadQty??0}) = {formatNumber(detail.secondStockTakeQty??0)} | |||||
| </Typography> | |||||
| </Stack> | |||||
| )} | |||||
| {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: 130, | |||||
| minWidth: 130, | |||||
| '& .MuiInputBase-input': { | |||||
| height: '1.4375em', | |||||
| padding: '4px 8px' | |||||
| } | |||||
| }} | |||||
| placeholder={t("Stock Take Qty") } | |||||
| disabled={selection !== "approver"} | |||||
| /> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={approverBadQty[detail.id] || ""} | |||||
| onChange={(e) => setApproverBadQty({ ...approverBadQty, [detail.id]: e.target.value })} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| '& .MuiInputBase-input': { | |||||
| height: '1.4375em', | |||||
| padding: '4px 8px' | |||||
| } | |||||
| }} | |||||
| placeholder={t("Bad Qty")} | |||||
| disabled={selection !== "approver"} | |||||
| /> | |||||
| <Typography variant="body2"> | |||||
| ={(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} | |||||
| </Typography> | |||||
| </Stack> | |||||
| )} | |||||
| {(() => { | |||||
| 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") - parseFloat(approverBadQty[detail.id] || "0"))|| 0; | |||||
| } | |||||
| const bookQty = detail.availableQty || 0; | |||||
| const difference = selectedQty - bookQty; | |||||
| const differenceColor = difference > 0 | |||||
| ? 'error.main' | |||||
| : difference < 0 | |||||
| ? 'error.main' | |||||
| : 'success.main'; | |||||
| return ( | |||||
| <Typography variant="body2" sx={{ fontWeight: 'bold', color: differenceColor }}> | |||||
| {t("Difference")}: {t("selected stock take qty")}({formatNumber(selectedQty)}) - {t("book qty")}({formatNumber(bookQty)}) = {formatNumber(difference)} | |||||
| </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" && ( | |||||
| <Box> | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| color="warning" | |||||
| onClick={() => handleUpdateStatusToNotMatch(detail)} | |||||
| disabled={updatingStatus || detail.stockTakeRecordStatus === "completed"} | |||||
| > | |||||
| {t("ReStockTake")} | |||||
| </Button> | |||||
| </Box> | |||||
| )} | |||||
| <br/> | |||||
| {detail.finalQty == null && ( | |||||
| <Box> | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => handleSaveApproverStockTake(detail)} | |||||
| disabled={saving || submitDisabled || detail.stockTakeRecordStatus === "completed"} | |||||
| > | |||||
| {t("Save")} | |||||
| </Button> | |||||
| </Box> | |||||
| )} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={total} | |||||
| page={page} | |||||
| onPageChange={handleChangePage} | |||||
| rowsPerPage={pageSize === "all" ? total : (pageSize as number)} | |||||
| onRowsPerPageChange={handleChangeRowsPerPage} | |||||
| rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]} | |||||
| labelRowsPerPage={t("Rows per page")} | |||||
| /> | |||||
| </> | |||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -224,23 +224,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT | |||||
| {t("Control Time")}: <TimeDisplay startTime={session.startTime} endTime={session.endTime} /> | {t("Control Time")}: <TimeDisplay startTime={session.startTime} endTime={session.endTime} /> | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Total Item Number")}: {session.totalItemNumber}</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> | </CardContent> | ||||
| <CardActions sx={{ pt: 0.5 ,justifyContent: "space-between"}}> | <CardActions sx={{ pt: 0.5 ,justifyContent: "space-between"}}> | ||||
| @@ -15,6 +15,7 @@ import { | |||||
| TableRow, | TableRow, | ||||
| Paper, | Paper, | ||||
| TextField, | TextField, | ||||
| TablePagination, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useState, useCallback, useEffect, useRef } from "react"; | import { useState, useCallback, useEffect, useRef } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -33,13 +34,13 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | ||||
| interface PickerStockTakeProps { | |||||
| interface PickerReStockTakeProps { | |||||
| selectedSession: AllPickedStockTakeListReponse; | selectedSession: AllPickedStockTakeListReponse; | ||||
| onBack: () => void; | onBack: () => void; | ||||
| onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | ||||
| } | } | ||||
| const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| selectedSession, | selectedSession, | ||||
| onBack, | onBack, | ||||
| onSnackbar, | onSnackbar, | ||||
| @@ -60,28 +61,63 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| const [saving, setSaving] = useState(false); | const [saving, setSaving] = useState(false); | ||||
| const [batchSaving, setBatchSaving] = useState(false); | const [batchSaving, setBatchSaving] = useState(false); | ||||
| const [shortcutInput, setShortcutInput] = useState<string>(""); | const [shortcutInput, setShortcutInput] = useState<string>(""); | ||||
| const [page, setPage] = useState(0); | |||||
| const [pageSize, setPageSize] = useState<number | string>("all"); | |||||
| const [total, setTotal] = useState(0); | |||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | ||||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | |||||
| setPage(newPage); | |||||
| }, []); | |||||
| const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |||||
| const newSize = parseInt(event.target.value, 10); | |||||
| if (newSize === -1) { | |||||
| setPageSize("all"); | |||||
| } else if (!isNaN(newSize)) { | |||||
| setPageSize(newSize); | |||||
| } | |||||
| setPage(0); | |||||
| }, []); | |||||
| 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); | |||||
| const loadDetails = useCallback(async (pageNum: number, size: number | string) => { | |||||
| setLoadingDetails(true); | |||||
| try { | |||||
| let actualSize: number; | |||||
| if (size === "all") { | |||||
| if (selectedSession.totalInventoryLotNumber > 0) { | |||||
| actualSize = selectedSession.totalInventoryLotNumber; | |||||
| } else if (total > 0) { | |||||
| actualSize = total; | |||||
| } else { | |||||
| actualSize = 10000; | |||||
| } | |||||
| } else { | |||||
| actualSize = typeof size === 'string' ? parseInt(size, 10) : size; | |||||
| } | } | ||||
| }; | |||||
| loadDetails(); | |||||
| }, [selectedSession]); | |||||
| const response = await getInventoryLotDetailsBySectionNotMatch( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, | |||||
| pageNum, | |||||
| actualSize | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); | |||||
| setTotal(response.total || 0); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setInventoryLotDetails([]); | |||||
| setTotal(0); | |||||
| } finally { | |||||
| setLoadingDetails(false); | |||||
| } | |||||
| }, [selectedSession, total]); | |||||
| useEffect(() => { | |||||
| loadDetails(page, pageSize); | |||||
| }, [page, pageSize, loadDetails]); | |||||
| const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | ||||
| setEditingRecord(detail); | setEditingRecord(detail); | ||||
| @@ -131,9 +167,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| badQty: parseFloat(badQty), | badQty: parseFloat(badQty), | ||||
| remark: isSecondSubmit ? (remark || null) : null, | remark: isSecondSubmit ? (remark || null) : null, | ||||
| }; | }; | ||||
| console.log('handleSaveStockTake: request:', request); | |||||
| console.log('handleSaveStockTake: selectedSession.stockTakeId:', selectedSession.stockTakeId); | |||||
| console.log('handleSaveStockTake: currentUserId:', currentUserId); | |||||
| console.log('handleSaveStockTake: request:', request); | |||||
| console.log('handleSaveStockTake: selectedSession.stockTakeId:', selectedSession.stockTakeId); | |||||
| console.log('handleSaveStockTake: currentUserId:', currentUserId); | |||||
| await saveStockTakeRecord( | await saveStockTakeRecord( | ||||
| request, | request, | ||||
| selectedSession.stockTakeId, | selectedSession.stockTakeId, | ||||
| @@ -143,11 +179,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| onSnackbar(t("Stock take record saved successfully"), "success"); | onSnackbar(t("Stock take record saved successfully"), "success"); | ||||
| handleCancelEdit(); | handleCancelEdit(); | ||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| await loadDetails(page, pageSize); | |||||
| } catch (e: any) { | } catch (e: any) { | ||||
| console.error("Save stock take record error:", e); | console.error("Save stock take record error:", e); | ||||
| let errorMessage = t("Failed to save stock take record"); | let errorMessage = t("Failed to save stock take record"); | ||||
| @@ -167,7 +199,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| } finally { | } finally { | ||||
| setSaving(false); | setSaving(false); | ||||
| } | } | ||||
| }, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar]); | |||||
| }, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||||
| const handleBatchSubmitAll = useCallback(async () => { | const handleBatchSubmitAll = useCallback(async () => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| @@ -195,11 +227,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| result.errorCount > 0 ? "warning" : "success" | result.errorCount > 0 ? "warning" : "success" | ||||
| ); | ); | ||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| await loadDetails(page, pageSize); | |||||
| } catch (e: any) { | } catch (e: any) { | ||||
| console.error("handleBatchSubmitAll: Error:", e); | console.error("handleBatchSubmitAll: Error:", e); | ||||
| let errorMessage = t("Failed to batch save stock take records"); | let errorMessage = t("Failed to batch save stock take records"); | ||||
| @@ -219,7 +247,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| } finally { | } finally { | ||||
| setBatchSaving(false); | setBatchSaving(false); | ||||
| } | } | ||||
| }, [selectedSession, t, currentUserId, onSnackbar]); | |||||
| }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| handleBatchSubmitAllRef.current = handleBatchSubmitAll; | handleBatchSubmitAllRef.current = handleBatchSubmitAll; | ||||
| @@ -325,213 +353,213 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <CircularProgress /> | <CircularProgress /> | ||||
| </Box> | </Box> | ||||
| ) : ( | ) : ( | ||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</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("Record Status")}</TableCell> | |||||
| <TableCell>{t("Action")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {inventoryLotDetails.length === 0 ? ( | |||||
| <> | |||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={12} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||||
| <TableCell>{t("Qty")}</TableCell> | |||||
| <TableCell>{t("Bad Qty")}</TableCell> | |||||
| <TableCell>{t("Remark")}</TableCell> | |||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Record Status")}</TableCell> | |||||
| <TableCell>{t("Action")}</TableCell> | |||||
| </TableRow> | </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; | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {inventoryLotDetails.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={8} 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.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> | |||||
| <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} | |||||
| 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> | |||||
| </Stack> | |||||
| </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 != null && detail.firstBadQty > 0 ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("First")}: {detail.firstBadQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : ( | |||||
| <Typography variant="body2" sx={{ visibility: 'hidden' }}> | |||||
| {t("First")}: 0.00 | |||||
| </Typography> | |||||
| )} | |||||
| {isEditing && isSecondSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondBadQty} | |||||
| onChange={(e) => setSecondBadQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.secondBadQty != null && detail.secondBadQty > 0 ? ( | |||||
| <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 ? ( | {isEditing && isSecondSubmit ? ( | ||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondQty} | |||||
| onChange={(e) => setSecondQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.secondStockTakeQty ? ( | |||||
| <> | |||||
| <Typography variant="body2">{t("Remark")}</Typography> | |||||
| <TextField | |||||
| size="small" | |||||
| value={remark} | |||||
| onChange={(e) => setRemark(e.target.value)} | |||||
| sx={{ width: 150 }} | |||||
| /> | |||||
| </> | |||||
| ) : ( | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {t("Second")}: {detail.secondStockTakeQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {!detail.firstStockTakeQty && !detail.secondStockTakeQty && !isEditing && ( | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| - | |||||
| {detail.remarks || "-"} | |||||
| </Typography> | </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 != null && detail.firstBadQty > 0 ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("First")}: {detail.firstBadQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : ( | |||||
| <Typography variant="body2" sx={{ visibility: 'hidden' }}> | |||||
| {t("First")}: 0.00 | |||||
| </Typography> | |||||
| )} | |||||
| {isEditing && isSecondSubmit ? ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondBadQty} | |||||
| onChange={(e) => setSecondBadQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.secondBadQty != null && detail.secondBadQty > 0 ? ( | |||||
| <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> | |||||
| <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> | |||||
| {isEditing ? ( | |||||
| <Stack direction="row" spacing={1}> | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| onClick={() => handleSaveStockTake(detail)} | |||||
| disabled={saving || submitDisabled} | |||||
| > | |||||
| {t("Save")} | |||||
| </Button> | |||||
| <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 | <Button | ||||
| size="small" | size="small" | ||||
| onClick={handleCancelEdit} | |||||
| variant="outlined" | |||||
| onClick={() => handleStartEdit(detail)} | |||||
| disabled={submitDisabled} | |||||
| > | > | ||||
| {t("Cancel")} | |||||
| {!detail.stockTakeRecordId | |||||
| ? t("Input") | |||||
| : detail.stockTakeRecordStatus === "notMatch" | |||||
| ? t("Input") | |||||
| : t("View")} | |||||
| </Button> | </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> | |||||
| )} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={total} | |||||
| page={page} | |||||
| onPageChange={handleChangePage} | |||||
| rowsPerPage={pageSize === "all" ? total : (pageSize as number)} | |||||
| onRowsPerPageChange={handleChangeRowsPerPage} | |||||
| rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]} | |||||
| labelRowsPerPage={t("Rows per page")} | |||||
| /> | |||||
| </> | |||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| export default PickerStockTake; | |||||
| export default PickerReStockTake; | |||||
| @@ -15,7 +15,13 @@ import { | |||||
| TableRow, | TableRow, | ||||
| Paper, | Paper, | ||||
| TextField, | TextField, | ||||
| TablePagination, | |||||
| Select, // Add this | |||||
| MenuItem, // Add this | |||||
| FormControl, // Add this | |||||
| InputLabel, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { SelectChangeEvent } from "@mui/material/Select"; | |||||
| import { useState, useCallback, useEffect, useRef } from "react"; | import { useState, useCallback, useEffect, useRef } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { | import { | ||||
| @@ -60,29 +66,76 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| const [saving, setSaving] = useState(false); | const [saving, setSaving] = useState(false); | ||||
| const [batchSaving, setBatchSaving] = useState(false); | const [batchSaving, setBatchSaving] = useState(false); | ||||
| const [shortcutInput, setShortcutInput] = useState<string>(""); | const [shortcutInput, setShortcutInput] = useState<string>(""); | ||||
| const [page, setPage] = useState(0); | |||||
| const [pageSize, setPageSize] = useState<number | string>("all"); | |||||
| const [total, setTotal] = useState(0); | |||||
| const totalPages = pageSize === "all" ? 1 : Math.ceil(total / (pageSize as number)); | |||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | 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); | |||||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | |||||
| setPage(newPage); | |||||
| }, []); | |||||
| const handlePageSelectChange = useCallback((event: SelectChangeEvent<number>) => { | |||||
| const newPage = parseInt(event.target.value as string, 10) - 1; // Convert to 0-indexed | |||||
| setPage(Math.max(0, Math.min(newPage, totalPages - 1))); | |||||
| }, [totalPages]); | |||||
| const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |||||
| const newSize = parseInt(event.target.value, 10); | |||||
| if (newSize === -1) { | |||||
| setPageSize("all"); | |||||
| } else if (!isNaN(newSize)) { | |||||
| setPageSize(newSize); | |||||
| } | |||||
| setPage(0); | |||||
| }, []); | |||||
| const loadDetails = useCallback(async (pageNum: number, size: number | string) => { | |||||
| console.log('loadDetails called with:', { pageNum, size, selectedSessionTotal: selectedSession.totalInventoryLotNumber }); | |||||
| setLoadingDetails(true); | |||||
| try { | |||||
| let actualSize: number; | |||||
| if (size === "all") { | |||||
| // Use totalInventoryLotNumber from selectedSession if available | |||||
| if (selectedSession.totalInventoryLotNumber > 0) { | |||||
| actualSize = selectedSession.totalInventoryLotNumber; | |||||
| console.log('Using "all" - actualSize set to totalInventoryLotNumber:', actualSize); | |||||
| } else if (total > 0) { | |||||
| // Fallback to total from previous response | |||||
| actualSize = total; | |||||
| console.log('Using "all" - actualSize set to total from state:', actualSize); | |||||
| } else { | |||||
| // Last resort: use a large number | |||||
| actualSize = 10000; | |||||
| console.log('Using "all" - actualSize set to default 10000'); | |||||
| } | |||||
| } else { | |||||
| actualSize = typeof size === 'string' ? parseInt(size, 10) : size; | |||||
| console.log('Using specific size - actualSize set to:', actualSize); | |||||
| } | } | ||||
| }; | |||||
| loadDetails(); | |||||
| }, [selectedSession]); | |||||
| console.log('Calling getInventoryLotDetailsBySection with actualSize:', actualSize); | |||||
| const response = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, | |||||
| pageNum, | |||||
| actualSize | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); | |||||
| setTotal(response.total || 0); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setInventoryLotDetails([]); | |||||
| setTotal(0); | |||||
| } finally { | |||||
| setLoadingDetails(false); | |||||
| } | |||||
| }, [selectedSession, total]); | |||||
| useEffect(() => { | |||||
| loadDetails(page, pageSize); | |||||
| }, [page, pageSize, loadDetails]); | |||||
| const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | ||||
| setEditingRecord(detail); | setEditingRecord(detail); | ||||
| @@ -176,12 +229,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| onSnackbar(t("Stock take record saved successfully"), "success"); | onSnackbar(t("Stock take record saved successfully"), "success"); | ||||
| handleCancelEdit(); | handleCancelEdit(); | ||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| await loadDetails(page, pageSize); | |||||
| } catch (e: any) { | } catch (e: any) { | ||||
| console.error("Save stock take record error:", e); | console.error("Save stock take record error:", e); | ||||
| let errorMessage = t("Failed to save stock take record"); | let errorMessage = t("Failed to save stock take record"); | ||||
| @@ -213,6 +263,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| t, | t, | ||||
| currentUserId, | currentUserId, | ||||
| onSnackbar, | onSnackbar, | ||||
| loadDetails, | |||||
| page, | |||||
| pageSize, | |||||
| ] | ] | ||||
| ); | ); | ||||
| @@ -243,11 +296,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| result.errorCount > 0 ? "warning" : "success" | result.errorCount > 0 ? "warning" : "success" | ||||
| ); | ); | ||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| await loadDetails(page, pageSize); | |||||
| } catch (e: any) { | } catch (e: any) { | ||||
| console.error("handleBatchSubmitAll: Error:", e); | console.error("handleBatchSubmitAll: Error:", e); | ||||
| let errorMessage = t("Failed to batch save stock take records"); | let errorMessage = t("Failed to batch save stock take records"); | ||||
| @@ -393,278 +442,290 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <CircularProgress /> | <CircularProgress /> | ||||
| </Box> | </Box> | ||||
| ) : ( | ) : ( | ||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available 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 ? ( | |||||
| <> | |||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={7} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||||
| <TableCell>{t("Remark")}</TableCell> | |||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Record Status")}</TableCell> | |||||
| <TableCell>{t("Action")}</TableCell> | |||||
| </TableRow> | </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.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> | |||||
| </Stack> | |||||
| </TableCell> | |||||
| {/* Qty + Bad Qty 合并显示/输入 */} | |||||
| <TableCell sx={{ minWidth: 300 }}> | |||||
| <Stack spacing={1}> | |||||
| {/* First */} | |||||
| {isEditing && isFirstSubmit ? ( | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Typography variant="body2">{t("First")}:</Typography> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={firstQty} | |||||
| onChange={(e) => setFirstQty(e.target.value)} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| "& .MuiInputBase-input": { | |||||
| height: "1.4375em", | |||||
| padding: "4px 8px", | |||||
| }, | |||||
| }} | |||||
| placeholder={t("Stock Take Qty")} | |||||
| /> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={firstBadQty} | |||||
| onChange={(e) => setFirstBadQty(e.target.value)} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| "& .MuiInputBase-input": { | |||||
| height: "1.4375em", | |||||
| padding: "4px 8px", | |||||
| }, | |||||
| }} | |||||
| placeholder={t("Bad Qty")} | |||||
| /> | |||||
| </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 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.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> | |||||
| </Stack> | |||||
| </TableCell> | |||||
| {/* Qty + Bad Qty 合并显示/输入 */} | |||||
| <TableCell sx={{ minWidth: 300 }}> | |||||
| <Stack spacing={1}> | |||||
| {/* First */} | |||||
| {isEditing && isFirstSubmit ? ( | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Typography variant="body2">{t("First")}:</Typography> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={firstQty} | |||||
| onChange={(e) => setFirstQty(e.target.value)} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| "& .MuiInputBase-input": { | |||||
| height: "1.4375em", | |||||
| padding: "4px 8px", | |||||
| }, | |||||
| }} | |||||
| placeholder={t("Stock Take Qty")} | |||||
| /> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={firstBadQty} | |||||
| onChange={(e) => setFirstBadQty(e.target.value)} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| "& .MuiInputBase-input": { | |||||
| height: "1.4375em", | |||||
| padding: "4px 8px", | |||||
| }, | |||||
| }} | |||||
| placeholder={t("Bad Qty")} | |||||
| /> | |||||
| <Typography variant="body2"> | |||||
| = | |||||
| {formatNumber( | |||||
| parseFloat(firstQty || "0") - | |||||
| parseFloat(firstBadQty || "0") | |||||
| )} | |||||
| </Typography> | |||||
| </Stack> | |||||
| ) : detail.firstStockTakeQty != null ? ( | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| = | |||||
| {t("First")}:{" "} | |||||
| {formatNumber( | |||||
| (detail.firstStockTakeQty ?? 0) + | |||||
| (detail.firstBadQty ?? 0) | |||||
| )}{" "} | |||||
| ( | |||||
| {formatNumber( | {formatNumber( | ||||
| parseFloat(firstQty || "0") - | |||||
| parseFloat(firstBadQty || "0") | |||||
| detail.firstBadQty ?? 0 | |||||
| )} | )} | ||||
| ) ={" "} | |||||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | |||||
| </Typography> | </Typography> | ||||
| </Stack> | |||||
| ) : detail.firstStockTakeQty != null ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("First")}:{" "} | |||||
| {formatNumber( | |||||
| (detail.firstStockTakeQty ?? 0) + | |||||
| (detail.firstBadQty ?? 0) | |||||
| )}{" "} | |||||
| ( | |||||
| {formatNumber( | |||||
| detail.firstBadQty ?? 0 | |||||
| ) : null} | |||||
| {/* Second */} | |||||
| {isEditing && isSecondSubmit ? ( | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Typography variant="body2">{t("Second")}:</Typography> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondQty} | |||||
| onChange={(e) => setSecondQty(e.target.value)} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| "& .MuiInputBase-input": { | |||||
| height: "1.4375em", | |||||
| padding: "4px 8px", | |||||
| }, | |||||
| }} | |||||
| placeholder={t("Stock Take Qty")} | |||||
| /> | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondBadQty} | |||||
| onChange={(e) => setSecondBadQty(e.target.value)} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| "& .MuiInputBase-input": { | |||||
| height: "1.4375em", | |||||
| padding: "4px 8px", | |||||
| }, | |||||
| }} | |||||
| placeholder={t("Bad Qty")} | |||||
| /> | |||||
| <Typography variant="body2"> | |||||
| = | |||||
| {formatNumber( | |||||
| parseFloat(secondQty || "0") - | |||||
| parseFloat(secondBadQty || "0") | |||||
| )} | |||||
| </Typography> | |||||
| </Stack> | |||||
| ) : detail.secondStockTakeQty != null ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("Second")}:{" "} | |||||
| {formatNumber( | |||||
| (detail.secondStockTakeQty ?? 0) + | |||||
| (detail.secondBadQty ?? 0) | |||||
| )}{" "} | |||||
| ( | |||||
| {formatNumber( | |||||
| detail.secondBadQty ?? 0 | |||||
| )} | |||||
| ) ={" "} | |||||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {!detail.firstStockTakeQty && | |||||
| !detail.secondStockTakeQty && | |||||
| !isEditing && ( | |||||
| <Typography | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| > | |||||
| - | |||||
| </Typography> | |||||
| )} | )} | ||||
| ) ={" "} | |||||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| </Stack> | |||||
| </TableCell> | |||||
| {/* Second */} | |||||
| {/* Remark */} | |||||
| <TableCell sx={{ width: 180 }}> | |||||
| {isEditing && isSecondSubmit ? ( | {isEditing && isSecondSubmit ? ( | ||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Typography variant="body2">{t("Second")}:</Typography> | |||||
| <> | |||||
| <Typography variant="body2">{t("Remark")}</Typography> | |||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | |||||
| value={secondQty} | |||||
| onChange={(e) => setSecondQty(e.target.value)} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| "& .MuiInputBase-input": { | |||||
| height: "1.4375em", | |||||
| padding: "4px 8px", | |||||
| }, | |||||
| }} | |||||
| placeholder={t("Stock Take Qty")} | |||||
| value={remark} | |||||
| onChange={(e) => setRemark(e.target.value)} | |||||
| sx={{ width: 150 }} | |||||
| /> | /> | ||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondBadQty} | |||||
| onChange={(e) => setSecondBadQty(e.target.value)} | |||||
| sx={{ | |||||
| width: 130, | |||||
| minWidth: 130, | |||||
| "& .MuiInputBase-input": { | |||||
| height: "1.4375em", | |||||
| padding: "4px 8px", | |||||
| }, | |||||
| }} | |||||
| placeholder={t("Bad Qty")} | |||||
| /> | |||||
| <Typography variant="body2"> | |||||
| = | |||||
| {formatNumber( | |||||
| parseFloat(secondQty || "0") - | |||||
| parseFloat(secondBadQty || "0") | |||||
| )} | |||||
| </Typography> | |||||
| </Stack> | |||||
| ) : detail.secondStockTakeQty != null ? ( | |||||
| </> | |||||
| ) : ( | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {t("Second")}:{" "} | |||||
| {formatNumber( | |||||
| (detail.secondStockTakeQty ?? 0) + | |||||
| (detail.secondBadQty ?? 0) | |||||
| )}{" "} | |||||
| ( | |||||
| {formatNumber( | |||||
| detail.secondBadQty ?? 0 | |||||
| )} | |||||
| ) ={" "} | |||||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | |||||
| {detail.remarks || "-"} | |||||
| </Typography> | </Typography> | ||||
| ) : null} | |||||
| {!detail.firstStockTakeQty && | |||||
| !detail.secondStockTakeQty && | |||||
| !isEditing && ( | |||||
| <Typography | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| > | |||||
| - | |||||
| </Typography> | |||||
| )} | |||||
| </Stack> | |||||
| </TableCell> | |||||
| {/* Remark */} | |||||
| <TableCell sx={{ width: 180 }}> | |||||
| {isEditing && isSecondSubmit ? ( | |||||
| <> | |||||
| <Typography variant="body2">{t("Remark")}</Typography> | |||||
| <TextField | |||||
| )} | |||||
| </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" | size="small" | ||||
| value={remark} | |||||
| onChange={(e) => setRemark(e.target.value)} | |||||
| sx={{ width: 150 }} | |||||
| label={t(detail.stockTakeRecordStatus || "")} | |||||
| color="default" | |||||
| /> | /> | ||||
| </> | |||||
| ) : ( | |||||
| <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> | |||||
| {isEditing ? ( | |||||
| <Stack direction="row" spacing={1}> | |||||
| )} | |||||
| </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 | <Button | ||||
| size="small" | size="small" | ||||
| variant="contained" | |||||
| onClick={() => handleSaveStockTake(detail)} | |||||
| disabled={saving || submitDisabled} | |||||
| variant="outlined" | |||||
| onClick={() => handleStartEdit(detail)} | |||||
| disabled={submitDisabled} | |||||
| > | > | ||||
| {t("Save")} | |||||
| </Button> | |||||
| <Button size="small" onClick={handleCancelEdit}> | |||||
| {t("Cancel")} | |||||
| {!detail.stockTakeRecordId | |||||
| ? t("Input") | |||||
| : detail.stockTakeRecordStatus === "notMatch" | |||||
| ? t("Input") | |||||
| : t("View")} | |||||
| </Button> | </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> | |||||
| )} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={total} | |||||
| page={page} | |||||
| onPageChange={handleChangePage} | |||||
| rowsPerPage={pageSize === "all" ? total : (pageSize as number)} | |||||
| onRowsPerPageChange={handleChangeRowsPerPage} | |||||
| rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]} | |||||
| labelRowsPerPage={t("Rows per page")} | |||||
| /> | |||||
| </> | |||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||