| @@ -1,167 +0,0 @@ | |||||
| "use client"; | |||||
| import React, { useState, useMemo } from 'react'; | |||||
| import { | |||||
| Box, | |||||
| Card, | |||||
| CardContent, | |||||
| Typography, | |||||
| MenuItem, | |||||
| TextField, | |||||
| Button, | |||||
| Grid, | |||||
| Divider | |||||
| } from '@mui/material'; | |||||
| import PrintIcon from '@mui/icons-material/Print'; | |||||
| import { REPORTS, ReportDefinition } from '@/config/reportConfig'; | |||||
| import { getSession } from "next-auth/react"; | |||||
| export default function ReportPage() { | |||||
| const [selectedReportId, setSelectedReportId] = useState<string>(''); | |||||
| const [criteria, setCriteria] = useState<Record<string, string>>({}); | |||||
| const [loading, setLoading] = useState(false); | |||||
| // Find the configuration for the currently selected report | |||||
| const currentReport = useMemo(() => | |||||
| REPORTS.find((r) => r.id === selectedReportId), | |||||
| [selectedReportId]); | |||||
| const handleReportChange = (event: React.ChangeEvent<HTMLInputElement>) => { | |||||
| setSelectedReportId(event.target.value); | |||||
| setCriteria({}); // Clear criteria when switching reports | |||||
| }; | |||||
| const handleFieldChange = (name: string, value: string) => { | |||||
| setCriteria((prev) => ({ ...prev, [name]: value })); | |||||
| }; | |||||
| const handlePrint = async () => { | |||||
| if (!currentReport) return; | |||||
| // 1. Mandatory Field Validation | |||||
| const missingFields = currentReport.fields | |||||
| .filter(field => field.required && !criteria[field.name]) | |||||
| .map(field => field.label); | |||||
| if (missingFields.length > 0) { | |||||
| alert(`Please enter the following mandatory fields:\n- ${missingFields.join('\n- ')}`); | |||||
| return; | |||||
| } | |||||
| setLoading(true); | |||||
| try { | |||||
| const token = localStorage.getItem("accessToken"); | |||||
| const queryParams = new URLSearchParams(criteria).toString(); | |||||
| const url = `${currentReport.apiEndpoint}?${queryParams}`; | |||||
| const response = await fetch(url, { | |||||
| method: 'GET', | |||||
| headers: { | |||||
| 'Authorization': `Bearer ${token}`, | |||||
| 'Accept': 'application/pdf', | |||||
| }, | |||||
| }); | |||||
| if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); | |||||
| const blob = await response.blob(); | |||||
| const downloadUrl = window.URL.createObjectURL(blob); | |||||
| const link = document.createElement('a'); | |||||
| link.href = downloadUrl; | |||||
| const contentDisposition = response.headers.get('Content-Disposition'); | |||||
| let fileName = `${currentReport.title}.pdf`; | |||||
| if (contentDisposition?.includes('filename=')) { | |||||
| fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); | |||||
| } | |||||
| link.setAttribute('download', fileName); | |||||
| document.body.appendChild(link); | |||||
| link.click(); | |||||
| link.remove(); | |||||
| window.URL.revokeObjectURL(downloadUrl); | |||||
| } catch (error) { | |||||
| console.error("Failed to generate report:", error); | |||||
| alert("An error occurred while generating the report. Please try again."); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }; | |||||
| return ( | |||||
| <Box sx={{ p: 4, maxWidth: 1000, margin: '0 auto' }}> | |||||
| <Typography variant="h4" gutterBottom fontWeight="bold"> | |||||
| Report Management | |||||
| </Typography> | |||||
| <Card sx={{ mb: 4, boxShadow: 3 }}> | |||||
| <CardContent> | |||||
| <Typography variant="h6" gutterBottom> | |||||
| Select Report Type | |||||
| </Typography> | |||||
| <TextField | |||||
| select | |||||
| fullWidth | |||||
| label="Report List" | |||||
| value={selectedReportId} | |||||
| onChange={handleReportChange} | |||||
| helperText="Please select which report you want to generate" | |||||
| > | |||||
| {REPORTS.map((report) => ( | |||||
| <MenuItem key={report.id} value={report.id}> | |||||
| {report.title} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </TextField> | |||||
| </CardContent> | |||||
| </Card> | |||||
| {currentReport && ( | |||||
| <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> | |||||
| <CardContent> | |||||
| <Typography variant="h6" color="primary" gutterBottom> | |||||
| Search Criteria: {currentReport.title} | |||||
| </Typography> | |||||
| <Divider sx={{ mb: 3 }} /> | |||||
| <Grid container spacing={3}> | |||||
| {currentReport.fields.map((field) => ( | |||||
| <Grid item xs={12} sm={6} key={field.name}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={field.label} | |||||
| type={field.type} | |||||
| placeholder={field.placeholder} | |||||
| InputLabelProps={field.type === 'date' ? { shrink: true } : {}} | |||||
| onChange={(e) => handleFieldChange(field.name, e.target.value)} | |||||
| value={criteria[field.name] || ''} | |||||
| select={field.type === 'select'} | |||||
| > | |||||
| {field.type === 'select' && field.options?.map((opt) => ( | |||||
| <MenuItem key={opt.value} value={opt.value}> | |||||
| {opt.label} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </TextField> | |||||
| </Grid> | |||||
| ))} | |||||
| </Grid> | |||||
| <Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end' }}> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="large" | |||||
| startIcon={<PrintIcon />} | |||||
| onClick={handlePrint} | |||||
| disabled={loading} | |||||
| sx={{ px: 4 }} | |||||
| > | |||||
| {loading ? "Generating..." : "Print Report"} | |||||
| </Button> | |||||
| </Box> | |||||
| </CardContent> | |||||
| </Card> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| @@ -4,7 +4,6 @@ import Typography from "@mui/material/Typography"; | |||||
| import { getServerI18n } from "@/i18n"; | import { getServerI18n } from "@/i18n"; | ||||
| import QrCodeHandleSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleSearchWrapper"; | import QrCodeHandleSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleSearchWrapper"; | ||||
| import QrCodeHandleEquipmentSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleEquipmentSearchWrapper"; | import QrCodeHandleEquipmentSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleEquipmentSearchWrapper"; | ||||
| import QrCodeHandleWarehouseSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleWarehouseSearchWrapper"; | |||||
| import QrCodeHandleTabs from "@/components/qrCodeHandles/qrCodeHandleTabs"; | import QrCodeHandleTabs from "@/components/qrCodeHandles/qrCodeHandleTabs"; | ||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| @@ -20,7 +19,7 @@ const QrCodeHandlePage: React.FC = async () => { | |||||
| {t("QR Code Handle")} | {t("QR Code Handle")} | ||||
| </Typography> | </Typography> | ||||
| <I18nProvider namespaces={["common", "user", "warehouse"]}> | |||||
| <I18nProvider namespaces={["common", "user"]}> | |||||
| <QrCodeHandleTabs | <QrCodeHandleTabs | ||||
| userTabContent={ | userTabContent={ | ||||
| <Suspense fallback={<QrCodeHandleSearchWrapper.Loading />}> | <Suspense fallback={<QrCodeHandleSearchWrapper.Loading />}> | ||||
| @@ -36,13 +35,6 @@ const QrCodeHandlePage: React.FC = async () => { | |||||
| </I18nProvider> | </I18nProvider> | ||||
| </Suspense> | </Suspense> | ||||
| } | } | ||||
| warehouseTabContent={ | |||||
| <Suspense fallback={<QrCodeHandleWarehouseSearchWrapper.Loading />}> | |||||
| <I18nProvider namespaces={["warehouse", "common", "dashboard"]}> | |||||
| <QrCodeHandleWarehouseSearchWrapper /> | |||||
| </I18nProvider> | |||||
| </Suspense> | |||||
| } | |||||
| /> | /> | ||||
| </I18nProvider> | </I18nProvider> | ||||
| </Box> | </Box> | ||||
| @@ -1,25 +0,0 @@ | |||||
| 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; | |||||
| @@ -131,21 +131,6 @@ export interface getTicketReleaseTable { | |||||
| handlerName: string | null; | handlerName: string | null; | ||||
| numberOfFGItems: number; | numberOfFGItems: number; | ||||
| } | } | ||||
| export interface TruckScheduleDashboardItem { | |||||
| storeId: string | null; | |||||
| truckId: number | null; | |||||
| truckLanceCode: string | null; | |||||
| truckDepartureTime: string | number[] | null; | |||||
| numberOfShopsToServe: number; | |||||
| numberOfPickTickets: number; | |||||
| totalItemsToPick: number; | |||||
| numberOfTicketsReleased: number; | |||||
| firstTicketStartTime: string | number[] | null; | |||||
| numberOfTicketsCompleted: number; | |||||
| lastTicketEndTime: string | number[] | null; | |||||
| pickTimeTakenMinutes: number | null; | |||||
| } | |||||
| export interface SearchDeliveryOrderInfoRequest { | export interface SearchDeliveryOrderInfoRequest { | ||||
| code: string; | code: string; | ||||
| shopName: string; | shopName: string; | ||||
| @@ -196,15 +181,6 @@ export const fetchTicketReleaseTable = cache(async (startDate: string, endDate: | |||||
| } | } | ||||
| ); | ); | ||||
| }); | }); | ||||
| export const fetchTruckScheduleDashboard = cache(async () => { | |||||
| return await serverFetchJson<TruckScheduleDashboardItem[]>( | |||||
| `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`, | |||||
| { | |||||
| method: "GET", | |||||
| } | |||||
| ); | |||||
| }); | |||||
| export const startBatchReleaseAsyncSingle = cache(async (data: { doId: number; userId: number }) => { | export const startBatchReleaseAsyncSingle = cache(async (data: { doId: number; userId: number }) => { | ||||
| const { doId, userId } = data; | const { doId, userId } = data; | ||||
| return await serverFetchJson<{ id: number|null; code: string; entity?: any }>( | return await serverFetchJson<{ id: number|null; code: string; entity?: any }>( | ||||
| @@ -1,16 +0,0 @@ | |||||
| "use client"; | |||||
| import { | |||||
| fetchTruckScheduleDashboard, | |||||
| type TruckScheduleDashboardItem | |||||
| } from "./actions"; | |||||
| export const fetchTruckScheduleDashboardClient = async (): Promise<TruckScheduleDashboardItem[]> => { | |||||
| return await fetchTruckScheduleDashboard(); | |||||
| }; | |||||
| export type { TruckScheduleDashboardItem }; | |||||
| export default fetchTruckScheduleDashboardClient; | |||||
| @@ -724,7 +724,6 @@ export const fetchAllJoborderProductProcessInfo = cache(async () => { | |||||
| } | } | ||||
| ); | ); | ||||
| }); | }); | ||||
| /* | /* | ||||
| export const updateProductProcessLineQty = async (request: UpdateProductProcessLineQtyRequest) => { | export const updateProductProcessLineQty = async (request: UpdateProductProcessLineQtyRequest) => { | ||||
| return serverFetchJson<UpdateProductProcessLineQtyResponse>( | return serverFetchJson<UpdateProductProcessLineQtyResponse>( | ||||
| @@ -1168,62 +1167,4 @@ 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; | |||||
| processingTime: number | null; | |||||
| setupTime: number | null; | |||||
| changeoverTime: number | null; | |||||
| 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"] }, | |||||
| } | |||||
| ); | |||||
| }); | |||||
| ; | |||||
| }; | |||||
| @@ -4,7 +4,7 @@ import { | |||||
| serverFetchJson, | serverFetchJson, | ||||
| serverFetchWithNoContent, | serverFetchWithNoContent, | ||||
| } from "@/app/utils/fetchUtil"; | } from "@/app/utils/fetchUtil"; | ||||
| import { revalidateTag, revalidatePath } from "next/cache"; | |||||
| import { revalidateTag } from "next/cache"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { CreateItemResponse, RecordsRes } from "../../utils"; | import { CreateItemResponse, RecordsRes } from "../../utils"; | ||||
| import { ItemQc, ItemsResult } from "."; | import { ItemQc, ItemsResult } from "."; | ||||
| @@ -60,21 +60,6 @@ export const saveItem = async (data: CreateItemInputs) => { | |||||
| return item; | return item; | ||||
| }; | }; | ||||
| export const deleteItem = async (id: number) => { | |||||
| const response = await serverFetchJson<ItemsResult>( | |||||
| `${BASE_API_URL}/items/${id}`, | |||||
| { | |||||
| method: "DELETE", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| revalidateTag("items"); | |||||
| revalidatePath("/(main)/settings/items"); | |||||
| return response; | |||||
| }; | |||||
| export interface ItemCombo { | export interface ItemCombo { | ||||
| id: number, | id: number, | ||||
| label: string, | label: string, | ||||
| @@ -58,7 +58,6 @@ export type ItemsResult = { | |||||
| area?: string | undefined; | area?: string | undefined; | ||||
| slot?: string | undefined; | slot?: string | undefined; | ||||
| LocationCode?: string | undefined; | LocationCode?: string | undefined; | ||||
| locationCode?: string | undefined; // Backend may return lowercase version | |||||
| isEgg?: boolean | undefined; | isEgg?: boolean | undefined; | ||||
| isFee?: boolean | undefined; | isFee?: boolean | undefined; | ||||
| isBag?: boolean | undefined; | isBag?: boolean | undefined; | ||||
| @@ -3,11 +3,6 @@ | |||||
| 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; | ||||
| @@ -44,34 +39,30 @@ export interface InventoryLotDetailResponse { | |||||
| export const getInventoryLotDetailsBySection = async ( | export const getInventoryLotDetailsBySection = async ( | ||||
| stockTakeSection: string, | stockTakeSection: string, | ||||
| stockTakeId?: number | null, | |||||
| pageNum?: number, | |||||
| pageSize?: number | |||||
| stockTakeId?: number | null | |||||
| ) => { | ) => { | ||||
| console.log('🌐 [API] getInventoryLotDetailsBySection called with:', { | console.log('🌐 [API] getInventoryLotDetailsBySection called with:', { | ||||
| stockTakeSection, | stockTakeSection, | ||||
| stockTakeId, | |||||
| pageNum, | |||||
| pageSize | |||||
| stockTakeId | |||||
| }); | }); | ||||
| const encodedSection = encodeURIComponent(stockTakeSection); | const encodedSection = encodeURIComponent(stockTakeSection); | ||||
| let url = `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsBySection?stockTakeSection=${encodedSection}&pageNum=${pageNum}&pageSize=${pageSize}`; | |||||
| let url = `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsBySection?stockTakeSection=${encodedSection}`; | |||||
| 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 response = await serverFetchJson<RecordsRes<InventoryLotDetailResponse>>( | |||||
| const details = await serverFetchJson<InventoryLotDetailResponse[]>( | |||||
| url, | url, | ||||
| { | { | ||||
| method: "GET", | method: "GET", | ||||
| }, | }, | ||||
| ); | ); | ||||
| console.log('[API] Response received:', response); | |||||
| return response; | |||||
| console.log('[API] Response received:', details); | |||||
| return details; | |||||
| } | } | ||||
| export interface SaveStockTakeRecordRequest { | export interface SaveStockTakeRecordRequest { | ||||
| stockTakeRecordId?: number | null; | stockTakeRecordId?: number | null; | ||||
| @@ -109,7 +100,6 @@ 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`, | ||||
| { | { | ||||
| @@ -287,86 +277,28 @@ export const updateStockTakeRecordStatusToNotMatch = async ( | |||||
| export const getInventoryLotDetailsBySectionNotMatch = async ( | export const getInventoryLotDetailsBySectionNotMatch = async ( | ||||
| stockTakeSection: string, | stockTakeSection: string, | ||||
| stockTakeId?: number | null, | |||||
| pageNum: number = 0, | |||||
| pageSize: number = 10 | |||||
| stockTakeId?: number | null | |||||
| ) => { | ) => { | ||||
| const encodedSection = encodeURIComponent(stockTakeSection); | |||||
| 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 | |||||
| console.log('🌐 [API] getInventoryLotDetailsBySectionNotMatch called with:', { | |||||
| stockTakeSection, | |||||
| stockTakeId | |||||
| }); | |||||
| const encodedSection = encodeURIComponent(stockTakeSection); | |||||
| let url = `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsBySectionNotMatch?stockTakeSection=${encodedSection}`; | |||||
| if (stockTakeId != null && stockTakeId > 0) { | if (stockTakeId != null && stockTakeId > 0) { | ||||
| url += `&stockTakeId=${stockTakeId}`; | url += `&stockTakeId=${stockTakeId}`; | ||||
| } | } | ||||
| const response = await serverFetchJson<RecordsRes<InventoryLotDetailResponse>>( | |||||
| console.log(' [API] Full URL:', url); | |||||
| const details = await serverFetchJson<InventoryLotDetailResponse[]>( | |||||
| url, | url, | ||||
| { | { | ||||
| method: "GET", | method: "GET", | ||||
| }, | }, | ||||
| ); | ); | ||||
| 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 || []; | |||||
| }); | |||||
| console.log('[API] Response received:', details); | |||||
| return details; | |||||
| } | |||||
| @@ -3,31 +3,23 @@ | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { WarehouseResult } from "./index"; | import { WarehouseResult } from "./index"; | ||||
| export const exportWarehouseQrCode = async (warehouseIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { | |||||
| export const fetchWarehouseListClient = async (): Promise<WarehouseResult[]> => { | |||||
| const token = localStorage.getItem("accessToken"); | const token = localStorage.getItem("accessToken"); | ||||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/warehouse/export-qrcode`, { | |||||
| method: "POST", | |||||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/warehouse`, { | |||||
| method: "GET", | |||||
| headers: { | headers: { | ||||
| "Content-Type": "application/json", | "Content-Type": "application/json", | ||||
| ...(token && { Authorization: `Bearer ${token}` }), | ...(token && { Authorization: `Bearer ${token}` }), | ||||
| }, | }, | ||||
| body: JSON.stringify({ warehouseIds }), | |||||
| }); | }); | ||||
| if (!response.ok) { | if (!response.ok) { | ||||
| if (response.status === 401) { | if (response.status === 401) { | ||||
| throw new Error("Unauthorized: Please log in again"); | throw new Error("Unauthorized: Please log in again"); | ||||
| } | } | ||||
| throw new Error(`Failed to export QR code: ${response.status} ${response.statusText}`); | |||||
| throw new Error(`Failed to fetch warehouses: ${response.status} ${response.statusText}`); | |||||
| } | } | ||||
| const filename = response.headers.get("Content-Disposition")?.split("filename=")[1]?.replace(/"/g, "") || "warehouse_qrcode.pdf"; | |||||
| const blob = await response.blob(); | |||||
| const arrayBuffer = await blob.arrayBuffer(); | |||||
| const blobValue = new Uint8Array(arrayBuffer); | |||||
| return { blobValue, filename }; | |||||
| }; | |||||
| return response.json(); | |||||
| }; | |||||
| @@ -159,8 +159,9 @@ const CreateItem: React.FC<Props> = ({ | |||||
| console.log(qcCheck); | console.log(qcCheck); | ||||
| // return | // return | ||||
| // do api | // do api | ||||
| console.log("asdad"); | |||||
| const responseI = await saveItem(data); | const responseI = await saveItem(data); | ||||
| console.log("asdad"); | |||||
| const responseQ = await saveItemQcChecks(qcCheck); | const responseQ = await saveItemQcChecks(qcCheck); | ||||
| if (responseI && responseQ) { | if (responseI && responseQ) { | ||||
| if (!Boolean(responseI.id)) { | if (!Boolean(responseI.id)) { | ||||
| @@ -26,18 +26,7 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => { | |||||
| const item = result.item; | const item = result.item; | ||||
| qcChecks = result.qcChecks; | qcChecks = result.qcChecks; | ||||
| const activeRows = qcChecks.filter((it) => it.isActive).map((i) => i.id); | const activeRows = qcChecks.filter((it) => it.isActive).map((i) => i.id); | ||||
| // Normalize LocationCode field (handle case sensitivity from MySQL) | |||||
| const locationCode = item?.LocationCode || item?.locationCode; | |||||
| console.log("Fetched item data for edit:", { | |||||
| id: item?.id, | |||||
| code: item?.code, | |||||
| name: item?.name, | |||||
| LocationCode: locationCode, | |||||
| rawItem: item | |||||
| }); | |||||
| console.log(qcChecks); | |||||
| defaultValues = { | defaultValues = { | ||||
| type: item?.type, | type: item?.type, | ||||
| id: item?.id, | id: item?.id, | ||||
| @@ -55,7 +44,7 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => { | |||||
| warehouse: item?.warehouse, | warehouse: item?.warehouse, | ||||
| area: item?.area, | area: item?.area, | ||||
| slot: item?.slot, | slot: item?.slot, | ||||
| LocationCode: locationCode, | |||||
| LocationCode: item?.LocationCode, | |||||
| isEgg: item?.isEgg, | isEgg: item?.isEgg, | ||||
| isFee: item?.isFee, | isFee: item?.isFee, | ||||
| isBag: item?.isBag, | isBag: item?.isBag, | ||||
| @@ -23,7 +23,7 @@ import { Controller, useFormContext } from "react-hook-form"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import InputDataGrid from "../InputDataGrid"; | import InputDataGrid from "../InputDataGrid"; | ||||
| import { SyntheticEvent, useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import { SyntheticEvent, useCallback, useMemo, useState } from "react"; | |||||
| import { GridColDef, GridRowModel } from "@mui/x-data-grid"; | import { GridColDef, GridRowModel } from "@mui/x-data-grid"; | ||||
| import { InputDataGridProps, TableRow } from "../InputDataGrid/InputDataGrid"; | import { InputDataGridProps, TableRow } from "../InputDataGrid/InputDataGrid"; | ||||
| import { TypeEnum } from "@/app/utils/typeEnum"; | import { TypeEnum } from "@/app/utils/typeEnum"; | ||||
| @@ -114,13 +114,6 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous | |||||
| onChange(value.id) | onChange(value.id) | ||||
| }, []) | }, []) | ||||
| // Ensure LocationCode is set from defaultValues when component mounts | |||||
| useEffect(() => { | |||||
| if (initialDefaultValues?.LocationCode && !getValues("LocationCode")) { | |||||
| setValue("LocationCode", initialDefaultValues.LocationCode); | |||||
| } | |||||
| }, [initialDefaultValues, setValue, getValues]); | |||||
| return ( | return ( | ||||
| <Card sx={{ display: "block" }}> | <Card sx={{ display: "block" }}> | ||||
| <CardContent component={Stack} spacing={4}> | <CardContent component={Stack} spacing={4}> | ||||
| @@ -17,7 +17,6 @@ import CollapsibleCard from "../CollapsibleCard"; | |||||
| // import SupervisorQcApproval, { IQCItems } from "./QC/SupervisorQcApproval"; | // import SupervisorQcApproval, { IQCItems } from "./QC/SupervisorQcApproval"; | ||||
| import { EscalationResult } from "@/app/api/escalation"; | import { EscalationResult } from "@/app/api/escalation"; | ||||
| import EscalationLogTable from "./escalation/EscalationLogTable"; | import EscalationLogTable from "./escalation/EscalationLogTable"; | ||||
| import { TruckScheduleDashboard } from "./truckSchedule"; | |||||
| type Props = { | type Props = { | ||||
| // iqc: IQCItems[] | undefined | // iqc: IQCItems[] | undefined | ||||
| escalationLogs: EscalationResult[] | escalationLogs: EscalationResult[] | ||||
| @@ -43,13 +42,6 @@ const DashboardPage: React.FC<Props> = ({ | |||||
| return ( | return ( | ||||
| <ThemeProvider theme={theme}> | <ThemeProvider theme={theme}> | ||||
| <Grid container spacing={2}> | <Grid container spacing={2}> | ||||
| <Grid item xs={12}> | |||||
| <CollapsibleCard title={t("Truck Schedule Dashboard")} defaultOpen={true}> | |||||
| <CardContent> | |||||
| <TruckScheduleDashboard /> | |||||
| </CardContent> | |||||
| </CollapsibleCard> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <CollapsibleCard | <CollapsibleCard | ||||
| title={`${t("Responsible Escalation List")} (${t("pending")} : ${ | title={`${t("Responsible Escalation List")} (${t("pending")} : ${ | ||||
| @@ -1,397 +0,0 @@ | |||||
| "use client"; | |||||
| import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; | |||||
| import { | |||||
| Box, | |||||
| Typography, | |||||
| FormControl, | |||||
| InputLabel, | |||||
| Select, | |||||
| MenuItem, | |||||
| Card, | |||||
| CardContent, | |||||
| Stack, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Paper, | |||||
| CircularProgress, | |||||
| Chip | |||||
| } from '@mui/material'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| import dayjs from 'dayjs'; | |||||
| import { fetchTruckScheduleDashboardClient, type TruckScheduleDashboardItem } from '@/app/api/do/client'; | |||||
| import { formatDepartureTime, arrayToDayjs } from '@/app/utils/formatUtil'; | |||||
| // Track completed items for hiding after 2 refresh cycles | |||||
| interface CompletedTracker { | |||||
| key: string; | |||||
| refreshCount: number; | |||||
| } | |||||
| const TruckScheduleDashboard: React.FC = () => { | |||||
| const { t } = useTranslation("dashboard"); | |||||
| const [selectedStore, setSelectedStore] = useState<string>(""); | |||||
| const [data, setData] = useState<TruckScheduleDashboardItem[]>([]); | |||||
| const [loading, setLoading] = useState<boolean>(true); | |||||
| // Initialize as null to avoid SSR/client hydration mismatch | |||||
| const [currentTime, setCurrentTime] = useState<dayjs.Dayjs | null>(null); | |||||
| const [isClient, setIsClient] = useState<boolean>(false); | |||||
| const completedTrackerRef = useRef<Map<string, CompletedTracker>>(new Map()); | |||||
| const refreshCountRef = useRef<number>(0); | |||||
| // Set client flag and time on mount | |||||
| useEffect(() => { | |||||
| setIsClient(true); | |||||
| setCurrentTime(dayjs()); | |||||
| }, []); | |||||
| // Format time from array or string to HH:mm | |||||
| const formatTime = (timeData: string | number[] | null): string => { | |||||
| if (!timeData) return '-'; | |||||
| if (Array.isArray(timeData)) { | |||||
| if (timeData.length >= 2) { | |||||
| const hour = timeData[0] || 0; | |||||
| const minute = timeData[1] || 0; | |||||
| return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; | |||||
| } | |||||
| return '-'; | |||||
| } | |||||
| if (typeof timeData === 'string') { | |||||
| const parts = timeData.split(':'); | |||||
| if (parts.length >= 2) { | |||||
| const hour = parseInt(parts[0], 10); | |||||
| const minute = parseInt(parts[1], 10); | |||||
| return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; | |||||
| } | |||||
| } | |||||
| return '-'; | |||||
| }; | |||||
| // Format datetime from array or string | |||||
| const formatDateTime = (dateTimeData: string | number[] | null): string => { | |||||
| if (!dateTimeData) return '-'; | |||||
| if (Array.isArray(dateTimeData)) { | |||||
| return arrayToDayjs(dateTimeData, true).format('HH:mm'); | |||||
| } | |||||
| const parsed = dayjs(dateTimeData); | |||||
| if (parsed.isValid()) { | |||||
| return parsed.format('HH:mm'); | |||||
| } | |||||
| return '-'; | |||||
| }; | |||||
| // Calculate time remaining for truck departure | |||||
| const calculateTimeRemaining = useCallback((departureTime: string | number[] | null): string => { | |||||
| if (!departureTime || !currentTime) return '-'; | |||||
| const now = currentTime; | |||||
| let departureHour: number; | |||||
| let departureMinute: number; | |||||
| if (Array.isArray(departureTime)) { | |||||
| if (departureTime.length < 2) return '-'; | |||||
| departureHour = departureTime[0] || 0; | |||||
| departureMinute = departureTime[1] || 0; | |||||
| } else if (typeof departureTime === 'string') { | |||||
| const parts = departureTime.split(':'); | |||||
| if (parts.length < 2) return '-'; | |||||
| departureHour = parseInt(parts[0], 10); | |||||
| departureMinute = parseInt(parts[1], 10); | |||||
| } else { | |||||
| return '-'; | |||||
| } | |||||
| // Create departure datetime for today | |||||
| const departure = now.clone().hour(departureHour).minute(departureMinute).second(0); | |||||
| const diffMinutes = departure.diff(now, 'minute'); | |||||
| if (diffMinutes < 0) { | |||||
| // Past departure time | |||||
| const absDiff = Math.abs(diffMinutes); | |||||
| const hours = Math.floor(absDiff / 60); | |||||
| const minutes = absDiff % 60; | |||||
| return `-${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; | |||||
| } else { | |||||
| const hours = Math.floor(diffMinutes / 60); | |||||
| const minutes = diffMinutes % 60; | |||||
| return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; | |||||
| } | |||||
| }, [currentTime]); | |||||
| // Generate unique key for tracking completed items | |||||
| const getItemKey = (item: TruckScheduleDashboardItem): string => { | |||||
| return `${item.storeId}-${item.truckLanceCode}-${item.truckDepartureTime}`; | |||||
| }; | |||||
| // Load data from API | |||||
| const loadData = useCallback(async () => { | |||||
| try { | |||||
| const result = await fetchTruckScheduleDashboardClient(); | |||||
| // Update completed tracker | |||||
| refreshCountRef.current += 1; | |||||
| const currentRefresh = refreshCountRef.current; | |||||
| result.forEach(item => { | |||||
| const key = getItemKey(item); | |||||
| // If all tickets are completed, track it | |||||
| if (item.numberOfPickTickets > 0 && item.numberOfTicketsCompleted >= item.numberOfPickTickets) { | |||||
| const existing = completedTrackerRef.current.get(key); | |||||
| if (!existing) { | |||||
| completedTrackerRef.current.set(key, { key, refreshCount: currentRefresh }); | |||||
| } | |||||
| } else { | |||||
| // Remove from tracker if no longer completed | |||||
| completedTrackerRef.current.delete(key); | |||||
| } | |||||
| }); | |||||
| // Filter out items that have been completed for 2+ refresh cycles | |||||
| const filteredResult = result.filter(item => { | |||||
| const key = getItemKey(item); | |||||
| const tracker = completedTrackerRef.current.get(key); | |||||
| if (tracker) { | |||||
| // Hide if completed for 2 or more refresh cycles | |||||
| if (currentRefresh - tracker.refreshCount >= 2) { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| return true; | |||||
| }); | |||||
| setData(filteredResult); | |||||
| } catch (error) { | |||||
| console.error('Error fetching truck schedule dashboard:', error); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }, []); | |||||
| // Initial load and auto-refresh every 5 minutes | |||||
| useEffect(() => { | |||||
| loadData(); | |||||
| const refreshInterval = setInterval(() => { | |||||
| loadData(); | |||||
| }, 5 * 60 * 1000); // 5 minutes | |||||
| return () => clearInterval(refreshInterval); | |||||
| }, [loadData]); | |||||
| // Update current time every 1 minute for time remaining calculation | |||||
| useEffect(() => { | |||||
| if (!isClient) return; | |||||
| const timeInterval = setInterval(() => { | |||||
| setCurrentTime(dayjs()); | |||||
| }, 60 * 1000); // 1 minute | |||||
| return () => clearInterval(timeInterval); | |||||
| }, [isClient]); | |||||
| // Filter data by selected store | |||||
| const filteredData = useMemo(() => { | |||||
| if (!selectedStore) return data; | |||||
| return data.filter(item => item.storeId === selectedStore); | |||||
| }, [data, selectedStore]); | |||||
| // Get chip color based on time remaining | |||||
| const getTimeChipColor = (departureTime: string | number[] | null): "success" | "warning" | "error" | "default" => { | |||||
| if (!departureTime || !currentTime) return "default"; | |||||
| const now = currentTime; | |||||
| let departureHour: number; | |||||
| let departureMinute: number; | |||||
| if (Array.isArray(departureTime)) { | |||||
| if (departureTime.length < 2) return "default"; | |||||
| departureHour = departureTime[0] || 0; | |||||
| departureMinute = departureTime[1] || 0; | |||||
| } else if (typeof departureTime === 'string') { | |||||
| const parts = departureTime.split(':'); | |||||
| if (parts.length < 2) return "default"; | |||||
| departureHour = parseInt(parts[0], 10); | |||||
| departureMinute = parseInt(parts[1], 10); | |||||
| } else { | |||||
| return "default"; | |||||
| } | |||||
| const departure = now.clone().hour(departureHour).minute(departureMinute).second(0); | |||||
| const diffMinutes = departure.diff(now, 'minute'); | |||||
| if (diffMinutes < 0) return "error"; // Past due | |||||
| if (diffMinutes <= 30) return "warning"; // Within 30 minutes | |||||
| return "success"; // More than 30 minutes | |||||
| }; | |||||
| return ( | |||||
| <Card sx={{ mb: 2 }}> | |||||
| <CardContent> | |||||
| {/* Title */} | |||||
| <Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}> | |||||
| {t("Truck Schedule Dashboard")} | |||||
| </Typography> | |||||
| {/* Filter */} | |||||
| <Stack direction="row" spacing={2} sx={{ mb: 3 }}> | |||||
| <FormControl sx={{ minWidth: 150 }} size="small"> | |||||
| <InputLabel id="store-select-label" shrink={true}> | |||||
| {t("Store ID")} | |||||
| </InputLabel> | |||||
| <Select | |||||
| labelId="store-select-label" | |||||
| id="store-select" | |||||
| value={selectedStore} | |||||
| label={t("Store ID")} | |||||
| onChange={(e) => setSelectedStore(e.target.value)} | |||||
| displayEmpty | |||||
| > | |||||
| <MenuItem value="">{t("All Stores")}</MenuItem> | |||||
| <MenuItem value="2/F">2/F</MenuItem> | |||||
| <MenuItem value="4/F">4/F</MenuItem> | |||||
| </Select> | |||||
| </FormControl> | |||||
| <Typography variant="body2" sx={{ alignSelf: 'center', color: 'text.secondary' }}> | |||||
| {t("Auto-refresh every 5 minutes")} | {t("Last updated")}: {isClient && currentTime ? currentTime.format('HH:mm:ss') : '--:--:--'} | |||||
| </Typography> | |||||
| </Stack> | |||||
| {/* Table */} | |||||
| <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 sx={{ backgroundColor: 'grey.100' }}> | |||||
| <TableCell sx={{ fontWeight: 600 }}>{t("Store ID")}</TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }}>{t("Truck Schedule")}</TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }}>{t("Time Remaining")}</TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }} align="center">{t("No. of Shops")}</TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }} align="center">{t("Total Items")}</TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }} align="center">{t("Tickets Released")}</TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }}>{t("First Ticket Start")}</TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }} align="center">{t("Tickets Completed")}</TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }}>{t("Last Ticket End")}</TableCell> | |||||
| <TableCell sx={{ fontWeight: 600 }} align="center">{t("Pick Time (min)")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {filteredData.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={10} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No truck schedules available for today")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| filteredData.map((row, index) => { | |||||
| const timeRemaining = calculateTimeRemaining(row.truckDepartureTime); | |||||
| const chipColor = getTimeChipColor(row.truckDepartureTime); | |||||
| return ( | |||||
| <TableRow | |||||
| key={`${row.storeId}-${row.truckLanceCode}-${index}`} | |||||
| sx={{ | |||||
| '&:hover': { backgroundColor: 'grey.50' }, | |||||
| backgroundColor: row.numberOfPickTickets > 0 && row.numberOfTicketsCompleted >= row.numberOfPickTickets | |||||
| ? 'success.light' | |||||
| : 'inherit' | |||||
| }} | |||||
| > | |||||
| <TableCell> | |||||
| <Chip | |||||
| label={row.storeId || '-'} | |||||
| size="small" | |||||
| color={row.storeId === '2/F' ? 'primary' : 'secondary'} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||||
| <Typography variant="body2" sx={{ fontWeight: 500 }}> | |||||
| {row.truckLanceCode || '-'} | |||||
| </Typography> | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| ETD: {formatTime(row.truckDepartureTime)} | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Chip | |||||
| label={timeRemaining} | |||||
| size="small" | |||||
| color={chipColor} | |||||
| sx={{ fontWeight: 600 }} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Typography variant="body2"> | |||||
| {row.numberOfShopsToServe} [{row.numberOfPickTickets}] | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Typography variant="body2" sx={{ fontWeight: 500 }}> | |||||
| {row.totalItemsToPick} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Chip | |||||
| label={row.numberOfTicketsReleased} | |||||
| size="small" | |||||
| color={row.numberOfTicketsReleased > 0 ? 'info' : 'default'} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {formatDateTime(row.firstTicketStartTime)} | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Chip | |||||
| label={row.numberOfTicketsCompleted} | |||||
| size="small" | |||||
| color={row.numberOfTicketsCompleted > 0 ? 'success' : 'default'} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {formatDateTime(row.lastTicketEndTime)} | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Typography | |||||
| variant="body2" | |||||
| sx={{ | |||||
| fontWeight: 500, | |||||
| color: row.pickTimeTakenMinutes !== null ? 'text.primary' : 'text.secondary' | |||||
| }} | |||||
| > | |||||
| {row.pickTimeTakenMinutes !== null ? row.pickTimeTakenMinutes : '-'} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| )} | |||||
| </Box> | |||||
| </CardContent> | |||||
| </Card> | |||||
| ); | |||||
| }; | |||||
| export default TruckScheduleDashboard; | |||||
| @@ -1,3 +0,0 @@ | |||||
| export { default as TruckScheduleDashboard } from './TruckScheduleDashboard'; | |||||
| @@ -6,6 +6,7 @@ import Skeleton from "@mui/material/Skeleton"; | |||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import React from "react"; | import React from "react"; | ||||
| // Can make this nicer | |||||
| export const EquipmentTypeSearchLoading: React.FC = () => { | export const EquipmentTypeSearchLoading: React.FC = () => { | ||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -139,6 +139,7 @@ function isCheckboxColumn<T extends ResultWithId>( | |||||
| return column.type === "checkbox"; | return column.type === "checkbox"; | ||||
| } | } | ||||
| // Icon Component Functions | |||||
| function convertObjectKeysToLowercase<T extends object>( | function convertObjectKeysToLowercase<T extends object>( | ||||
| obj: T, | obj: T, | ||||
| ): object | undefined { | ): object | undefined { | ||||
| @@ -206,6 +207,7 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| const [page, setPage] = React.useState(0); | const [page, setPage] = React.useState(0); | ||||
| const [rowsPerPage, setRowsPerPage] = React.useState(10); | const [rowsPerPage, setRowsPerPage] = React.useState(10); | ||||
| /// this | |||||
| const handleChangePage: TablePaginationProps["onPageChange"] = ( | const handleChangePage: TablePaginationProps["onPageChange"] = ( | ||||
| _event, | _event, | ||||
| newPage, | newPage, | ||||
| @@ -236,6 +238,7 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| } | } | ||||
| }; | }; | ||||
| // checkbox | |||||
| const currItems = useMemo(() => { | const currItems = useMemo(() => { | ||||
| return items.length > 10 ? items | return items.length > 10 ? items | ||||
| .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) | .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) | ||||
| @@ -251,6 +254,7 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| const handleRowClick = useCallback( | const handleRowClick = useCallback( | ||||
| (event: MouseEvent<unknown>, item: T, columns: Column<T>[]) => { | (event: MouseEvent<unknown>, item: T, columns: Column<T>[]) => { | ||||
| // check is disabled or not | |||||
| let disabled = false; | let disabled = false; | ||||
| columns.forEach((col) => { | columns.forEach((col) => { | ||||
| if (isCheckboxColumn(col) && col.disabled) { | if (isCheckboxColumn(col) && col.disabled) { | ||||
| @@ -265,6 +269,7 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| return; | return; | ||||
| } | } | ||||
| // set id | |||||
| const id = item.id; | const id = item.id; | ||||
| if (setCheckboxIds) { | if (setCheckboxIds) { | ||||
| const selectedIndex = checkboxIds.indexOf(id); | const selectedIndex = checkboxIds.indexOf(id); | ||||
| @@ -330,7 +335,7 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| column.renderHeader() | column.renderHeader() | ||||
| ) : ( | ) : ( | ||||
| column.label.split('\n').map((line, index) => ( | column.label.split('\n').map((line, index) => ( | ||||
| <div key={index}>{line}</div> | |||||
| <div key={index}>{line}</div> // Render each line in a div | |||||
| )) | )) | ||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| @@ -437,6 +442,7 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| return noWrapper ? table : <Paper sx={{ overflow: "hidden" }}>{table}</Paper>; | return noWrapper ? table : <Paper sx={{ overflow: "hidden" }}>{table}</Paper>; | ||||
| } | } | ||||
| // Table cells | |||||
| interface TableCellsProps<T extends ResultWithId> { | interface TableCellsProps<T extends ResultWithId> { | ||||
| column: Column<T>; | column: Column<T>; | ||||
| columnName: keyof T; | columnName: keyof T; | ||||
| @@ -4,6 +4,7 @@ import Skeleton from "@mui/material/Skeleton"; | |||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import React from "react"; | import React from "react"; | ||||
| // Can make this nicer | |||||
| export const EquipmentTypeSearchLoading: React.FC = () => { | export const EquipmentTypeSearchLoading: React.FC = () => { | ||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -1,16 +1,21 @@ | |||||
| import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory"; | import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory"; | ||||
| import { Dispatch, SetStateAction, useCallback, useMemo } from "react"; | |||||
| import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { Column } from "../SearchResults"; | import { Column } from "../SearchResults"; | ||||
| import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults"; | import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults"; | ||||
| import { CheckCircleOutline, DoDisturb, EditNote } from "@mui/icons-material"; | |||||
| import { arrayToDateString } from "@/app/utils/formatUtil"; | import { arrayToDateString } from "@/app/utils/formatUtil"; | ||||
| import { Typography } from "@mui/material"; | |||||
| import { isFinite } from "lodash"; | |||||
| import { Box, Card, Grid, IconButton, Modal, TextField, Typography, Button } from "@mui/material"; | |||||
| import useUploadContext from "../UploadProvider/useUploadContext"; | import useUploadContext from "../UploadProvider/useUploadContext"; | ||||
| import { downloadFile } from "@/app/utils/commonUtil"; | import { downloadFile } from "@/app/utils/commonUtil"; | ||||
| import { fetchQrCodeByLotLineId, LotLineToQrcode } from "@/app/api/pdf/actions"; | import { fetchQrCodeByLotLineId, LotLineToQrcode } from "@/app/api/pdf/actions"; | ||||
| import QrCodeIcon from "@mui/icons-material/QrCode"; | import QrCodeIcon from "@mui/icons-material/QrCode"; | ||||
| import PrintIcon from "@mui/icons-material/Print"; | |||||
| import SwapHoriz from "@mui/icons-material/SwapHoriz"; | |||||
| import CloseIcon from "@mui/icons-material/Close"; | |||||
| import { Autocomplete } from "@mui/material"; | |||||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||||
| import { fetchWarehouseListClient } from "@/app/api/warehouse/client"; | |||||
| import { updateInventoryLotLineQuantities } from "@/app/api/inventory/actions"; | |||||
| interface Props { | interface Props { | ||||
| inventoryLotLines: InventoryLotLineResult[] | null; | inventoryLotLines: InventoryLotLineResult[] | null; | ||||
| @@ -23,8 +28,26 @@ interface Props { | |||||
| const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory }) => { | const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory }) => { | ||||
| const { t } = useTranslation(["inventory"]); | const { t } = useTranslation(["inventory"]); | ||||
| const { setIsUploading } = useUploadContext(); | const { setIsUploading } = useUploadContext(); | ||||
| const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false); | |||||
| const [selectedLotLine, setSelectedLotLine] = useState<InventoryLotLineResult | null>(null); | |||||
| const [startLocation, setStartLocation] = useState<string>(""); | |||||
| const [targetLocation, setTargetLocation] = useState<string>(""); | |||||
| const [targetLocationInput, setTargetLocationInput] = useState<string>(""); | |||||
| const [qtyToBeTransferred, setQtyToBeTransferred] = useState<number>(0); | |||||
| const [warehouses, setWarehouses] = useState<WarehouseResult[]>([]); | |||||
| const printQrcode = useCallback(async (lotLineId: number) => { | |||||
| useEffect(() => { | |||||
| if (stockTransferModalOpen) { | |||||
| fetchWarehouseListClient() | |||||
| .then(setWarehouses) | |||||
| .catch(console.error); | |||||
| } | |||||
| }, [stockTransferModalOpen]); | |||||
| const originalQty = selectedLotLine?.availableQty || 0; | |||||
| const remainingQty = originalQty - qtyToBeTransferred; | |||||
| const downloadQrCode = useCallback(async (lotLineId: number) => { | |||||
| setIsUploading(true); | setIsUploading(true); | ||||
| // const postData = { stockInLineIds: [42,43,44] }; | // const postData = { stockInLineIds: [42,43,44] }; | ||||
| const postData: LotLineToQrcode = { | const postData: LotLineToQrcode = { | ||||
| @@ -37,12 +60,24 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| setIsUploading(false); | setIsUploading(false); | ||||
| }, [setIsUploading]); | }, [setIsUploading]); | ||||
| const handleStockTransfer = useCallback( | |||||
| (lotLine: InventoryLotLineResult) => { | |||||
| setSelectedLotLine(lotLine); | |||||
| setStockTransferModalOpen(true); | |||||
| setStartLocation(lotLine.warehouse.code || ""); | |||||
| setTargetLocation(""); | |||||
| setTargetLocationInput(""); | |||||
| setQtyToBeTransferred(0); | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| const onDetailClick = useCallback( | const onDetailClick = useCallback( | ||||
| (lotLine: InventoryLotLineResult) => { | (lotLine: InventoryLotLineResult) => { | ||||
| printQrcode(lotLine.id) | |||||
| downloadQrCode(lotLine.id) | |||||
| // lot line id to find stock in line | // lot line id to find stock in line | ||||
| }, | }, | ||||
| [printQrcode], | |||||
| [downloadQrCode], | |||||
| ); | ); | ||||
| const columns = useMemo<Column<InventoryLotLineResult>[]>( | const columns = useMemo<Column<InventoryLotLineResult>[]>( | ||||
| () => [ | () => [ | ||||
| @@ -108,14 +143,32 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| name: "warehouse", | name: "warehouse", | ||||
| label: t("Warehouse"), | label: t("Warehouse"), | ||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| return `${params.warehouse.code} - ${params.warehouse.name}` | |||||
| return `${params.warehouse.code}` | |||||
| }, | }, | ||||
| }, | }, | ||||
| { | { | ||||
| name: "id", | name: "id", | ||||
| label: t("qrcode"), | |||||
| label: t("Download QR Code"), | |||||
| onClick: onDetailClick, | onClick: onDetailClick, | ||||
| buttonIcon: <QrCodeIcon />, | buttonIcon: <QrCodeIcon />, | ||||
| align: "center", | |||||
| headerAlign: "center", | |||||
| }, | |||||
| { | |||||
| name: "id", | |||||
| label: t("Print QR Code"), | |||||
| onClick: () => {}, | |||||
| buttonIcon: <PrintIcon />, | |||||
| align: "center", | |||||
| headerAlign: "center", | |||||
| }, | |||||
| { | |||||
| name: "id", | |||||
| label: t("Stock Transfer"), | |||||
| onClick: handleStockTransfer, | |||||
| buttonIcon: <SwapHoriz />, | |||||
| align: "center", | |||||
| headerAlign: "center", | |||||
| }, | }, | ||||
| // { | // { | ||||
| // name: "status", | // name: "status", | ||||
| @@ -131,8 +184,39 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| // } | // } | ||||
| // }, | // }, | ||||
| ], | ], | ||||
| [t], | |||||
| [t, onDetailClick, downloadQrCode, handleStockTransfer], | |||||
| ); | ); | ||||
| const handleCloseStockTransferModal = useCallback(() => { | |||||
| setStockTransferModalOpen(false); | |||||
| setSelectedLotLine(null); | |||||
| setStartLocation(""); | |||||
| setTargetLocation(""); | |||||
| setTargetLocationInput(""); | |||||
| setQtyToBeTransferred(0); | |||||
| }, []); | |||||
| const handleSubmitStockTransfer = useCallback(async () => { | |||||
| try { | |||||
| setIsUploading(true); | |||||
| // Decrease the inQty (availableQty) in the source inventory lot line | |||||
| // TODO: Add logic to increase qty in target location warehouse | |||||
| alert(t("Stock transfer successful")); | |||||
| handleCloseStockTransferModal(); | |||||
| // TODO: Refresh the inventory lot lines list | |||||
| } catch (error: any) { | |||||
| console.error("Error transferring stock:", error); | |||||
| alert(error?.message || t("Failed to transfer stock. Please try again.")); | |||||
| } finally { | |||||
| setIsUploading(false); | |||||
| } | |||||
| }, [selectedLotLine, targetLocation, qtyToBeTransferred, originalQty, handleCloseStockTransferModal, setIsUploading, t]); | |||||
| return <> | return <> | ||||
| <Typography variant="h6">{inventory ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})` : t("No items are selected yet.")}</Typography> | <Typography variant="h6">{inventory ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})` : t("No items are selected yet.")}</Typography> | ||||
| <SearchResults<InventoryLotLineResult> | <SearchResults<InventoryLotLineResult> | ||||
| @@ -142,6 +226,191 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| setPagingController={setPagingController} | setPagingController={setPagingController} | ||||
| totalCount={totalCount} | totalCount={totalCount} | ||||
| /> | /> | ||||
| <Modal | |||||
| open={stockTransferModalOpen} | |||||
| onClose={handleCloseStockTransferModal} | |||||
| sx={{ | |||||
| display: 'flex', | |||||
| alignItems: 'center', | |||||
| justifyContent: 'center', | |||||
| }} | |||||
| > | |||||
| <Card | |||||
| sx={{ | |||||
| position: 'relative', | |||||
| width: '95%', | |||||
| maxWidth: '1200px', | |||||
| maxHeight: '90vh', | |||||
| overflow: 'auto', | |||||
| p: 3, | |||||
| }} | |||||
| > | |||||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> | |||||
| <Typography variant="h6"> | |||||
| {inventory && selectedLotLine | |||||
| ? `${inventory.itemCode} ${inventory.itemName} (${selectedLotLine.lotNo})` | |||||
| : t("Stock Transfer") | |||||
| } | |||||
| </Typography> | |||||
| <IconButton onClick={handleCloseStockTransferModal}> | |||||
| <CloseIcon /> | |||||
| </IconButton> | |||||
| </Box> | |||||
| <Grid container spacing={1} sx={{ mt: 2 }}> | |||||
| <Grid item xs={5.5}> | |||||
| <TextField | |||||
| label={t("Start Location")} | |||||
| fullWidth | |||||
| variant="outlined" | |||||
| value={startLocation} | |||||
| disabled | |||||
| InputLabelProps={{ | |||||
| shrink: !!startLocation, | |||||
| sx: { fontSize: "0.9375rem" }, | |||||
| }} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}> | |||||
| <Typography variant="body1">{t("to")}</Typography> | |||||
| </Grid> | |||||
| <Grid item xs={5.5}> | |||||
| <Autocomplete | |||||
| options={warehouses.filter(w => w.code !== startLocation)} | |||||
| getOptionLabel={(option) => option.code || ""} | |||||
| value={targetLocation ? warehouses.find(w => w.code === targetLocation) || null : null} | |||||
| inputValue={targetLocationInput} | |||||
| onInputChange={(event, newInputValue) => { | |||||
| setTargetLocationInput(newInputValue); | |||||
| if (targetLocation && newInputValue !== targetLocation) { | |||||
| setTargetLocation(""); | |||||
| } | |||||
| }} | |||||
| onChange={(event, newValue) => { | |||||
| if (newValue) { | |||||
| setTargetLocation(newValue.code); | |||||
| setTargetLocationInput(newValue.code); | |||||
| } else { | |||||
| setTargetLocation(""); | |||||
| setTargetLocationInput(""); | |||||
| } | |||||
| }} | |||||
| filterOptions={(options, { inputValue }) => { | |||||
| if (!inputValue || inputValue.trim() === "") return options; | |||||
| const searchTerm = inputValue.toLowerCase().trim(); | |||||
| return options.filter((option) => | |||||
| (option.code || "").toLowerCase().includes(searchTerm) || | |||||
| (option.name || "").toLowerCase().includes(searchTerm) || | |||||
| (option.description || "").toLowerCase().includes(searchTerm) | |||||
| ); | |||||
| }} | |||||
| isOptionEqualToValue={(option, value) => option.code === value.code} | |||||
| autoHighlight={false} | |||||
| autoSelect={false} | |||||
| clearOnBlur={false} | |||||
| renderOption={(props, option) => ( | |||||
| <li {...props}> | |||||
| {option.code} | |||||
| </li> | |||||
| )} | |||||
| renderInput={(params) => ( | |||||
| <TextField | |||||
| {...params} | |||||
| label={t("Target Location")} | |||||
| variant="outlined" | |||||
| fullWidth | |||||
| InputLabelProps={{ | |||||
| shrink: !!targetLocation || !!targetLocationInput, | |||||
| sx: { fontSize: "0.9375rem" }, | |||||
| }} | |||||
| /> | |||||
| )} | |||||
| /> | |||||
| </Grid> | |||||
| </Grid> | |||||
| <Grid container spacing={1} sx={{ mt: 2 }}> | |||||
| <Grid item xs={2}> | |||||
| <TextField | |||||
| label={t("Original Qty")} | |||||
| fullWidth | |||||
| variant="outlined" | |||||
| value={originalQty} | |||||
| disabled | |||||
| InputLabelProps={{ | |||||
| shrink: true, | |||||
| sx: { fontSize: "0.9375rem" }, | |||||
| }} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}> | |||||
| <Typography variant="body1">-</Typography> | |||||
| </Grid> | |||||
| <Grid item xs={2}> | |||||
| <TextField | |||||
| label={t("Qty To Be Transferred")} | |||||
| fullWidth | |||||
| variant="outlined" | |||||
| type="number" | |||||
| value={qtyToBeTransferred} | |||||
| onChange={(e) => { | |||||
| const value = parseInt(e.target.value) || 0; | |||||
| const maxValue = Math.max(0, originalQty); | |||||
| setQtyToBeTransferred(Math.min(Math.max(0, value), maxValue)); | |||||
| }} | |||||
| inputProps={{ min: 0, max: originalQty, step: 1 }} | |||||
| InputLabelProps={{ | |||||
| shrink: true, | |||||
| sx: { fontSize: "0.9375rem" }, | |||||
| }} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}> | |||||
| <Typography variant="body1">=</Typography> | |||||
| </Grid> | |||||
| <Grid item xs={2}> | |||||
| <TextField | |||||
| label={t("Remaining Qty")} | |||||
| fullWidth | |||||
| variant="outlined" | |||||
| value={remainingQty} | |||||
| disabled | |||||
| InputLabelProps={{ | |||||
| shrink: true, | |||||
| sx: { fontSize: "0.9375rem" }, | |||||
| }} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={2}> | |||||
| <TextField | |||||
| label={t("Stock UoM")} | |||||
| fullWidth | |||||
| variant="outlined" | |||||
| value={selectedLotLine?.uom || ""} | |||||
| disabled | |||||
| InputLabelProps={{ | |||||
| shrink: true, | |||||
| sx: { fontSize: "0.9375rem" }, | |||||
| }} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={2} sx={{ display: 'flex', alignItems: 'center' }}> | |||||
| <Button | |||||
| variant="contained" | |||||
| fullWidth | |||||
| sx={{ | |||||
| height: '56px', | |||||
| fontSize: '0.9375rem', | |||||
| }} | |||||
| onClick={handleSubmitStockTransfer} | |||||
| disabled={!selectedLotLine || !targetLocation || qtyToBeTransferred <= 0 || qtyToBeTransferred > originalQty} | |||||
| > | |||||
| {t("Submit")} | |||||
| </Button> | |||||
| </Grid> | |||||
| </Grid> | |||||
| </Card> | |||||
| </Modal> | |||||
| </> | </> | ||||
| } | } | ||||
| @@ -13,8 +13,6 @@ import { TypeEnum } from "@/app/utils/typeEnum"; | |||||
| import axios from "axios"; | import axios from "axios"; | ||||
| import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api"; | import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | import axiosInstance from "@/app/(main)/axios/axiosInstance"; | ||||
| import { deleteItem } from "@/app/api/settings/item/actions"; | |||||
| import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; | |||||
| type Props = { | type Props = { | ||||
| items: ItemsResult[]; | items: ItemsResult[]; | ||||
| @@ -52,6 +50,8 @@ const ItemsSearch: React.FC<Props> = ({ items }) => { | |||||
| [router], | [router], | ||||
| ); | ); | ||||
| const onDeleteClick = useCallback((item: ItemsResult) => {}, [router]); | |||||
| const checkItemStatus = useCallback((item: ItemsResult): "complete" | "missing" => { | const checkItemStatus = useCallback((item: ItemsResult): "complete" | "missing" => { | ||||
| // Check if type exists and is not empty | // Check if type exists and is not empty | ||||
| const hasType = item.type != null && String(item.type).trim() !== ""; | const hasType = item.type != null && String(item.type).trim() !== ""; | ||||
| @@ -76,6 +76,48 @@ const ItemsSearch: React.FC<Props> = ({ items }) => { | |||||
| return "missing"; | return "missing"; | ||||
| }, []); | }, []); | ||||
| const columns = useMemo<Column<ItemsResultWithStatus>[]>( | |||||
| () => [ | |||||
| { | |||||
| name: "id", | |||||
| label: t("Details"), | |||||
| onClick: onDetailClick, | |||||
| buttonIcon: <EditNote />, | |||||
| }, | |||||
| { | |||||
| name: "code", | |||||
| label: t("Code"), | |||||
| }, | |||||
| { | |||||
| name: "name", | |||||
| label: t("Name"), | |||||
| }, | |||||
| { | |||||
| name: "type", | |||||
| label: t("Type"), | |||||
| }, | |||||
| { | |||||
| name: "status", | |||||
| label: t("Status"), | |||||
| renderCell: (item) => { | |||||
| const status = item.status || checkItemStatus(item); | |||||
| if (status === "complete") { | |||||
| return <Chip label={t("Complete")} color="success" size="small" />; | |||||
| } else { | |||||
| return <Chip label={t("Missing Data")} color="warning" size="small" />; | |||||
| } | |||||
| }, | |||||
| }, | |||||
| { | |||||
| name: "action", | |||||
| label: t(""), | |||||
| buttonIcon: <GridDeleteIcon />, | |||||
| onClick: onDeleteClick, | |||||
| }, | |||||
| ], | |||||
| [onDeleteClick, onDetailClick, t, checkItemStatus], | |||||
| ); | |||||
| const refetchData = useCallback( | const refetchData = useCallback( | ||||
| async (filterObj: SearchQuery) => { | async (filterObj: SearchQuery) => { | ||||
| const authHeader = axiosInstance.defaults.headers["Authorization"]; | const authHeader = axiosInstance.defaults.headers["Authorization"]; | ||||
| @@ -92,6 +134,8 @@ const ItemsSearch: React.FC<Props> = ({ items }) => { | |||||
| `${NEXT_PUBLIC_API_URL}/items/getRecordByPage`, | `${NEXT_PUBLIC_API_URL}/items/getRecordByPage`, | ||||
| { params }, | { params }, | ||||
| ); | ); | ||||
| console.log("API Response:", response); | |||||
| console.log("First record keys:", response.data?.records?.[0] ? Object.keys(response.data.records[0]) : "No records"); | |||||
| if (response.status == 200) { | if (response.status == 200) { | ||||
| // Normalize field names and add status to each item | // Normalize field names and add status to each item | ||||
| const itemsWithStatus: ItemsResultWithStatus[] = response.data.records.map((item: any) => { | const itemsWithStatus: ItemsResultWithStatus[] = response.data.records.map((item: any) => { | ||||
| @@ -106,12 +150,18 @@ const ItemsSearch: React.FC<Props> = ({ items }) => { | |||||
| qcCategory: item.qcCategory || (qcCategoryId ? { id: qcCategoryId } : undefined), | qcCategory: item.qcCategory || (qcCategoryId ? { id: qcCategoryId } : undefined), | ||||
| }; | }; | ||||
| console.log("Normalized item:", { | |||||
| id: normalizedItem.id, | |||||
| LocationCode: normalizedItem.LocationCode, | |||||
| qcCategoryId: qcCategoryId, | |||||
| qcCategory: normalizedItem.qcCategory | |||||
| }); | |||||
| return { | return { | ||||
| ...normalizedItem, | ...normalizedItem, | ||||
| status: checkItemStatus(normalizedItem), | status: checkItemStatus(normalizedItem), | ||||
| }; | }; | ||||
| }); | }); | ||||
| console.log("Fetched items data:", itemsWithStatus); | |||||
| setFilteredItems(itemsWithStatus as ItemsResult[]); | setFilteredItems(itemsWithStatus as ItemsResult[]); | ||||
| setTotalCount(response.data.total); | setTotalCount(response.data.total); | ||||
| return response; // Return the data from the response | return response; // Return the data from the response | ||||
| @@ -135,64 +185,6 @@ const ItemsSearch: React.FC<Props> = ({ items }) => { | |||||
| refetchData, | refetchData, | ||||
| ]); | ]); | ||||
| const onDeleteClick = useCallback( | |||||
| (item: ItemsResult) => { | |||||
| deleteDialog(async () => { | |||||
| if (item.id) { | |||||
| const itemId = typeof item.id === "string" ? parseInt(item.id, 10) : item.id; | |||||
| if (!isNaN(itemId)) { | |||||
| await deleteItem(itemId); | |||||
| await refetchData(filterObj); | |||||
| await successDialog(t("Delete Success"), t); | |||||
| } | |||||
| } | |||||
| }, t); | |||||
| }, | |||||
| [refetchData, filterObj, t], | |||||
| ); | |||||
| const columns = useMemo<Column<ItemsResultWithStatus>[]>( | |||||
| () => [ | |||||
| { | |||||
| name: "id", | |||||
| label: t("Details"), | |||||
| onClick: onDetailClick, | |||||
| buttonIcon: <EditNote />, | |||||
| }, | |||||
| { | |||||
| name: "code", | |||||
| label: t("Code"), | |||||
| }, | |||||
| { | |||||
| name: "name", | |||||
| label: t("Name"), | |||||
| }, | |||||
| { | |||||
| name: "type", | |||||
| label: t("Type"), | |||||
| }, | |||||
| { | |||||
| name: "status", | |||||
| label: t("Status"), | |||||
| renderCell: (item) => { | |||||
| const status = item.status || checkItemStatus(item); | |||||
| if (status === "complete") { | |||||
| return <Chip label={t("Complete")} color="success" size="small" />; | |||||
| } else { | |||||
| return <Chip label={t("Missing Data")} color="warning" size="small" />; | |||||
| } | |||||
| }, | |||||
| }, | |||||
| { | |||||
| name: "action", | |||||
| label: t(""), | |||||
| buttonIcon: <GridDeleteIcon />, | |||||
| onClick: onDeleteClick, | |||||
| }, | |||||
| ], | |||||
| [onDeleteClick, onDetailClick, t, checkItemStatus], | |||||
| ); | |||||
| const onReset = useCallback(() => { | const onReset = useCallback(() => { | ||||
| setFilteredItems(items); | setFilteredItems(items); | ||||
| }, [items]); | }, [items]); | ||||
| @@ -37,7 +37,6 @@ 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[]; | ||||
| @@ -490,7 +489,6 @@ 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> | ||||
| @@ -505,7 +503,6 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => { | |||||
| printQty={printQty} | printQty={printQty} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {tabIndex === 2 && <MaterialPickStatusTable />} | |||||
| </Box> | </Box> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -1,381 +0,0 @@ | |||||
| "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; | |||||
| @@ -26,7 +26,14 @@ import Link from "next/link"; | |||||
| import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | ||||
| import Logo from "../Logo"; | import Logo from "../Logo"; | ||||
| import BugReportIcon from "@mui/icons-material/BugReport"; | import BugReportIcon from "@mui/icons-material/BugReport"; | ||||
| import { AUTH } from "../../authorities"; | |||||
| import { | |||||
| VIEW_USER, | |||||
| MAINTAIN_USER, | |||||
| VIEW_GROUP, | |||||
| MAINTAIN_GROUP, | |||||
| // Add more authorities as needed, e.g.: | |||||
| TESTING, PROD, PACK, ADMIN, STOCK, Driver | |||||
| } from "../../authorities"; | |||||
| interface NavigationItem { | interface NavigationItem { | ||||
| icon: React.ReactNode; | icon: React.ReactNode; | ||||
| @@ -60,18 +67,15 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Store Management", | label: "Store Management", | ||||
| path: "", | path: "", | ||||
| requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ADMIN], | |||||
| children: [ | children: [ | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Purchase Order", | label: "Purchase Order", | ||||
| requiredAbility: [AUTH.PURCHASE, AUTH.ADMIN], | |||||
| path: "/po", | path: "/po", | ||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Pick Order", | label: "Pick Order", | ||||
| requiredAbility: [AUTH.STOCK, AUTH.ADMIN], | |||||
| path: "/pickOrder", | path: "/pickOrder", | ||||
| }, | }, | ||||
| // { | // { | ||||
| @@ -97,19 +101,16 @@ const NavigationContent: React.FC = () => { | |||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "View item In-out And inventory Ledger", | label: "View item In-out And inventory Ledger", | ||||
| requiredAbility: [AUTH.STOCK, AUTH.ADMIN], | |||||
| path: "/inventory", | path: "/inventory", | ||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Stock Take Management", | label: "Stock Take Management", | ||||
| requiredAbility: [AUTH.STOCK_TAKE, AUTH.ADMIN], | |||||
| path: "/stocktakemanagement", | path: "/stocktakemanagement", | ||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Stock Issue", | label: "Stock Issue", | ||||
| requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.ADMIN], | |||||
| path: "/stockIssue", | path: "/stockIssue", | ||||
| }, | }, | ||||
| //TODO: anna | //TODO: anna | ||||
| @@ -121,33 +122,24 @@ const NavigationContent: React.FC = () => { | |||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Put Away Scan", | label: "Put Away Scan", | ||||
| requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.ADMIN], | |||||
| path: "/putAway", | path: "/putAway", | ||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Finished Good Order", | label: "Finished Good Order", | ||||
| requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN], | |||||
| path: "/finishedGood", | path: "/finishedGood", | ||||
| }, | }, | ||||
| { | |||||
| icon: <RequestQuote />, | |||||
| label: "Stock Record", | |||||
| requiredAbility: [AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN], | |||||
| path: "/stockRecord", | |||||
| }, | |||||
| ], | ], | ||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Delivery", | label: "Delivery", | ||||
| path: "", | path: "", | ||||
| requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN], | |||||
| //requiredAbility: VIEW_DO, | |||||
| children: [ | children: [ | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Delivery Order", | label: "Delivery Order", | ||||
| requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN], | |||||
| path: "/do", | path: "/do", | ||||
| }, | }, | ||||
| ], | ], | ||||
| @@ -190,7 +182,6 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Scheduling", | label: "Scheduling", | ||||
| path: "", | path: "", | ||||
| requiredAbility: [AUTH.FORECAST, AUTH.ADMIN], | |||||
| children: [ | children: [ | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| @@ -215,30 +206,25 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Management Job Order", | label: "Management Job Order", | ||||
| path: "", | path: "", | ||||
| requiredAbility: [AUTH.JOB_CREATE, AUTH.JOB_PICK, AUTH.JOB_PROD, AUTH.ADMIN], | |||||
| children: [ | children: [ | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Search Job Order/ Create Job Order", | label: "Search Job Order/ Create Job Order", | ||||
| requiredAbility: [AUTH.JOB_CREATE, AUTH.ADMIN], | |||||
| path: "/jo", | path: "/jo", | ||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Job Order Pickexcution", | label: "Job Order Pickexcution", | ||||
| requiredAbility: [AUTH.JOB_PICK, AUTH.JOB_MAT, AUTH.ADMIN], | |||||
| path: "/jodetail", | path: "/jodetail", | ||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Job Order Production Process", | label: "Job Order Production Process", | ||||
| requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], | |||||
| path: "/productionProcess", | path: "/productionProcess", | ||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Bag Usage", | label: "Bag Usage", | ||||
| requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN], | |||||
| path: "/bag", | path: "/bag", | ||||
| }, | }, | ||||
| ], | ], | ||||
| @@ -247,40 +233,33 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <BugReportIcon />, | icon: <BugReportIcon />, | ||||
| label: "PS", | label: "PS", | ||||
| path: "/ps", | path: "/ps", | ||||
| requiredAbility: AUTH.TESTING, | |||||
| requiredAbility: TESTING, | |||||
| isHidden: false, | isHidden: false, | ||||
| }, | }, | ||||
| { | { | ||||
| icon: <BugReportIcon />, | icon: <BugReportIcon />, | ||||
| label: "Printer Testing", | label: "Printer Testing", | ||||
| path: "/testing", | path: "/testing", | ||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||||
| isHidden: false, | |||||
| }, | |||||
| { | |||||
| icon: <BugReportIcon />, | |||||
| label: "Report Management", | |||||
| path: "/report", | |||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||||
| requiredAbility: TESTING, | |||||
| isHidden: false, | isHidden: false, | ||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Settings", | label: "Settings", | ||||
| path: "", | path: "", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| requiredAbility: [VIEW_USER, VIEW_GROUP], | |||||
| children: [ | children: [ | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "User", | label: "User", | ||||
| path: "/settings/user", | path: "/settings/user", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| requiredAbility: VIEW_USER, | |||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "User Group", | label: "User Group", | ||||
| path: "/settings/user", | path: "/settings/user", | ||||
| requiredAbility: [AUTH.VIEW_GROUP, AUTH.ADMIN], | |||||
| requiredAbility: VIEW_GROUP, | |||||
| }, | }, | ||||
| // { | // { | ||||
| // icon: <RequestQuote />, | // icon: <RequestQuote />, | ||||
| @@ -1,324 +0,0 @@ | |||||
| "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, processingTime: number | null, setupTime: number | null, changeoverTime: number | null): string => { | |||||
| if (!planEndTime) return '-'; | |||||
| let endTime: dayjs.Dayjs; | |||||
| // Handle array format [year, month, day, hour, minute, second] | |||||
| // Use arrayToDayjs for consistency with other parts of the codebase | |||||
| if (Array.isArray(planEndTime)) { | |||||
| try { | |||||
| endTime = arrayToDayjs(planEndTime, true); | |||||
| 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 the planned end time is in the past, show 0 (or you could show negative time) | |||||
| 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", )} | |||||
| </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.")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell rowSpan={3}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("FG / WIP Item")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell rowSpan={3}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||||
| {t("Production Time Remaining")} | |||||
| </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")} {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")} | |||||
| </Typography> | |||||
| <Typography variant="caption" sx={{ fontWeight: 600 }}> | |||||
| {t("Finish")} | |||||
| </Typography> | |||||
| <Typography variant="caption" sx={{ fontWeight: 600 }}> | |||||
| {t("Wait Time [minutes]")} | |||||
| </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, row.processingTime, row.setupTime, row.changeoverTime)} | |||||
| </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; | |||||
| @@ -142,7 +142,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||||
| } | } | ||||
| // 3) 更新 JO 状态 | // 3) 更新 JO 状态 | ||||
| // await updateJo({ id: process.jobOrderId, status: "completed" }); | |||||
| await updateJo({ id: process.jobOrderId, status: "completed" }); | |||||
| // 4) 刷新列表 | // 4) 刷新列表 | ||||
| await fetchProcesses(); | await fetchProcesses(); | ||||
| @@ -8,7 +8,6 @@ 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, | ||||
| @@ -165,7 +164,6 @@ 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 && ( | ||||
| @@ -192,9 +190,6 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo | |||||
| selectedPrinter={selectedPrinter} | selectedPrinter={selectedPrinter} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {tabIndex === 2 && ( | |||||
| <JobProcessStatus /> | |||||
| )} | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -69,7 +69,6 @@ const TruckLaneDetail: React.FC = () => { | |||||
| const [uniqueRemarks, setUniqueRemarks] = useState<string[]>([]); | const [uniqueRemarks, setUniqueRemarks] = useState<string[]>([]); | ||||
| const [uniqueShopCodes, setUniqueShopCodes] = useState<string[]>([]); | const [uniqueShopCodes, setUniqueShopCodes] = useState<string[]>([]); | ||||
| const [uniqueShopNames, setUniqueShopNames] = useState<string[]>([]); | const [uniqueShopNames, setUniqueShopNames] = useState<string[]>([]); | ||||
| const [shopNameByCodeMap, setShopNameByCodeMap] = useState<Map<string, string>>(new Map()); | |||||
| const [addShopDialogOpen, setAddShopDialogOpen] = useState<boolean>(false); | const [addShopDialogOpen, setAddShopDialogOpen] = useState<boolean>(false); | ||||
| const [newShop, setNewShop] = useState({ | const [newShop, setNewShop] = useState({ | ||||
| shopName: "", | shopName: "", | ||||
| @@ -87,12 +86,11 @@ const TruckLaneDetail: React.FC = () => { | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const fetchAutocompleteData = async () => { | const fetchAutocompleteData = async () => { | ||||
| try { | try { | ||||
| const [shopData, remarks, codes, names, allShopsFromShopTable] = await Promise.all([ | |||||
| const [shopData, remarks, codes, names] = await Promise.all([ | |||||
| findAllUniqueShopNamesAndCodesFromTrucksClient() as Promise<Array<{ name: string; code: string }>>, | findAllUniqueShopNamesAndCodesFromTrucksClient() as Promise<Array<{ name: string; code: string }>>, | ||||
| findAllUniqueRemarksFromTrucksClient() as Promise<string[]>, | findAllUniqueRemarksFromTrucksClient() as Promise<string[]>, | ||||
| findAllUniqueShopCodesFromTrucksClient() as Promise<string[]>, | findAllUniqueShopCodesFromTrucksClient() as Promise<string[]>, | ||||
| findAllUniqueShopNamesFromTrucksClient() as Promise<string[]>, | findAllUniqueShopNamesFromTrucksClient() as Promise<string[]>, | ||||
| fetchAllShopsClient() as Promise<ShopAndTruck[]>, | |||||
| ]); | ]); | ||||
| // Convert to Shop format (id will be 0 since we don't have shop IDs from truck table) | // Convert to Shop format (id will be 0 since we don't have shop IDs from truck table) | ||||
| @@ -107,15 +105,6 @@ const TruckLaneDetail: React.FC = () => { | |||||
| setUniqueRemarks(remarks || []); | setUniqueRemarks(remarks || []); | ||||
| setUniqueShopCodes(codes || []); | setUniqueShopCodes(codes || []); | ||||
| setUniqueShopNames(names || []); | setUniqueShopNames(names || []); | ||||
| // Create lookup map: shopCode -> shopName from shop table | |||||
| const shopNameMap = new Map<string, string>(); | |||||
| (allShopsFromShopTable || []).forEach((shop) => { | |||||
| if (shop.code) { | |||||
| shopNameMap.set(String(shop.code).trim().toLowerCase(), String(shop.name || "").trim()); | |||||
| } | |||||
| }); | |||||
| setShopNameByCodeMap(shopNameMap); | |||||
| } catch (err) { | } catch (err) { | ||||
| console.error("Failed to load autocomplete data:", err); | console.error("Failed to load autocomplete data:", err); | ||||
| } | } | ||||
| @@ -711,7 +700,6 @@ const TruckLaneDetail: React.FC = () => { | |||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell>{t("Shop Name")}</TableCell> | <TableCell>{t("Shop Name")}</TableCell> | ||||
| <TableCell>{t("Shop Branch")}</TableCell> | |||||
| <TableCell>{t("Shop Code")}</TableCell> | <TableCell>{t("Shop Code")}</TableCell> | ||||
| <TableCell>{t("Remark")}</TableCell> | <TableCell>{t("Remark")}</TableCell> | ||||
| <TableCell>{t("Loading Sequence")}</TableCell> | <TableCell>{t("Loading Sequence")}</TableCell> | ||||
| @@ -721,7 +709,7 @@ const TruckLaneDetail: React.FC = () => { | |||||
| <TableBody> | <TableBody> | ||||
| {shopsData.length === 0 ? ( | {shopsData.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={6} align="center"> | |||||
| <TableCell colSpan={5} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("No shops found using this truck lane")} | {t("No shops found using this truck lane")} | ||||
| </Typography> | </Typography> | ||||
| @@ -731,14 +719,6 @@ const TruckLaneDetail: React.FC = () => { | |||||
| shopsData.map((shop, index) => ( | shopsData.map((shop, index) => ( | ||||
| <TableRow key={shop.id ?? `shop-${index}`}> | <TableRow key={shop.id ?? `shop-${index}`}> | ||||
| <TableCell> | <TableCell> | ||||
| {/* Shop Name from shop table (read-only, looked up by shop code) */} | |||||
| {(() => { | |||||
| const shopCode = String(shop.code || "").trim().toLowerCase(); | |||||
| return shopNameByCodeMap.get(shopCode) || "-"; | |||||
| })()} | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| {/* Shop Branch from truck table (editable) */} | |||||
| {editingRowIndex === index ? ( | {editingRowIndex === index ? ( | ||||
| <Autocomplete | <Autocomplete | ||||
| freeSolo | freeSolo | ||||
| @@ -365,28 +365,26 @@ return ( | |||||
| </Grid></> | </Grid></> | ||||
| )} */} | )} */} | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <Controller | |||||
| control={control} | |||||
| name="expiryDate" | |||||
| render={({ field }) => { | |||||
| const expiryDateValue = watch("expiryDate"); | |||||
| return ( | |||||
| <LocalizationProvider | |||||
| dateAdapter={AdapterDayjs} | |||||
| adapterLocale={`${language}-hk`} | |||||
| > | |||||
| <Controller | |||||
| control={control} | |||||
| name="expiryDate" | |||||
| // rules={{ required: !Boolean(productionDate) }} | |||||
| render={({ field }) => { | |||||
| return ( | |||||
| <LocalizationProvider | |||||
| dateAdapter={AdapterDayjs} | |||||
| adapterLocale={`${language}-hk`} | |||||
| > | |||||
| <DatePicker | <DatePicker | ||||
| {...field} | {...field} | ||||
| sx={textfieldSx} | sx={textfieldSx} | ||||
| label={t("expiryDate")} | label={t("expiryDate")} | ||||
| value={expiryDateValue ? dayjs(expiryDateValue) : null} // Use null instead of undefined | |||||
| value={expiryDate ? dayjs(expiryDate) : undefined} | |||||
| format={OUTPUT_DATE_FORMAT} | format={OUTPUT_DATE_FORMAT} | ||||
| disabled={disabled} | disabled={disabled} | ||||
| onChange={(date) => { | onChange={(date) => { | ||||
| if (!date) { | |||||
| setValue("expiryDate", ""); | |||||
| return; | |||||
| } | |||||
| if (!date) return; | |||||
| console.log(date.format(INPUT_DATE_FORMAT)); | |||||
| setValue("expiryDate", date.format(INPUT_DATE_FORMAT)); | setValue("expiryDate", date.format(INPUT_DATE_FORMAT)); | ||||
| }} | }} | ||||
| inputRef={field.ref} | inputRef={field.ref} | ||||
| @@ -418,10 +416,10 @@ return ( | |||||
| }, | }, | ||||
| }} | }} | ||||
| /> | /> | ||||
| </LocalizationProvider> | |||||
| ); | |||||
| }} | |||||
| /> | |||||
| </LocalizationProvider> | |||||
| ); | |||||
| }} | |||||
| /> | |||||
| </Grid> | </Grid> | ||||
| {/* <Grid item xs={6}> | {/* <Grid item xs={6}> | ||||
| {putawayMode ? ( | {putawayMode ? ( | ||||
| @@ -1,444 +0,0 @@ | |||||
| "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; | |||||
| @@ -1,26 +0,0 @@ | |||||
| 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,7 +201,23 @@ 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,14 +14,10 @@ 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, useMemo } from "react"; | |||||
| import { useState, useCallback, useEffect, useRef } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { | import { | ||||
| AllPickedStockTakeListReponse, | AllPickedStockTakeListReponse, | ||||
| @@ -56,8 +52,7 @@ 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>>({}); | ||||
| @@ -65,111 +60,28 @@ 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>>(); | ||||
| 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; | |||||
| } | |||||
| 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(() => { | 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"; | |||||
| } | |||||
| 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); | |||||
| } | } | ||||
| }); | |||||
| 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]); | |||||
| }; | |||||
| loadDetails(); | |||||
| }, [selectedSession]); | |||||
| const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| return; | return; | ||||
| @@ -223,7 +135,11 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| onSnackbar(t("Approver stock take record saved successfully"), "success"); | onSnackbar(t("Approver stock take record saved successfully"), "success"); | ||||
| await loadDetails(page, pageSize); | |||||
| 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("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"); | ||||
| @@ -243,8 +159,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| } finally { | } finally { | ||||
| setSaving(false); | setSaving(false); | ||||
| } | } | ||||
| }, [selectedSession, qtySelection, approverQty, approverBadQty, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||||
| }, [selectedSession, qtySelection, approverQty, approverBadQty, t, currentUserId, onSnackbar]); | |||||
| 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"); | ||||
| @@ -256,6 +171,12 @@ 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"); | ||||
| @@ -274,20 +195,8 @@ 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, loadDetails]); | |||||
| }, [selectedSession, t, onSnackbar]); | |||||
| 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'); | ||||
| @@ -314,7 +223,11 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| result.errorCount > 0 ? "warning" : "success" | result.errorCount > 0 ? "warning" : "success" | ||||
| ); | ); | ||||
| await loadDetails(page, pageSize); | |||||
| 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("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"); | ||||
| @@ -334,12 +247,11 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| } finally { | } finally { | ||||
| setBatchSaving(false); | setBatchSaving(false); | ||||
| } | } | ||||
| }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||||
| }, [selectedSession, t, currentUserId, onSnackbar]); | |||||
| 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', { | ||||
| @@ -347,7 +259,6 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| maximumFractionDigits: 2 | maximumFractionDigits: 2 | ||||
| }); | }); | ||||
| }; | }; | ||||
| const uniqueWarehouses = Array.from( | const uniqueWarehouses = Array.from( | ||||
| new Set( | new Set( | ||||
| inventoryLotDetails | inventoryLotDetails | ||||
| @@ -355,7 +266,6 @@ 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) { | ||||
| @@ -370,270 +280,232 @@ 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> | |||||
| <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> | |||||
| <Button variant="contained" color="primary" onClick={handleBatchSubmitAll} disabled={batchSaving}> | |||||
| {t("Batch Save All")} | |||||
| </Button> | |||||
| </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> | |||||
| <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 ? ( | |||||
| <TableRow> | <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> | |||||
| <TableCell colSpan={7} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | </TableRow> | ||||
| </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"); | |||||
| ) : ( | |||||
| inventoryLotDetails.map((detail) => { | |||||
| const submitDisabled = isSubmitDisabled(detail); | |||||
| const hasFirst = detail.firstStockTakeQty != null && detail.firstStockTakeQty > 0; | |||||
| const hasSecond = detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0; | |||||
| const selection = qtySelection[detail.id] || "first"; | |||||
| return ( | |||||
| <TableRow key={detail.id}> | |||||
| <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell> | |||||
| <TableCell sx={{ | |||||
| maxWidth: 150, | |||||
| wordBreak: 'break-word', | |||||
| whiteSpace: 'normal', | |||||
| lineHeight: 1.5 | |||||
| }}> | |||||
| return ( | |||||
| <TableRow key={detail.id}> | |||||
| <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell> | |||||
| <TableCell sx={{ | |||||
| maxWidth: 150, | |||||
| wordBreak: 'break-word', | |||||
| whiteSpace: 'normal', | |||||
| lineHeight: 1.5 | |||||
| }}> | |||||
| <Stack spacing={0.5}> | |||||
| <Box>{detail.itemCode || "-"} {detail.itemName || "-"}</Box> | |||||
| <Box>{detail.lotNo || "-"}</Box> | |||||
| <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | |||||
| {/*<Box><Chip size="small" label={t(detail.status)} color="default" /></Box>*/} | |||||
| </Stack> | |||||
| </TableCell> | |||||
| <TableCell sx={{ minWidth: 300 }}> | |||||
| {detail.finalQty != null ? ( | |||||
| <Stack spacing={0.5}> | <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> | |||||
| <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> | |||||
| </Stack> | </Stack> | ||||
| </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")} | |||||
| /> | |||||
| </> | |||||
| ) : ( | |||||
| <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> | |||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -224,7 +224,23 @@ 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,7 +15,6 @@ 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"; | ||||
| @@ -34,13 +33,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 PickerReStockTakeProps { | |||||
| interface PickerStockTakeProps { | |||||
| 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 PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| selectedSession, | selectedSession, | ||||
| onBack, | onBack, | ||||
| onSnackbar, | onSnackbar, | ||||
| @@ -61,63 +60,28 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| 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); | |||||
| }, []); | |||||
| 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; | |||||
| } | |||||
| 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(() => { | useEffect(() => { | ||||
| loadDetails(page, pageSize); | |||||
| }, [page, pageSize, loadDetails]); | |||||
| const loadDetails = async () => { | |||||
| setLoadingDetails(true); | |||||
| try { | |||||
| const details = await getInventoryLotDetailsBySectionNotMatch( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setInventoryLotDetails([]); | |||||
| } finally { | |||||
| setLoadingDetails(false); | |||||
| } | |||||
| }; | |||||
| loadDetails(); | |||||
| }, [selectedSession]); | |||||
| const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | ||||
| setEditingRecord(detail); | setEditingRecord(detail); | ||||
| @@ -167,9 +131,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| 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, | ||||
| @@ -179,7 +143,11 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| onSnackbar(t("Stock take record saved successfully"), "success"); | onSnackbar(t("Stock take record saved successfully"), "success"); | ||||
| handleCancelEdit(); | handleCancelEdit(); | ||||
| await loadDetails(page, pageSize); | |||||
| 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("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"); | ||||
| @@ -199,7 +167,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| } finally { | } finally { | ||||
| setSaving(false); | setSaving(false); | ||||
| } | } | ||||
| }, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||||
| }, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar]); | |||||
| const handleBatchSubmitAll = useCallback(async () => { | const handleBatchSubmitAll = useCallback(async () => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| @@ -227,7 +195,11 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| result.errorCount > 0 ? "warning" : "success" | result.errorCount > 0 ? "warning" : "success" | ||||
| ); | ); | ||||
| await loadDetails(page, pageSize); | |||||
| 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("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"); | ||||
| @@ -247,7 +219,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| } finally { | } finally { | ||||
| setBatchSaving(false); | setBatchSaving(false); | ||||
| } | } | ||||
| }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||||
| }, [selectedSession, t, currentUserId, onSnackbar]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| handleBatchSubmitAllRef.current = handleBatchSubmitAll; | handleBatchSubmitAllRef.current = handleBatchSubmitAll; | ||||
| @@ -353,213 +325,213 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| <CircularProgress /> | <CircularProgress /> | ||||
| </Box> | </Box> | ||||
| ) : ( | ) : ( | ||||
| <> | |||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <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 ? ( | |||||
| <TableRow> | <TableRow> | ||||
| <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> | |||||
| <TableCell colSpan={12} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | </TableRow> | ||||
| </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; | |||||
| ) : ( | |||||
| 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> | |||||
| <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 }}> | |||||
| 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} | |||||
| {isEditing && isSecondSubmit ? ( | {isEditing && isSecondSubmit ? ( | ||||
| <> | |||||
| <Typography variant="body2">{t("Remark")}</Typography> | |||||
| <TextField | |||||
| size="small" | |||||
| value={remark} | |||||
| onChange={(e) => setRemark(e.target.value)} | |||||
| sx={{ width: 150 }} | |||||
| /> | |||||
| </> | |||||
| ) : ( | |||||
| <TextField | |||||
| size="small" | |||||
| type="number" | |||||
| value={secondQty} | |||||
| onChange={(e) => setSecondQty(e.target.value)} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| ) : detail.secondStockTakeQty ? ( | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {detail.remarks || "-"} | |||||
| {t("Second")}: {detail.secondStockTakeQty.toFixed(2)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {!detail.firstStockTakeQty && !detail.secondStockTakeQty && !isEditing && ( | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| - | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </TableCell> | |||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| </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> | |||||
| {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> | |||||
| ) : ( | |||||
| <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 | <Button | ||||
| size="small" | size="small" | ||||
| variant="outlined" | |||||
| onClick={() => handleStartEdit(detail)} | |||||
| disabled={submitDisabled} | |||||
| variant="contained" | |||||
| onClick={() => handleSaveStockTake(detail)} | |||||
| disabled={saving || submitDisabled} | |||||
| > | > | ||||
| {!detail.stockTakeRecordId | |||||
| ? t("Input") | |||||
| : detail.stockTakeRecordStatus === "notMatch" | |||||
| ? t("Input") | |||||
| : t("View")} | |||||
| {t("Save")} | |||||
| </Button> | </Button> | ||||
| )} | |||||
| </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")} | |||||
| /> | |||||
| </> | |||||
| <Button | |||||
| size="small" | |||||
| onClick={handleCancelEdit} | |||||
| > | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| </Stack> | |||||
| ) : ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| onClick={() => handleStartEdit(detail)} | |||||
| disabled={submitDisabled} | |||||
| > | |||||
| {!detail.stockTakeRecordId | |||||
| ? t("Input") | |||||
| : detail.stockTakeRecordStatus === "notMatch" | |||||
| ? t("Input") | |||||
| : t("View")} | |||||
| </Button> | |||||
| )} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| export default PickerReStockTake; | |||||
| export default PickerStockTake; | |||||
| @@ -15,13 +15,7 @@ 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 { | ||||
| @@ -66,76 +60,29 @@ 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>>(); | ||||
| 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); | |||||
| } | |||||
| 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(() => { | useEffect(() => { | ||||
| loadDetails(page, pageSize); | |||||
| }, [page, pageSize, loadDetails]); | |||||
| const loadDetails = async () => { | |||||
| setLoadingDetails(true); | |||||
| try { | |||||
| const details = await getInventoryLotDetailsBySection( | |||||
| selectedSession.stockTakeSession, | |||||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null | |||||
| ); | |||||
| setInventoryLotDetails(Array.isArray(details) ? details : []); | |||||
| } catch (e) { | |||||
| console.error(e); | |||||
| setInventoryLotDetails([]); | |||||
| } finally { | |||||
| setLoadingDetails(false); | |||||
| } | |||||
| }; | |||||
| loadDetails(); | |||||
| }, [selectedSession]); | |||||
| const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | ||||
| setEditingRecord(detail); | setEditingRecord(detail); | ||||
| @@ -229,9 +176,12 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| onSnackbar(t("Stock take record saved successfully"), "success"); | onSnackbar(t("Stock take record saved successfully"), "success"); | ||||
| handleCancelEdit(); | handleCancelEdit(); | ||||
| await loadDetails(page, pageSize); | |||||
| 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("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"); | ||||
| @@ -263,9 +213,6 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| t, | t, | ||||
| currentUserId, | currentUserId, | ||||
| onSnackbar, | onSnackbar, | ||||
| loadDetails, | |||||
| page, | |||||
| pageSize, | |||||
| ] | ] | ||||
| ); | ); | ||||
| @@ -296,7 +243,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| result.errorCount > 0 ? "warning" : "success" | result.errorCount > 0 ? "warning" : "success" | ||||
| ); | ); | ||||
| await loadDetails(page, pageSize); | |||||
| 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("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"); | ||||
| @@ -442,290 +393,278 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <CircularProgress /> | <CircularProgress /> | ||||
| </Box> | </Box> | ||||
| ) : ( | ) : ( | ||||
| <> | |||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <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 ? ( | |||||
| <TableRow> | <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> | |||||
| <TableCell colSpan={7} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | |||||
| <TableBody> | |||||
| {inventoryLotDetails.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={7} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("No data")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| inventoryLotDetails.map((detail) => { | |||||
| const 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"> | |||||
| {t("First")}:{" "} | |||||
| {formatNumber( | |||||
| (detail.firstStockTakeQty ?? 0) + | |||||
| (detail.firstBadQty ?? 0) | |||||
| )}{" "} | |||||
| ( | |||||
| {formatNumber( | |||||
| detail.firstBadQty ?? 0 | |||||
| )} | |||||
| ) ={" "} | |||||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | |||||
| </Typography> | |||||
| ) : 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 ? ( | |||||
| ) : ( | |||||
| 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"> | <Typography variant="body2"> | ||||
| {t("Second")}:{" "} | |||||
| {formatNumber( | |||||
| (detail.secondStockTakeQty ?? 0) + | |||||
| (detail.secondBadQty ?? 0) | |||||
| )}{" "} | |||||
| ( | |||||
| = | |||||
| {formatNumber( | {formatNumber( | ||||
| detail.secondBadQty ?? 0 | |||||
| parseFloat(firstQty || "0") - | |||||
| parseFloat(firstBadQty || "0") | |||||
| )} | )} | ||||
| ) ={" "} | |||||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | |||||
| </Typography> | </Typography> | ||||
| ) : null} | |||||
| {!detail.firstStockTakeQty && | |||||
| !detail.secondStockTakeQty && | |||||
| !isEditing && ( | |||||
| <Typography | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| > | |||||
| - | |||||
| </Typography> | |||||
| </Stack> | |||||
| ) : detail.firstStockTakeQty != null ? ( | |||||
| <Typography variant="body2"> | |||||
| {t("First")}:{" "} | |||||
| {formatNumber( | |||||
| (detail.firstStockTakeQty ?? 0) + | |||||
| (detail.firstBadQty ?? 0) | |||||
| )}{" "} | |||||
| ( | |||||
| {formatNumber( | |||||
| detail.firstBadQty ?? 0 | |||||
| )} | )} | ||||
| </Stack> | |||||
| </TableCell> | |||||
| ) ={" "} | |||||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | |||||
| </Typography> | |||||
| ) : null} | |||||
| {/* Remark */} | |||||
| <TableCell sx={{ width: 180 }}> | |||||
| {/* Second */} | |||||
| {isEditing && isSecondSubmit ? ( | {isEditing && isSecondSubmit ? ( | ||||
| <> | |||||
| <Typography variant="body2">{t("Remark")}</Typography> | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Typography variant="body2">{t("Second")}:</Typography> | |||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| value={remark} | |||||
| onChange={(e) => setRemark(e.target.value)} | |||||
| sx={{ width: 150 }} | |||||
| 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"> | <Typography variant="body2"> | ||||
| {detail.remarks || "-"} | |||||
| {t("Second")}:{" "} | |||||
| {formatNumber( | |||||
| (detail.secondStockTakeQty ?? 0) + | |||||
| (detail.secondBadQty ?? 0) | |||||
| )}{" "} | |||||
| ( | |||||
| {formatNumber( | |||||
| detail.secondBadQty ?? 0 | |||||
| )} | |||||
| ) ={" "} | |||||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | |||||
| </Typography> | </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 | |||||
| ) : 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 | |||||
| size="small" | size="small" | ||||
| label={t(detail.stockTakeRecordStatus || "")} | |||||
| color="default" | |||||
| value={remark} | |||||
| onChange={(e) => setRemark(e.target.value)} | |||||
| sx={{ width: 150 }} | |||||
| /> | /> | ||||
| )} | |||||
| </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> | |||||
| ) : ( | |||||
| </> | |||||
| ) : ( | |||||
| <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}> | |||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| variant="outlined" | |||||
| onClick={() => handleStartEdit(detail)} | |||||
| disabled={submitDisabled} | |||||
| variant="contained" | |||||
| onClick={() => handleSaveStockTake(detail)} | |||||
| disabled={saving || submitDisabled} | |||||
| > | > | ||||
| {!detail.stockTakeRecordId | |||||
| ? t("Input") | |||||
| : detail.stockTakeRecordStatus === "notMatch" | |||||
| ? t("Input") | |||||
| : t("View")} | |||||
| {t("Save")} | |||||
| </Button> | |||||
| <Button size="small" onClick={handleCancelEdit}> | |||||
| {t("Cancel")} | |||||
| </Button> | </Button> | ||||
| )} | |||||
| </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")} | |||||
| /> | |||||
| </> | |||||
| </Stack> | |||||
| ) : ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| onClick={() => handleStartEdit(detail)} | |||||
| disabled={submitDisabled} | |||||
| > | |||||
| {!detail.stockTakeRecordId | |||||
| ? t("Input") | |||||
| : detail.stockTakeRecordStatus === "notMatch" | |||||
| ? t("Input") | |||||
| : t("View")} | |||||
| </Button> | |||||
| )} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -53,6 +53,8 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| successDialog(t("Delete Success"), t); | successDialog(t("Delete Success"), t); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Failed to delete warehouse:", error); | console.error("Failed to delete warehouse:", error); | ||||
| // Don't redirect on error, just show error message | |||||
| // The error will be logged but user stays on the page | |||||
| } | } | ||||
| }, t); | }, t); | ||||
| }, [t, router]); | }, [t, router]); | ||||
| @@ -74,14 +76,18 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| try { | try { | ||||
| let results: WarehouseResult[] = warehouses; | let results: WarehouseResult[] = warehouses; | ||||
| // Build search pattern from the four fields: store_idF-warehouse-area-slot | |||||
| // Only search by code field - match the code that follows this pattern | |||||
| const storeId = searchInputs.store_id?.trim() || ""; | const storeId = searchInputs.store_id?.trim() || ""; | ||||
| const warehouse = searchInputs.warehouse?.trim() || ""; | const warehouse = searchInputs.warehouse?.trim() || ""; | ||||
| const area = searchInputs.area?.trim() || ""; | const area = searchInputs.area?.trim() || ""; | ||||
| const slot = searchInputs.slot?.trim() || ""; | const slot = searchInputs.slot?.trim() || ""; | ||||
| const stockTakeSection = searchInputs.stockTakeSection?.trim() || ""; | const stockTakeSection = searchInputs.stockTakeSection?.trim() || ""; | ||||
| // If any field has a value, filter by code pattern and stockTakeSection | |||||
| if (storeId || warehouse || area || slot || stockTakeSection) { | if (storeId || warehouse || area || slot || stockTakeSection) { | ||||
| results = warehouses.filter((warehouseItem) => { | results = warehouses.filter((warehouseItem) => { | ||||
| // Filter by stockTakeSection if provided | |||||
| if (stockTakeSection) { | if (stockTakeSection) { | ||||
| const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | ||||
| if (!itemStockTakeSection.includes(stockTakeSection.toLowerCase())) { | if (!itemStockTakeSection.includes(stockTakeSection.toLowerCase())) { | ||||
| @@ -89,6 +95,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| } | } | ||||
| } | } | ||||
| // Filter by code pattern if any code-related field is provided | |||||
| if (storeId || warehouse || area || slot) { | if (storeId || warehouse || area || slot) { | ||||
| if (!warehouseItem.code) { | if (!warehouseItem.code) { | ||||
| return false; | return false; | ||||
| @@ -96,6 +103,8 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| const codeValue = String(warehouseItem.code).toLowerCase(); | const codeValue = String(warehouseItem.code).toLowerCase(); | ||||
| // Check if code matches the pattern: store_id-warehouse-area-slot | |||||
| // Match each part if provided | |||||
| const codeParts = codeValue.split("-"); | const codeParts = codeValue.split("-"); | ||||
| if (codeParts.length >= 4) { | if (codeParts.length >= 4) { | ||||
| @@ -112,6 +121,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | ||||
| } | } | ||||
| // Fallback: if code doesn't follow the pattern, check if it contains any of the search terms | |||||
| const storeIdMatch = !storeId || codeValue.includes(storeId.toLowerCase()); | const storeIdMatch = !storeId || codeValue.includes(storeId.toLowerCase()); | ||||
| const warehouseMatch = !warehouse || codeValue.includes(warehouse.toLowerCase()); | const warehouseMatch = !warehouse || codeValue.includes(warehouse.toLowerCase()); | ||||
| const areaMatch = !area || codeValue.includes(area.toLowerCase()); | const areaMatch = !area || codeValue.includes(area.toLowerCase()); | ||||
| @@ -120,9 +130,11 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | ||||
| } | } | ||||
| // If only stockTakeSection is provided, return true (already filtered above) | |||||
| return true; | return true; | ||||
| }); | }); | ||||
| } else { | } else { | ||||
| // If no search terms, show all warehouses | |||||
| results = warehouses; | results = warehouses; | ||||
| } | } | ||||
| @@ -130,6 +142,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error searching warehouses:", error); | console.error("Error searching warehouses:", error); | ||||
| // Fallback: filter by code pattern and stockTakeSection | |||||
| const storeId = searchInputs.store_id?.trim().toLowerCase() || ""; | const storeId = searchInputs.store_id?.trim().toLowerCase() || ""; | ||||
| const warehouse = searchInputs.warehouse?.trim().toLowerCase() || ""; | const warehouse = searchInputs.warehouse?.trim().toLowerCase() || ""; | ||||
| const area = searchInputs.area?.trim().toLowerCase() || ""; | const area = searchInputs.area?.trim().toLowerCase() || ""; | ||||
| @@ -138,6 +151,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| setFilteredWarehouse( | setFilteredWarehouse( | ||||
| warehouses.filter((warehouseItem) => { | warehouses.filter((warehouseItem) => { | ||||
| // Filter by stockTakeSection if provided | |||||
| if (stockTakeSection) { | if (stockTakeSection) { | ||||
| const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | ||||
| if (!itemStockTakeSection.includes(stockTakeSection)) { | if (!itemStockTakeSection.includes(stockTakeSection)) { | ||||
| @@ -145,6 +159,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| } | } | ||||
| } | } | ||||
| // Filter by code if any code-related field is provided | |||||
| if (storeId || warehouse || area || slot) { | if (storeId || warehouse || area || slot) { | ||||
| if (!warehouseItem.code) { | if (!warehouseItem.code) { | ||||
| return false; | return false; | ||||
| @@ -252,6 +267,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| justifyContent: "flex-start", | justifyContent: "flex-start", | ||||
| }} | }} | ||||
| > | > | ||||
| {/* 樓層 field with F inside on the right */} | |||||
| <TextField | <TextField | ||||
| label={t("store_id")} | label={t("store_id")} | ||||
| value={searchInputs.store_id} | value={searchInputs.store_id} | ||||
| @@ -269,6 +285,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | <Typography variant="body1" sx={{ mx: 0.5 }}> | ||||
| - | - | ||||
| </Typography> | </Typography> | ||||
| {/* 倉庫 field */} | |||||
| <TextField | <TextField | ||||
| label={t("warehouse")} | label={t("warehouse")} | ||||
| value={searchInputs.warehouse} | value={searchInputs.warehouse} | ||||
| @@ -281,6 +298,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | <Typography variant="body1" sx={{ mx: 0.5 }}> | ||||
| - | - | ||||
| </Typography> | </Typography> | ||||
| {/* 區域 field */} | |||||
| <TextField | <TextField | ||||
| label={t("area")} | label={t("area")} | ||||
| value={searchInputs.area} | value={searchInputs.area} | ||||
| @@ -293,6 +311,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | <Typography variant="body1" sx={{ mx: 0.5 }}> | ||||
| - | - | ||||
| </Typography> | </Typography> | ||||
| {/* 儲位 field */} | |||||
| <TextField | <TextField | ||||
| label={t("slot")} | label={t("slot")} | ||||
| value={searchInputs.slot} | value={searchInputs.slot} | ||||
| @@ -302,6 +321,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| size="small" | size="small" | ||||
| sx={{ width: "150px", minWidth: "120px" }} | sx={{ width: "150px", minWidth: "120px" }} | ||||
| /> | /> | ||||
| {/* 盤點區域 field */} | |||||
| <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | ||||
| <TextField | <TextField | ||||
| label={t("stockTakeSection")} | label={t("stockTakeSection")} | ||||
| @@ -30,24 +30,20 @@ function TabPanel(props: TabPanelProps) { | |||||
| interface QrCodeHandleTabsProps { | interface QrCodeHandleTabsProps { | ||||
| userTabContent: ReactNode; | userTabContent: ReactNode; | ||||
| equipmentTabContent: ReactNode; | equipmentTabContent: ReactNode; | ||||
| warehouseTabContent: ReactNode; | |||||
| } | } | ||||
| const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({ | const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({ | ||||
| userTabContent, | userTabContent, | ||||
| equipmentTabContent, | equipmentTabContent, | ||||
| warehouseTabContent, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const { t: tUser } = useTranslation("user"); | const { t: tUser } = useTranslation("user"); | ||||
| const { t: tWarehouse } = useTranslation("warehouse"); | |||||
| const searchParams = useSearchParams(); | const searchParams = useSearchParams(); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const getInitialTab = () => { | const getInitialTab = () => { | ||||
| const tab = searchParams.get("tab"); | const tab = searchParams.get("tab"); | ||||
| if (tab === "equipment") return 1; | if (tab === "equipment") return 1; | ||||
| if (tab === "warehouse") return 2; | |||||
| if (tab === "user") return 0; | if (tab === "user") return 0; | ||||
| return 0; | return 0; | ||||
| }; | }; | ||||
| @@ -58,8 +54,6 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({ | |||||
| const tab = searchParams.get("tab"); | const tab = searchParams.get("tab"); | ||||
| if (tab === "equipment") { | if (tab === "equipment") { | ||||
| setCurrentTab(1); | setCurrentTab(1); | ||||
| } else if (tab === "warehouse") { | |||||
| setCurrentTab(2); | |||||
| } else if (tab === "user") { | } else if (tab === "user") { | ||||
| setCurrentTab(0); | setCurrentTab(0); | ||||
| } | } | ||||
| @@ -67,9 +61,7 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({ | |||||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | ||||
| setCurrentTab(newValue); | setCurrentTab(newValue); | ||||
| let tabName = "user"; | |||||
| if (newValue === 1) tabName = "equipment"; | |||||
| else if (newValue === 2) tabName = "warehouse"; | |||||
| const tabName = newValue === 1 ? "equipment" : "user"; | |||||
| const params = new URLSearchParams(searchParams.toString()); | const params = new URLSearchParams(searchParams.toString()); | ||||
| params.set("tab", tabName); | params.set("tab", tabName); | ||||
| router.push(`?${params.toString()}`, { scroll: false }); | router.push(`?${params.toString()}`, { scroll: false }); | ||||
| @@ -81,7 +73,6 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({ | |||||
| <Tabs value={currentTab} onChange={handleTabChange}> | <Tabs value={currentTab} onChange={handleTabChange}> | ||||
| <Tab label={tUser("User")} /> | <Tab label={tUser("User")} /> | ||||
| <Tab label={t("Equipment")} /> | <Tab label={t("Equipment")} /> | ||||
| <Tab label={tWarehouse("Warehouse")} /> | |||||
| </Tabs> | </Tabs> | ||||
| </Box> | </Box> | ||||
| @@ -92,10 +83,6 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({ | |||||
| <TabPanel value={currentTab} index={1}> | <TabPanel value={currentTab} index={1}> | ||||
| {equipmentTabContent} | {equipmentTabContent} | ||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={currentTab} index={2}> | |||||
| {warehouseTabContent} | |||||
| </TabPanel> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -1,675 +0,0 @@ | |||||
| "use client"; | |||||
| import { useCallback, useMemo, useState, useEffect } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import SearchResults, { Column } from "../SearchResults"; | |||||
| import { successDialog } from "../Swal/CustomAlerts"; | |||||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||||
| import { downloadFile } from "@/app/utils/commonUtil"; | |||||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||||
| import { exportWarehouseQrCode } from "@/app/api/warehouse/client"; | |||||
| import { | |||||
| Checkbox, | |||||
| Box, | |||||
| Button, | |||||
| TextField, | |||||
| Stack, | |||||
| Autocomplete, | |||||
| Modal, | |||||
| Card, | |||||
| CardContent, | |||||
| CardActions, | |||||
| IconButton, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Paper, | |||||
| Typography, | |||||
| InputAdornment | |||||
| } from "@mui/material"; | |||||
| import DownloadIcon from "@mui/icons-material/Download"; | |||||
| import PrintIcon from "@mui/icons-material/Print"; | |||||
| import CloseIcon from "@mui/icons-material/Close"; | |||||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||||
| import Search from "@mui/icons-material/Search"; | |||||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||||
| interface Props { | |||||
| warehouses: WarehouseResult[]; | |||||
| printerCombo: PrinterCombo[]; | |||||
| } | |||||
| const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo }) => { | |||||
| const { t } = useTranslation(["warehouse", "common"]); | |||||
| const [filteredWarehouses, setFilteredWarehouses] = useState(warehouses); | |||||
| const { setIsUploading } = useUploadContext(); | |||||
| const [pagingController, setPagingController] = useState({ | |||||
| pageNum: 1, | |||||
| pageSize: 10, | |||||
| }); | |||||
| const [checkboxIds, setCheckboxIds] = useState<number[]>([]); | |||||
| const [selectAll, setSelectAll] = useState(false); | |||||
| const [printQty, setPrintQty] = useState(1); | |||||
| const [isSearching, setIsSearching] = useState(false); | |||||
| const [previewOpen, setPreviewOpen] = useState(false); | |||||
| const [previewUrl, setPreviewUrl] = useState<string | null>(null); | |||||
| const [selectedWarehousesModalOpen, setSelectedWarehousesModalOpen] = useState(false); | |||||
| const [searchInputs, setSearchInputs] = useState({ | |||||
| store_id: "", | |||||
| warehouse: "", | |||||
| area: "", | |||||
| slot: "", | |||||
| }); | |||||
| const filteredPrinters = useMemo(() => { | |||||
| return printerCombo.filter((printer) => { | |||||
| return printer.type === "A4"; | |||||
| }); | |||||
| }, [printerCombo]); | |||||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | undefined>( | |||||
| filteredPrinters.length > 0 ? filteredPrinters[0] : undefined | |||||
| ); | |||||
| useEffect(() => { | |||||
| if (!selectedPrinter || !filteredPrinters.find(p => p.id === selectedPrinter.id)) { | |||||
| setSelectedPrinter(filteredPrinters.length > 0 ? filteredPrinters[0] : undefined); | |||||
| } | |||||
| }, [filteredPrinters, selectedPrinter]); | |||||
| const handleReset = useCallback(() => { | |||||
| setSearchInputs({ | |||||
| store_id: "", | |||||
| warehouse: "", | |||||
| area: "", | |||||
| slot: "", | |||||
| }); | |||||
| setFilteredWarehouses(warehouses); | |||||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||||
| }, [warehouses, pagingController.pageSize]); | |||||
| const handleSearch = useCallback(() => { | |||||
| setIsSearching(true); | |||||
| try { | |||||
| let results: WarehouseResult[] = warehouses; | |||||
| const storeId = searchInputs.store_id?.trim() || ""; | |||||
| const warehouse = searchInputs.warehouse?.trim() || ""; | |||||
| const area = searchInputs.area?.trim() || ""; | |||||
| const slot = searchInputs.slot?.trim() || ""; | |||||
| if (storeId || warehouse || area || slot) { | |||||
| results = warehouses.filter((warehouseItem) => { | |||||
| if (storeId || warehouse || area || slot) { | |||||
| if (!warehouseItem.code) { | |||||
| return false; | |||||
| } | |||||
| const codeValue = String(warehouseItem.code).toLowerCase(); | |||||
| const codeParts = codeValue.split("-"); | |||||
| if (codeParts.length >= 4) { | |||||
| const codeStoreId = codeParts[0] || ""; | |||||
| const codeWarehouse = codeParts[1] || ""; | |||||
| const codeArea = codeParts[2] || ""; | |||||
| const codeSlot = codeParts[3] || ""; | |||||
| const storeIdMatch = !storeId || codeStoreId.includes(storeId.toLowerCase()); | |||||
| const warehouseMatch = !warehouse || codeWarehouse.includes(warehouse.toLowerCase()); | |||||
| const areaMatch = !area || codeArea.includes(area.toLowerCase()); | |||||
| const slotMatch = !slot || codeSlot.includes(slot.toLowerCase()); | |||||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | |||||
| } | |||||
| const storeIdMatch = !storeId || codeValue.includes(storeId.toLowerCase()); | |||||
| const warehouseMatch = !warehouse || codeValue.includes(warehouse.toLowerCase()); | |||||
| const areaMatch = !area || codeValue.includes(area.toLowerCase()); | |||||
| const slotMatch = !slot || codeValue.includes(slot.toLowerCase()); | |||||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | |||||
| } | |||||
| return true; | |||||
| }); | |||||
| } else { | |||||
| results = warehouses; | |||||
| } | |||||
| setFilteredWarehouses(results); | |||||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||||
| } catch (error) { | |||||
| console.error("Error searching warehouses:", error); | |||||
| const storeId = searchInputs.store_id?.trim().toLowerCase() || ""; | |||||
| const warehouse = searchInputs.warehouse?.trim().toLowerCase() || ""; | |||||
| const area = searchInputs.area?.trim().toLowerCase() || ""; | |||||
| const slot = searchInputs.slot?.trim().toLowerCase() || ""; | |||||
| setFilteredWarehouses( | |||||
| warehouses.filter((warehouseItem) => { | |||||
| if (storeId || warehouse || area || slot) { | |||||
| if (!warehouseItem.code) { | |||||
| return false; | |||||
| } | |||||
| const codeValue = String(warehouseItem.code).toLowerCase(); | |||||
| const codeParts = codeValue.split("-"); | |||||
| if (codeParts.length >= 4) { | |||||
| const storeIdMatch = !storeId || codeParts[0].includes(storeId); | |||||
| const warehouseMatch = !warehouse || codeParts[1].includes(warehouse); | |||||
| const areaMatch = !area || codeParts[2].includes(area); | |||||
| const slotMatch = !slot || codeParts[3].includes(slot); | |||||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | |||||
| } | |||||
| return (!storeId || codeValue.includes(storeId)) && | |||||
| (!warehouse || codeValue.includes(warehouse)) && | |||||
| (!area || codeValue.includes(area)) && | |||||
| (!slot || codeValue.includes(slot)); | |||||
| } | |||||
| return true; | |||||
| }) | |||||
| ); | |||||
| } finally { | |||||
| setIsSearching(false); | |||||
| } | |||||
| }, [searchInputs, warehouses, pagingController.pageSize]); | |||||
| const handleSelectWarehouse = useCallback((warehouseId: number, checked: boolean) => { | |||||
| if (checked) { | |||||
| setCheckboxIds(prev => [...prev, warehouseId]); | |||||
| } else { | |||||
| setCheckboxIds(prev => prev.filter(id => id !== warehouseId)); | |||||
| setSelectAll(false); | |||||
| } | |||||
| }, []); | |||||
| const handleSelectAll = useCallback((checked: boolean) => { | |||||
| if (checked) { | |||||
| setCheckboxIds(filteredWarehouses.map(warehouse => warehouse.id)); | |||||
| setSelectAll(true); | |||||
| } else { | |||||
| setCheckboxIds([]); | |||||
| setSelectAll(false); | |||||
| } | |||||
| }, [filteredWarehouses]); | |||||
| const showPdfPreview = useCallback(async (warehouseIds: number[]) => { | |||||
| if (warehouseIds.length === 0) { | |||||
| return; | |||||
| } | |||||
| try { | |||||
| setIsUploading(true); | |||||
| const response = await exportWarehouseQrCode(warehouseIds); | |||||
| const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" }); | |||||
| const url = URL.createObjectURL(blob); | |||||
| setPreviewUrl(`${url}#toolbar=0`); | |||||
| setPreviewOpen(true); | |||||
| } catch (error) { | |||||
| console.error("Error exporting QR code:", error); | |||||
| } finally { | |||||
| setIsUploading(false); | |||||
| } | |||||
| }, [setIsUploading]); | |||||
| const handleClosePreview = useCallback(() => { | |||||
| setPreviewOpen(false); | |||||
| if (previewUrl) { | |||||
| URL.revokeObjectURL(previewUrl); | |||||
| setPreviewUrl(null); | |||||
| } | |||||
| }, [previewUrl]); | |||||
| const handleDownloadQrCode = useCallback(async (warehouseIds: number[]) => { | |||||
| if (warehouseIds.length === 0) { | |||||
| return; | |||||
| } | |||||
| try { | |||||
| setIsUploading(true); | |||||
| const response = await exportWarehouseQrCode(warehouseIds); | |||||
| downloadFile(response.blobValue, response.filename); | |||||
| setSelectedWarehousesModalOpen(false); | |||||
| successDialog("二維碼已下載", t); | |||||
| } catch (error) { | |||||
| console.error("Error exporting QR code:", error); | |||||
| } finally { | |||||
| setIsUploading(false); | |||||
| } | |||||
| }, [setIsUploading, t]); | |||||
| const handlePrint = useCallback(async () => { | |||||
| if (checkboxIds.length === 0) { | |||||
| return; | |||||
| } | |||||
| try { | |||||
| setIsUploading(true); | |||||
| const response = await exportWarehouseQrCode(checkboxIds); | |||||
| const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" }); | |||||
| const url = URL.createObjectURL(blob); | |||||
| const printWindow = window.open(url, '_blank'); | |||||
| if (printWindow) { | |||||
| printWindow.onload = () => { | |||||
| for (let i = 0; i < printQty; i++) { | |||||
| setTimeout(() => { | |||||
| printWindow.print(); | |||||
| }, i * 500); | |||||
| } | |||||
| }; | |||||
| } | |||||
| setTimeout(() => { | |||||
| URL.revokeObjectURL(url); | |||||
| }, 1000); | |||||
| setSelectedWarehousesModalOpen(false); | |||||
| successDialog("二維碼已列印", t); | |||||
| } catch (error) { | |||||
| console.error("Error printing QR code:", error); | |||||
| } finally { | |||||
| setIsUploading(false); | |||||
| } | |||||
| }, [checkboxIds, printQty, setIsUploading, t]); | |||||
| const handleViewSelectedQrCodes = useCallback(() => { | |||||
| if (checkboxIds.length === 0) { | |||||
| return; | |||||
| } | |||||
| setSelectedWarehousesModalOpen(true); | |||||
| }, [checkboxIds]); | |||||
| const selectedWarehouses = useMemo(() => { | |||||
| return warehouses.filter(warehouse => checkboxIds.includes(warehouse.id)); | |||||
| }, [warehouses, checkboxIds]); | |||||
| const handleCloseSelectedWarehousesModal = useCallback(() => { | |||||
| setSelectedWarehousesModalOpen(false); | |||||
| }, []); | |||||
| const columns = useMemo<Column<WarehouseResult>[]>( | |||||
| () => [ | |||||
| { | |||||
| name: "id", | |||||
| label: "", | |||||
| sx: { width: "50px", minWidth: "50px" }, | |||||
| renderCell: (params) => ( | |||||
| <Checkbox | |||||
| checked={checkboxIds.includes(params.id)} | |||||
| onChange={(e) => handleSelectWarehouse(params.id, e.target.checked)} | |||||
| onClick={(e) => e.stopPropagation()} | |||||
| /> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| name: "code", | |||||
| label: t("code"), | |||||
| align: "left", | |||||
| headerAlign: "left", | |||||
| sx: { width: "200px", minWidth: "200px" }, | |||||
| }, | |||||
| { | |||||
| name: "store_id", | |||||
| label: t("store_id"), | |||||
| align: "left", | |||||
| headerAlign: "left", | |||||
| sx: { width: "150px", minWidth: "150px" }, | |||||
| }, | |||||
| { | |||||
| name: "warehouse", | |||||
| label: t("warehouse"), | |||||
| align: "left", | |||||
| headerAlign: "left", | |||||
| sx: { width: "150px", minWidth: "150px" }, | |||||
| }, | |||||
| { | |||||
| name: "area", | |||||
| label: t("area"), | |||||
| align: "left", | |||||
| headerAlign: "left", | |||||
| sx: { width: "150px", minWidth: "150px" }, | |||||
| }, | |||||
| { | |||||
| name: "slot", | |||||
| label: t("slot"), | |||||
| align: "left", | |||||
| headerAlign: "left", | |||||
| sx: { width: "150px", minWidth: "150px" }, | |||||
| }, | |||||
| ], | |||||
| [t, checkboxIds, handleSelectWarehouse], | |||||
| ); | |||||
| return ( | |||||
| <> | |||||
| <Card> | |||||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||||
| <Typography variant="overline">{t("Search Criteria")}</Typography> | |||||
| <Box | |||||
| sx={{ | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| gap: 1, | |||||
| flexWrap: "nowrap", | |||||
| justifyContent: "flex-start", | |||||
| }} | |||||
| > | |||||
| <TextField | |||||
| label={t("store_id")} | |||||
| value={searchInputs.store_id} | |||||
| onChange={(e) => | |||||
| setSearchInputs((prev) => ({ ...prev, store_id: e.target.value })) | |||||
| } | |||||
| size="small" | |||||
| sx={{ width: "150px", minWidth: "120px" }} | |||||
| InputProps={{ | |||||
| endAdornment: ( | |||||
| <InputAdornment position="end">F</InputAdornment> | |||||
| ), | |||||
| }} | |||||
| /> | |||||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | |||||
| - | |||||
| </Typography> | |||||
| <TextField | |||||
| label={t("warehouse")} | |||||
| value={searchInputs.warehouse} | |||||
| onChange={(e) => | |||||
| setSearchInputs((prev) => ({ ...prev, warehouse: e.target.value })) | |||||
| } | |||||
| size="small" | |||||
| sx={{ width: "150px", minWidth: "120px" }} | |||||
| /> | |||||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | |||||
| - | |||||
| </Typography> | |||||
| <TextField | |||||
| label={t("area")} | |||||
| value={searchInputs.area} | |||||
| onChange={(e) => | |||||
| setSearchInputs((prev) => ({ ...prev, area: e.target.value })) | |||||
| } | |||||
| size="small" | |||||
| sx={{ width: "150px", minWidth: "120px" }} | |||||
| /> | |||||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | |||||
| - | |||||
| </Typography> | |||||
| <TextField | |||||
| label={t("slot")} | |||||
| value={searchInputs.slot} | |||||
| onChange={(e) => | |||||
| setSearchInputs((prev) => ({ ...prev, slot: e.target.value })) | |||||
| } | |||||
| size="small" | |||||
| sx={{ width: "150px", minWidth: "120px" }} | |||||
| /> | |||||
| </Box> | |||||
| <CardActions sx={{ justifyContent: "flex-start", px: 0, pt: 1 }}> | |||||
| <Button | |||||
| variant="text" | |||||
| startIcon={<RestartAlt />} | |||||
| onClick={handleReset} | |||||
| > | |||||
| {t("Reset")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Search />} | |||||
| onClick={handleSearch} | |||||
| > | |||||
| {t("Search")} | |||||
| </Button> | |||||
| </CardActions> | |||||
| </CardContent> | |||||
| </Card> | |||||
| <SearchResults<WarehouseResult> | |||||
| items={filteredWarehouses} | |||||
| columns={columns} | |||||
| pagingController={pagingController} | |||||
| setPagingController={setPagingController} | |||||
| totalCount={filteredWarehouses.length} | |||||
| isAutoPaging={true} | |||||
| /> | |||||
| <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 2 }}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| onClick={() => handleSelectAll(!selectAll)} | |||||
| startIcon={<Checkbox checked={selectAll} />} | |||||
| > | |||||
| 選擇全部倉庫 ({checkboxIds.length} / {filteredWarehouses.length}) | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={handleViewSelectedQrCodes} | |||||
| disabled={checkboxIds.length === 0} | |||||
| color="primary" | |||||
| > | |||||
| 查看已選擇倉庫二維碼 ({checkboxIds.length}) | |||||
| </Button> | |||||
| </Box> | |||||
| <Modal | |||||
| open={selectedWarehousesModalOpen} | |||||
| onClose={handleCloseSelectedWarehousesModal} | |||||
| sx={{ | |||||
| display: 'flex', | |||||
| alignItems: 'center', | |||||
| justifyContent: 'center', | |||||
| }} | |||||
| > | |||||
| <Card | |||||
| sx={{ | |||||
| position: 'relative', | |||||
| width: '90%', | |||||
| maxWidth: '800px', | |||||
| maxHeight: '90vh', | |||||
| display: 'flex', | |||||
| flexDirection: 'column', | |||||
| outline: 'none', | |||||
| }} | |||||
| > | |||||
| <Box | |||||
| sx={{ | |||||
| display: 'flex', | |||||
| justifyContent: 'space-between', | |||||
| alignItems: 'center', | |||||
| p: 2, | |||||
| borderBottom: 1, | |||||
| borderColor: 'divider', | |||||
| }} | |||||
| > | |||||
| <Typography variant="h6" component="h2"> | |||||
| 已選擇倉庫 ({selectedWarehouses.length}) | |||||
| </Typography> | |||||
| <IconButton onClick={handleCloseSelectedWarehousesModal}> | |||||
| <CloseIcon /> | |||||
| </IconButton> | |||||
| </Box> | |||||
| <Box | |||||
| sx={{ | |||||
| flex: 1, | |||||
| overflow: 'auto', | |||||
| p: 2, | |||||
| }} | |||||
| > | |||||
| <TableContainer component={Paper} variant="outlined"> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell> | |||||
| <strong>{t("code")}</strong> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <strong>{t("store_id")}</strong> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <strong>{t("warehouse")}</strong> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <strong>{t("area")}</strong> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <strong>{t("slot")}</strong> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {selectedWarehouses.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={5} align="center"> | |||||
| 沒有選擇的倉庫 | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| selectedWarehouses.map((warehouse) => ( | |||||
| <TableRow key={warehouse.id}> | |||||
| <TableCell>{warehouse.code || '-'}</TableCell> | |||||
| <TableCell>{warehouse.store_id || '-'}</TableCell> | |||||
| <TableCell>{warehouse.warehouse || '-'}</TableCell> | |||||
| <TableCell>{warehouse.area || '-'}</TableCell> | |||||
| <TableCell>{warehouse.slot || '-'}</TableCell> | |||||
| </TableRow> | |||||
| )) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| </Box> | |||||
| <Box | |||||
| sx={{ | |||||
| p: 2, | |||||
| borderTop: 1, | |||||
| borderColor: 'divider', | |||||
| bgcolor: 'background.paper', | |||||
| }} | |||||
| > | |||||
| <Stack direction="row" justifyContent="flex-end" alignItems="center" gap={2}> | |||||
| <Autocomplete<PrinterCombo> | |||||
| options={filteredPrinters} | |||||
| value={selectedPrinter ?? null} | |||||
| onChange={(event, value) => { | |||||
| setSelectedPrinter(value ?? undefined); | |||||
| }} | |||||
| getOptionLabel={(option) => option.name || option.label || option.code || String(option.id)} | |||||
| isOptionEqualToValue={(option, value) => option.id === value.id} | |||||
| renderInput={(params) => ( | |||||
| <TextField | |||||
| {...params} | |||||
| variant="outlined" | |||||
| label="列印機" | |||||
| sx={{ width: 300 }} | |||||
| /> | |||||
| )} | |||||
| /> | |||||
| <TextField | |||||
| variant="outlined" | |||||
| label="列印數量" | |||||
| type="number" | |||||
| value={printQty} | |||||
| onChange={(e) => { | |||||
| const value = parseInt(e.target.value) || 1; | |||||
| setPrintQty(Math.max(1, value)); | |||||
| }} | |||||
| inputProps={{ min: 1 }} | |||||
| sx={{ width: 120 }} | |||||
| /> | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<PrintIcon />} | |||||
| onClick={handlePrint} | |||||
| disabled={checkboxIds.length === 0 || filteredPrinters.length === 0} | |||||
| color="primary" | |||||
| > | |||||
| 列印 | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<DownloadIcon />} | |||||
| onClick={() => handleDownloadQrCode(checkboxIds)} | |||||
| disabled={checkboxIds.length === 0} | |||||
| color="primary" | |||||
| > | |||||
| 下載二維碼 | |||||
| </Button> | |||||
| </Stack> | |||||
| </Box> | |||||
| </Card> | |||||
| </Modal> | |||||
| <Modal | |||||
| open={previewOpen} | |||||
| onClose={handleClosePreview} | |||||
| sx={{ | |||||
| display: 'flex', | |||||
| alignItems: 'center', | |||||
| justifyContent: 'center', | |||||
| }} | |||||
| > | |||||
| <Card | |||||
| sx={{ | |||||
| position: 'relative', | |||||
| width: '90%', | |||||
| maxWidth: '900px', | |||||
| maxHeight: '90vh', | |||||
| display: 'flex', | |||||
| flexDirection: 'column', | |||||
| outline: 'none', | |||||
| }} | |||||
| > | |||||
| <Box | |||||
| sx={{ | |||||
| display: 'flex', | |||||
| justifyContent: 'flex-end', | |||||
| alignItems: 'center', | |||||
| p: 2, | |||||
| borderBottom: 1, | |||||
| borderColor: 'divider', | |||||
| }} | |||||
| > | |||||
| <IconButton | |||||
| onClick={handleClosePreview} | |||||
| > | |||||
| <CloseIcon /> | |||||
| </IconButton> | |||||
| </Box> | |||||
| <Box | |||||
| sx={{ | |||||
| flex: 1, | |||||
| overflow: 'auto', | |||||
| p: 2, | |||||
| }} | |||||
| > | |||||
| {previewUrl && ( | |||||
| <iframe | |||||
| src={previewUrl} | |||||
| width="100%" | |||||
| height="600px" | |||||
| style={{ | |||||
| border: 'none', | |||||
| }} | |||||
| title="PDF Preview" | |||||
| /> | |||||
| )} | |||||
| </Box> | |||||
| </Card> | |||||
| </Modal> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default QrCodeHandleWarehouseSearch; | |||||
| @@ -1,21 +0,0 @@ | |||||
| import React from "react"; | |||||
| import QrCodeHandleWarehouseSearch from "./qrCodeHandleWarehouseSearch"; | |||||
| import QrCodeHandleSearchLoading from "./qrCodeHandleSearchLoading"; | |||||
| import { fetchWarehouseList } from "@/app/api/warehouse"; | |||||
| import { fetchPrinterCombo } from "@/app/api/settings/printer"; | |||||
| interface SubComponents { | |||||
| Loading: typeof QrCodeHandleSearchLoading; | |||||
| } | |||||
| const QrCodeHandleWarehouseSearchWrapper: React.FC & SubComponents = async () => { | |||||
| const [warehouses, printerCombo] = await Promise.all([ | |||||
| fetchWarehouseList(), | |||||
| fetchPrinterCombo(), | |||||
| ]); | |||||
| return <QrCodeHandleWarehouseSearch warehouses={warehouses} printerCombo={printerCombo} />; | |||||
| }; | |||||
| QrCodeHandleWarehouseSearchWrapper.Loading = QrCodeHandleSearchLoading; | |||||
| export default QrCodeHandleWarehouseSearchWrapper; | |||||
| @@ -2,7 +2,7 @@ | |||||
| import { AuthOptions } from "next-auth"; | import { AuthOptions } from "next-auth"; | ||||
| import CredentialsProvider from "next-auth/providers/credentials"; | import CredentialsProvider from "next-auth/providers/credentials"; | ||||
| import { LOGIN_API_PATH } from "./api"; | import { LOGIN_API_PATH } from "./api"; | ||||
| import { Session } from "next-auth"; | |||||
| // Extend the built-in types | // Extend the built-in types | ||||
| declare module "next-auth" { | declare module "next-auth" { | ||||
| interface Session { | interface Session { | ||||
| @@ -98,10 +98,5 @@ export const authOptions: AuthOptions = { | |||||
| }, | }, | ||||
| }, | }, | ||||
| }; | }; | ||||
| export type SessionWithTokens = Session & { | |||||
| accessToken: string | null; | |||||
| refreshToken?: string; | |||||
| abilities: string[]; | |||||
| id?: string; | |||||
| }; | |||||
| export default authOptions; | export default authOptions; | ||||
| @@ -1,51 +0,0 @@ | |||||
| export type FieldType = 'date' | 'text' | 'select' | 'number'; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| export interface ReportField { | |||||
| label: string; | |||||
| name: string; | |||||
| type: FieldType; | |||||
| placeholder?: string; | |||||
| required: boolean; | |||||
| options?: { label: string; value: string }[]; // For select types | |||||
| } | |||||
| export interface ReportDefinition { | |||||
| id: string; | |||||
| title: string; | |||||
| apiEndpoint: string; | |||||
| fields: ReportField[]; | |||||
| } | |||||
| export const REPORTS: ReportDefinition[] = [ | |||||
| { | |||||
| id: "rep-001", | |||||
| title: "Report 1", | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-report1`, | |||||
| fields: [ | |||||
| { label: "From Date", name: "fromDate", type: "date", required: true }, // Mandatory | |||||
| { label: "To Date", name: "toDate", type: "date", required: true }, // Mandatory | |||||
| { label: "Item Code", name: "itemCode", type: "text", required: false, placeholder: "e.g. FG"}, | |||||
| { label: "Item Type", name: "itemType", type: "select", required: false, | |||||
| options: [ | |||||
| { label: "FG", value: "FG" }, | |||||
| { label: "Material", value: "Mat" } | |||||
| ] }, | |||||
| ] | |||||
| }, | |||||
| { | |||||
| id: "rep-002", | |||||
| title: "Report 2", | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-report2`, | |||||
| fields: [ | |||||
| { label: "Target Date", name: "targetDate", type: "date", required: false }, | |||||
| { label: "Item Code", name: "itemCode", type: "text", required: false }, | |||||
| { label: "Shift", name: "shift", type: "select", options: [ | |||||
| { label: "Day", value: "D" }, | |||||
| { label: "Night", value: "N" } | |||||
| ], required: false} | |||||
| ] | |||||
| }, | |||||
| // Add Report 3 to 10 following the same pattern... | |||||
| ]; | |||||
| @@ -26,9 +26,6 @@ | |||||
| "Shop added to truck lane successfully": "Shop added to truck lane successfully", | "Shop added to truck lane successfully": "Shop added to truck lane successfully", | ||||
| "Failed to create shop in truck lane": "Failed to create shop in truck lane", | "Failed to create shop in truck lane": "Failed to create shop in truck lane", | ||||
| "Add Shop": "Add Shop", | "Add Shop": "Add Shop", | ||||
| "Shop Name": "Shop Name", | |||||
| "Shop Branch": "Shop Branch", | |||||
| "Shop Code": "Shop Code", | |||||
| "Search or select shop name": "Search or select shop name", | "Search or select shop name": "Search or select shop name", | ||||
| "Search or select shop code": "Search or select shop code", | "Search or select shop code": "Search or select shop code", | ||||
| "Search or select remark": "Search or select remark", | "Search or select remark": "Search or select remark", | ||||
| @@ -1,76 +1 @@ | |||||
| { | |||||
| "Dashboard": "Dashboard", | |||||
| "Order status": "Order status", | |||||
| "pending": "pending", | |||||
| "receiving": "receiving", | |||||
| "total": "total", | |||||
| "Warehouse temperature record": "Warehouse temperature record", | |||||
| "Warehouse type": "Warehouse type", | |||||
| "Last 6 hours": "Last 6 hours", | |||||
| "Add some entries!": "Add some entries!", | |||||
| "Last 24 hours": "Last 24 hours", | |||||
| "Cold storage": "Cold storage", | |||||
| "Normal temperature storage": "Normal temperature storage", | |||||
| "Temperature status": "Temperature status", | |||||
| "Humidity status": "Humidity status", | |||||
| "Warehouse status": "Warehouse status", | |||||
| "Progress chart": "Progress chart", | |||||
| "Purchase Order Code": "Purchase Order Code", | |||||
| "Item Name": "Item Name", | |||||
| "Escalation Level": "Escalation Level", | |||||
| "Reason": "Reason", | |||||
| "escalated date": "escalated date", | |||||
| "Order completion": "Order completion", | |||||
| "Store Management": "Store Management", | |||||
| "Consumable": "Consumable", | |||||
| "Shipment": "Shipment", | |||||
| "Extracted order": "Extracted order", | |||||
| "Pending order": "Pending order", | |||||
| "Temperature": "Temperature", | |||||
| "Humidity": "Humidity", | |||||
| "Pending storage": "Pending storage", | |||||
| "Total storage": "Total storage", | |||||
| "Application completion": "Application completion", | |||||
| "Processed application": "Processed application", | |||||
| "Pending application": "Pending application", | |||||
| "pending inspection material": "pending inspection material", | |||||
| "rejected": "rejected", | |||||
| "accepted": "accepted", | |||||
| "escalated": "escalated", | |||||
| "inspected material": "inspected material", | |||||
| "total material": "total material", | |||||
| "stock in escalation list": "stock in escalation list", | |||||
| "Responsible for handling colleagues": "Responsible for handling colleagues", | |||||
| "Completed QC Total": "Completed QC Total", | |||||
| "QC Fail Count": "QC Fail Count", | |||||
| "DN Date": "DN Date", | |||||
| "Received Qty": "Received Qty", | |||||
| "Po Code": "Po Code", | |||||
| "My Escalation List": "My Escalation List", | |||||
| "Escalation List": "Escalation List", | |||||
| "Purchase UoM": "Purchase UoM", | |||||
| "QC Completed Count": "QC Completed Count", | |||||
| "QC Fail-Total Count": "QC Fail-Total Count", | |||||
| "escalationStatus": "escalationStatus", | |||||
| "escalated datetime": "escalated datetime", | |||||
| "escalateFrom": "escalateFrom", | |||||
| "No": "No", | |||||
| "Responsible Escalation List": "Responsible Escalation List", | |||||
| "show completed logs": "show completed logs", | |||||
| "Rows per page": "Rows per page", | |||||
| "Truck Schedule Dashboard": "Truck Schedule Dashboard", | |||||
| "Store ID": "Store ID", | |||||
| "All Stores": "All Stores", | |||||
| "Auto-refresh every 5 minutes": "Auto-refresh every 5 minutes", | |||||
| "Last updated": "Last updated", | |||||
| "Truck Schedule": "Truck Schedule", | |||||
| "Time Remaining": "Time Remaining", | |||||
| "No. of Shops": "No. of Shops", | |||||
| "Total Items": "Total Items", | |||||
| "Tickets Released": "Tickets Released", | |||||
| "First Ticket Start": "First Ticket Start", | |||||
| "Tickets Completed": "Tickets Completed", | |||||
| "Last Ticket End": "Last Ticket End", | |||||
| "Pick Time (min)": "Pick Time (min)", | |||||
| "No truck schedules available for today": "No truck schedules available for today" | |||||
| } | |||||
| {} | |||||
| @@ -4,7 +4,7 @@ | |||||
| "Job Order Production Process": "工單生產流程", | "Job Order Production Process": "工單生產流程", | ||||
| "productionProcess": "生產流程", | "productionProcess": "生產流程", | ||||
| "Search Criteria": "搜尋條件", | "Search Criteria": "搜尋條件", | ||||
| "Stock Record": "庫存記錄", | |||||
| "All": "全部", | |||||
| "No options": "沒有選項", | "No options": "沒有選項", | ||||
| "Select Another Bag Lot": "選擇另一個包裝袋", | "Select Another Bag Lot": "選擇另一個包裝袋", | ||||
| "Finished QC Job Orders": "完成QC工單", | "Finished QC Job Orders": "完成QC工單", | ||||
| @@ -28,13 +28,6 @@ | |||||
| "Total finished QC job orders": "總完成QC工單數量", | "Total finished QC job orders": "總完成QC工單數量", | ||||
| "Over Time": "超時", | "Over Time": "超時", | ||||
| "Code": "編號", | "Code": "編號", | ||||
| "Job Order No.": "工單編號", | |||||
| "FG / WIP Item": "成品/半成品", | |||||
| "Production Time Remaining": "生產剩餘時間", | |||||
| "Process": "工序", | |||||
| "Start": "開始", | |||||
| "Finish": "完成", | |||||
| "Wait Time [minutes]": "等待時間(分鐘)", | |||||
| "Staff No": "員工編號", | "Staff No": "員工編號", | ||||
| "code": "編號", | "code": "編號", | ||||
| "Name": "名稱", | "Name": "名稱", | ||||
| @@ -48,13 +41,6 @@ | |||||
| "No": "沒有", | "No": "沒有", | ||||
| "Assignment failed: ": "分配失敗: ", | "Assignment failed: ": "分配失敗: ", | ||||
| "Unknown error": "未知錯誤", | "Unknown error": "未知錯誤", | ||||
| "Job Process Status": "工單流程狀態", | |||||
| "Total Time": "總時間", | |||||
| "Remaining Time": "剩餘時間", | |||||
| "Wait Time": "等待時間", | |||||
| "Wait Time [minutes]": "等待時間(分鐘)", | |||||
| "End Time": "完成時間", | |||||
| "WIP": "半成品", | "WIP": "半成品", | ||||
| "R&D": "研發", | "R&D": "研發", | ||||
| "STF": "樣品", | "STF": "樣品", | ||||
| @@ -322,7 +308,6 @@ | |||||
| "ShopAndTruck": "店鋪路線管理", | "ShopAndTruck": "店鋪路線管理", | ||||
| "Shop Information": "店鋪資訊", | "Shop Information": "店鋪資訊", | ||||
| "Shop Name": "店鋪名稱", | "Shop Name": "店鋪名稱", | ||||
| "Shop Branch": "店鋪分店", | |||||
| "Shop Code": "店鋪編號", | "Shop Code": "店鋪編號", | ||||
| "Truck Lane": "卡車路線", | "Truck Lane": "卡車路線", | ||||
| "Truck Lane Detail": "卡車路線詳情", | "Truck Lane Detail": "卡車路線詳情", | ||||
| @@ -57,20 +57,5 @@ | |||||
| "No": "無", | "No": "無", | ||||
| "Responsible Escalation List": "負責的上報列表", | "Responsible Escalation List": "負責的上報列表", | ||||
| "show completed logs": "顯示已完成上報", | "show completed logs": "顯示已完成上報", | ||||
| "Rows per page": "每頁行數", | |||||
| "Truck Schedule Dashboard": "車輛調度儀表板", | |||||
| "Store ID": "樓層", | |||||
| "All Stores": "所有樓層", | |||||
| "Auto-refresh every 5 minutes": "每5分鐘自動刷新", | |||||
| "Last updated": "最後更新", | |||||
| "Truck Schedule": "車輛班次", | |||||
| "Time Remaining": "剩餘時間", | |||||
| "No. of Shops": "門店數量", | |||||
| "Total Items": "總貨品數", | |||||
| "Tickets Released": "已發放成品出倉單", | |||||
| "First Ticket Start": "首單開始時間", | |||||
| "Tickets Completed": "已完成成品出倉單", | |||||
| "Last Ticket End": "末單結束時間", | |||||
| "Pick Time (min)": "揀貨時間(分鐘)", | |||||
| "No truck schedules available for today": "今日無車輛調度計劃" | |||||
| "Rows per page": "每頁行數" | |||||
| } | } | ||||
| @@ -10,23 +10,16 @@ | |||||
| "fg": "成品", | "fg": "成品", | ||||
| "Back to List": "返回列表", | "Back to List": "返回列表", | ||||
| "Record Status": "記錄狀態", | "Record Status": "記錄狀態", | ||||
| "Stock take record status updated to not match": "盤點記錄狀態更新為數值不符", | |||||
| "available": "可用", | "available": "可用", | ||||
| "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | ||||
| "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | |||||
| "not available": "不可用", | "not available": "不可用", | ||||
| "Batch Submit All": "批量提交所有", | "Batch Submit All": "批量提交所有", | ||||
| "Batch Save All": "批量保存所有", | "Batch Save All": "批量保存所有", | ||||
| "Batch Submit All": "批量提交所有", | |||||
| "Batch Save All": "批量保存所有", | |||||
| "not match": "數值不符", | "not match": "數值不符", | ||||
| "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數", | "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數", | ||||
| "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數", | |||||
| "View ReStockTake": "查看重新盤點", | "View ReStockTake": "查看重新盤點", | ||||
| "Stock Take Qty": "盤點數", | "Stock Take Qty": "盤點數", | ||||
| "Stock Take Qty": "盤點數", | |||||
| "ReStockTake": "重新盤點", | "ReStockTake": "重新盤點", | ||||
| "Stock Taker": "盤點員", | "Stock Taker": "盤點員", | ||||
| "Total Item Number": "貨品數量", | "Total Item Number": "貨品數量", | ||||
| @@ -38,17 +31,6 @@ | |||||
| "start time": "開始時間", | "start time": "開始時間", | ||||
| "end time": "結束時間", | "end time": "結束時間", | ||||
| "Control Time": "操作時間", | "Control Time": "操作時間", | ||||
| "Stock Taker": "盤點員", | |||||
| "Total Item Number": "貨品數量", | |||||
| "Start Time": "開始時間", | |||||
| "Difference": "差異", | |||||
| "stockTaking": "盤點中", | |||||
| "selected stock take qty": "已選擇盤點數量", | |||||
| "book qty": "帳面庫存", | |||||
| "start time": "開始時間", | |||||
| "end time": "結束時間", | |||||
| "Only Variance": "僅差異", | |||||
| "Control Time": "操作時間", | |||||
| "pass": "通過", | "pass": "通過", | ||||
| "not pass": "不通過", | "not pass": "不通過", | ||||
| "Available": "可用", | "Available": "可用", | ||||
| @@ -57,7 +39,6 @@ | |||||
| "Last Stock Take Date": "上次盤點日期", | "Last Stock Take Date": "上次盤點日期", | ||||
| "Remark": "備註", | "Remark": "備註", | ||||
| "notMatch": "數值不符", | "notMatch": "數值不符", | ||||
| "notMatch": "數值不符", | |||||
| "Stock take record saved successfully": "盤點記錄保存成功", | "Stock take record saved successfully": "盤點記錄保存成功", | ||||
| "View Details": "查看詳細", | "View Details": "查看詳細", | ||||
| "Input": "輸入", | "Input": "輸入", | ||||
| @@ -168,16 +149,14 @@ | |||||
| "Stock take adjustment has been confirmed successfully!": "盤點調整確認成功!", | "Stock take adjustment has been confirmed successfully!": "盤點調整確認成功!", | ||||
| "System Qty": "系統數量", | "System Qty": "系統數量", | ||||
| "Variance": "差異", | "Variance": "差異", | ||||
| "Stock Record": "庫存記錄", | |||||
| "Item-lotNo": "貨品-批號", | |||||
| "In Qty": "入庫數量", | |||||
| "Out Qty": "出庫數量", | |||||
| "Balance Qty": "庫存數量", | |||||
| "Start Date": "開始日期", | |||||
| "End Date": "結束日期", | |||||
| "Loading": "加載中", | |||||
| "adj": "調整", | |||||
| "nor": "正常" | |||||
| "Print QR Code": "打印標籤", | |||||
| "Download QR Code": "下載標籤", | |||||
| "Stock Transfer": "轉倉", | |||||
| "Start Location": "原倉庫", | |||||
| "Target Location": "目標倉庫", | |||||
| "Remaining Qty": "剩餘庫存", | |||||
| "Original Qty": "原有庫存", | |||||
| "Qty To Be Transferred": "需轉移數量", | |||||
| "to": "至", | |||||
| "Submit": "提交" | |||||
| } | } | ||||
| @@ -4,14 +4,11 @@ | |||||
| "Edit Job Order Detail": "工單詳情", | "Edit Job Order Detail": "工單詳情", | ||||
| "Details": "細節", | "Details": "細節", | ||||
| "Actions": "操作", | "Actions": "操作", | ||||
| "Process": "工序", | |||||
| "Create Job Order": "建立工單", | "Create Job Order": "建立工單", | ||||
| "Code": "工單編號", | "Code": "工單編號", | ||||
| "Name": "成品/半成品名稱", | "Name": "成品/半成品名稱", | ||||
| "Picked Qty": "已提料數量", | "Picked Qty": "已提料數量", | ||||
| "Confirm All": "確認所有提料", | "Confirm All": "確認所有提料", | ||||
| "Wait Time [minutes]": "等待時間(分鐘)", | |||||
| "Job Process Status": "工單流程狀態", | |||||
| "Search Job Order/ Create Job Order":"搜尋工單/建立工單", | "Search Job Order/ Create Job Order":"搜尋工單/建立工單", | ||||
| "UoM": "銷售單位", | "UoM": "銷售單位", | ||||
| "Select Another Bag Lot":"選擇另一個包裝袋", | "Select Another Bag Lot":"選擇另一個包裝袋", | ||||
| @@ -104,13 +101,6 @@ | |||||
| "Job Order Pickexcution": "工單提料", | "Job Order Pickexcution": "工單提料", | ||||
| "Pick Order Detail": "提料單細節", | "Pick Order Detail": "提料單細節", | ||||
| "Finished Job Order Record": "已完成工單記錄", | "Finished Job Order Record": "已完成工單記錄", | ||||
| "No. of Items to be Picked": "需提料數量", | |||||
| "No. of Items with Issue During Pick": "提料過程中出現問題的數量", | |||||
| "Pick Start Time": "提料開始時間", | |||||
| "Pick End Time": "提料結束時間", | |||||
| "FG / WIP Item": "成品/半成品", | |||||
| "Pick Order No.- Job Order No.- Item": "提料單編號-工單編號-成品/半成品", | |||||
| "Pick Time Taken (minutes)": "提料時間(分鐘)", | |||||
| "Index": "編號", | "Index": "編號", | ||||
| "Route": "路線", | "Route": "路線", | ||||
| "Qty": "數量", | "Qty": "數量", | ||||
| @@ -527,13 +517,6 @@ | |||||
| "Start Scan": "開始掃碼", | "Start Scan": "開始掃碼", | ||||
| "Stop Scan": "停止掃碼", | "Stop Scan": "停止掃碼", | ||||
| "Material Pick Status": "物料提料狀態", | |||||
| "Job Order Qty": "工單數量", | |||||
| "Sign out": "登出", | |||||
| "Job Order No.": "工單編號", | |||||
| "FG / WIP Item": "成品/半成品", | |||||
| "Production Time Remaining": "生產剩餘時間", | |||||
| "Process": "工序", | |||||
| "Start": "開始", | |||||
| "Finish": "完成" | |||||
| "Sign out": "登出" | |||||
| } | } | ||||
| @@ -1,4 +1,5 @@ | |||||
| import { NextRequestWithAuth, withAuth } from "next-auth/middleware"; | import { NextRequestWithAuth, withAuth } from "next-auth/middleware"; | ||||
| // import { authOptions } from "@/config/authConfig"; | |||||
| import { authOptions } from "./config/authConfig"; | import { authOptions } from "./config/authConfig"; | ||||
| import { NextFetchEvent, NextResponse } from "next/server"; | import { NextFetchEvent, NextResponse } from "next/server"; | ||||
| import { PRIVATE_ROUTES } from "./routes"; | import { PRIVATE_ROUTES } from "./routes"; | ||||
| @@ -9,14 +10,15 @@ const authMiddleware = withAuth({ | |||||
| pages: authOptions.pages, | pages: authOptions.pages, | ||||
| callbacks: { | callbacks: { | ||||
| authorized: ({ req, token }) => { | authorized: ({ req, token }) => { | ||||
| const currentTime = Math.floor(Date.now() / 1000); | |||||
| // Redirect to login if: | |||||
| // 1. No token exists | |||||
| // 2. Token has an expiry field (exp) and current time has passed it | |||||
| if (!token || (token.exp && currentTime > (token.exp as number))) { | |||||
| return false; | |||||
| if (!Boolean(token)) { | |||||
| return Boolean(token); | |||||
| } | } | ||||
| // example | |||||
| // const abilities = token!.abilities as string[] | |||||
| // if (req.nextUrl.pathname.endsWith('/user') && 'abilities dont hv view/maintain user') { | |||||
| // return false | |||||
| // } | |||||
| return true; | return true; | ||||
| }, | }, | ||||
| }, | }, | ||||
| @@ -26,9 +28,9 @@ export default async function middleware( | |||||
| req: NextRequestWithAuth, | req: NextRequestWithAuth, | ||||
| event: NextFetchEvent, | event: NextFetchEvent, | ||||
| ) { | ) { | ||||
| // Handle language parameters | |||||
| const langPref = req.nextUrl.searchParams.get(LANG_QUERY_PARAM); | const langPref = req.nextUrl.searchParams.get(LANG_QUERY_PARAM); | ||||
| if (langPref) { | if (langPref) { | ||||
| // Redirect to same url without the lang query param + set cookies | |||||
| const newUrl = new URL(req.nextUrl); | const newUrl = new URL(req.nextUrl); | ||||
| newUrl.searchParams.delete(LANG_QUERY_PARAM); | newUrl.searchParams.delete(LANG_QUERY_PARAM); | ||||
| const response = NextResponse.redirect(newUrl); | const response = NextResponse.redirect(newUrl); | ||||
| @@ -36,19 +38,8 @@ export default async function middleware( | |||||
| return response; | return response; | ||||
| } | } | ||||
| // Check if the current URL starts with any string in PRIVATE_ROUTES | |||||
| const isPrivateRoute = PRIVATE_ROUTES.some((route) => | |||||
| req.nextUrl.pathname.startsWith(route) | |||||
| ); | |||||
| // Debugging: View terminal logs to see if the path is being caught | |||||
| if (req.nextUrl.pathname.startsWith("/ps") || req.nextUrl.pathname.startsWith("/testing")) { | |||||
| console.log("--- Middleware Check ---"); | |||||
| console.log("Path:", req.nextUrl.pathname); | |||||
| console.log("Is Private Match:", isPrivateRoute); | |||||
| } | |||||
| return isPrivateRoute | |||||
| ? await authMiddleware(req, event) // Run authentication check | |||||
| : NextResponse.next(); // Allow public access | |||||
| } | |||||
| // Matcher for using the auth middleware | |||||
| return PRIVATE_ROUTES.some((route) => req.nextUrl.pathname.startsWith(route)) | |||||
| ? await authMiddleware(req, event) // Let auth middleware handle response | |||||
| : NextResponse.next(); // Return normal response | |||||
| } | |||||
| @@ -2,9 +2,6 @@ export const PRIVATE_ROUTES = [ | |||||
| "/analytics", | "/analytics", | ||||
| "/dashboard", | "/dashboard", | ||||
| "/dashboard", | "/dashboard", | ||||
| "/testing", | |||||
| "/ps", | |||||
| "/report", | |||||
| "/invoice", | "/invoice", | ||||
| "/projects", | "/projects", | ||||
| "/tasks", | "/tasks", | ||||