kelvin.yau 3 тижднів тому
джерело
коміт
15c42b8334
50 змінених файлів з 1257 додано та 4118 видалено
  1. +0
    -167
      src/app/(main)/report/page.tsx
  2. +1
    -9
      src/app/(main)/settings/qrCodeHandle/page.tsx
  3. +0
    -25
      src/app/(main)/stockRecord/page.tsx
  4. +0
    -24
      src/app/api/do/actions.tsx
  5. +0
    -16
      src/app/api/do/client.ts
  6. +1
    -60
      src/app/api/jo/actions.ts
  7. +1
    -16
      src/app/api/settings/item/actions.ts
  8. +0
    -1
      src/app/api/settings/item/index.ts
  9. +19
    -87
      src/app/api/stockTake/actions.ts
  10. +6
    -14
      src/app/api/warehouse/client.ts
  11. +2
    -1
      src/components/CreateItem/CreateItem.tsx
  12. +2
    -13
      src/components/CreateItem/CreateItemWrapper.tsx
  13. +1
    -8
      src/components/CreateItem/ProductDetails.tsx
  14. +0
    -8
      src/components/DashboardPage/DashboardPage.tsx
  15. +0
    -397
      src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx
  16. +0
    -3
      src/components/DashboardPage/truckSchedule/index.ts
  17. +1
    -0
      src/components/EquipmentSearch/EquipmentSearchLoading.tsx
  18. +7
    -1
      src/components/EquipmentSearch/EquipmentSearchResults.tsx
  19. +1
    -0
      src/components/EquipmentTypeSearch/EquipmentTypeSearchLoading.tsx
  20. +279
    -10
      src/components/InventorySearch/InventoryLotLineTable.tsx
  21. +53
    -61
      src/components/ItemsSearch/ItemsSearch.tsx
  22. +0
    -3
      src/components/Jodetail/JodetailSearch.tsx
  23. +0
    -381
      src/components/Jodetail/MaterialPickStatusTable.tsx
  24. +14
    -35
      src/components/NavigationContent/NavigationContent.tsx
  25. +0
    -324
      src/components/ProductionProcess/JobProcessStatus.tsx
  26. +1
    -1
      src/components/ProductionProcess/ProductionProcessList.tsx
  27. +0
    -5
      src/components/ProductionProcess/ProductionProcessPage.tsx
  28. +2
    -22
      src/components/Shop/TruckLaneDetail.tsx
  29. +17
    -19
      src/components/StockIn/FgStockInForm.tsx
  30. +0
    -444
      src/components/StockRecord/SearchPage.tsx
  31. +0
    -26
      src/components/StockRecord/index.tsx
  32. +17
    -1
      src/components/StockTakeManagement/ApproverCardList.tsx
  33. +253
    -381
      src/components/StockTakeManagement/ApproverStockTake.tsx
  34. +17
    -1
      src/components/StockTakeManagement/PickerCardList.tsx
  35. +227
    -255
      src/components/StockTakeManagement/PickerReStockTake.tsx
  36. +282
    -343
      src/components/StockTakeManagement/PickerStockTake.tsx
  37. +20
    -0
      src/components/WarehouseHandle/WarehouseHandle.tsx
  38. +1
    -14
      src/components/qrCodeHandles/qrCodeHandleTabs.tsx
  39. +0
    -675
      src/components/qrCodeHandles/qrCodeHandleWarehouseSearch.tsx
  40. +0
    -21
      src/components/qrCodeHandles/qrCodeHandleWarehouseSearchWrapper.tsx
  41. +2
    -7
      src/config/authConfig.ts
  42. +0
    -51
      src/config/reportConfig.ts
  43. +0
    -3
      src/i18n/en/common.json
  44. +1
    -76
      src/i18n/en/dashboard.json
  45. +1
    -16
      src/i18n/zh/common.json
  46. +1
    -16
      src/i18n/zh/dashboard.json
  47. +10
    -31
      src/i18n/zh/inventory.json
  48. +2
    -19
      src/i18n/zh/jo.json
  49. +15
    -24
      src/middleware.ts
  50. +0
    -3
      src/routes.ts

+ 0
- 167
src/app/(main)/report/page.tsx Переглянути файл

@@ -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>
);
}

+ 1
- 9
src/app/(main)/settings/qrCodeHandle/page.tsx Переглянути файл

@@ -4,7 +4,6 @@ import Typography from "@mui/material/Typography";
import { getServerI18n } from "@/i18n";
import QrCodeHandleSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleSearchWrapper";
import QrCodeHandleEquipmentSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleEquipmentSearchWrapper";
import QrCodeHandleWarehouseSearchWrapper from "@/components/qrCodeHandles/qrCodeHandleWarehouseSearchWrapper";
import QrCodeHandleTabs from "@/components/qrCodeHandles/qrCodeHandleTabs";
import { I18nProvider } from "@/i18n";
import Box from "@mui/material/Box";
@@ -20,7 +19,7 @@ const QrCodeHandlePage: React.FC = async () => {
{t("QR Code Handle")}
</Typography>
<I18nProvider namespaces={["common", "user", "warehouse"]}>
<I18nProvider namespaces={["common", "user"]}>
<QrCodeHandleTabs
userTabContent={
<Suspense fallback={<QrCodeHandleSearchWrapper.Loading />}>
@@ -36,13 +35,6 @@ const QrCodeHandlePage: React.FC = async () => {
</I18nProvider>
</Suspense>
}
warehouseTabContent={
<Suspense fallback={<QrCodeHandleWarehouseSearchWrapper.Loading />}>
<I18nProvider namespaces={["warehouse", "common", "dashboard"]}>
<QrCodeHandleWarehouseSearchWrapper />
</I18nProvider>
</Suspense>
}
/>
</I18nProvider>
</Box>


+ 0
- 25
src/app/(main)/stockRecord/page.tsx Переглянути файл

@@ -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;

+ 0
- 24
src/app/api/do/actions.tsx Переглянути файл

@@ -131,21 +131,6 @@ export interface getTicketReleaseTable {
handlerName: string | null;
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 {
code: 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 }) => {
const { doId, userId } = data;
return await serverFetchJson<{ id: number|null; code: string; entity?: any }>(


+ 0
- 16
src/app/api/do/client.ts Переглянути файл

@@ -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;



+ 1
- 60
src/app/api/jo/actions.ts Переглянути файл

@@ -724,7 +724,6 @@ export const fetchAllJoborderProductProcessInfo = cache(async () => {
}
);
});

/*
export const updateProductProcessLineQty = async (request: UpdateProductProcessLineQtyRequest) => {
return serverFetchJson<UpdateProductProcessLineQtyResponse>(
@@ -1168,62 +1167,4 @@ export const updateProductProcessLineProcessingTimeSetupTimeChangeoverTime = asy
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"] },
}
);
});

;
};

+ 1
- 16
src/app/api/settings/item/actions.ts Переглянути файл

@@ -4,7 +4,7 @@ import {
serverFetchJson,
serverFetchWithNoContent,
} from "@/app/utils/fetchUtil";
import { revalidateTag, revalidatePath } from "next/cache";
import { revalidateTag } from "next/cache";
import { BASE_API_URL } from "@/config/api";
import { CreateItemResponse, RecordsRes } from "../../utils";
import { ItemQc, ItemsResult } from ".";
@@ -60,21 +60,6 @@ export const saveItem = async (data: CreateItemInputs) => {
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 {
id: number,
label: string,


+ 0
- 1
src/app/api/settings/item/index.ts Переглянути файл

@@ -58,7 +58,6 @@ export type ItemsResult = {
area?: string | undefined;
slot?: string | undefined;
LocationCode?: string | undefined;
locationCode?: string | undefined; // Backend may return lowercase version
isEgg?: boolean | undefined;
isFee?: boolean | undefined;
isBag?: boolean | undefined;


+ 19
- 87
src/app/api/stockTake/actions.ts Переглянути файл

@@ -3,11 +3,6 @@
import { cache } from 'react';
import { serverFetchJson } from "@/app/utils/fetchUtil"; // 改为 serverFetchJson
import { BASE_API_URL } from "@/config/api";

export interface RecordsRes<T> {
records: T[];
total: number;
}
export interface InventoryLotDetailResponse {
id: number;
inventoryLotId: number;
@@ -44,34 +39,30 @@ export interface InventoryLotDetailResponse {

export const getInventoryLotDetailsBySection = async (
stockTakeSection: string,
stockTakeId?: number | null,
pageNum?: number,
pageSize?: number
stockTakeId?: number | null
) => {
console.log('🌐 [API] getInventoryLotDetailsBySection called with:', {
stockTakeSection,
stockTakeId,
pageNum,
pageSize
stockTakeId
});
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) {
url += `&stockTakeId=${stockTakeId}`;
}
console.log(' [API] Full URL:', url);
const response = await serverFetchJson<RecordsRes<InventoryLotDetailResponse>>(
const details = await serverFetchJson<InventoryLotDetailResponse[]>(
url,
{
method: "GET",
},
);
console.log('[API] Response received:', response);
return response;
console.log('[API] Response received:', details);
return details;
}
export interface SaveStockTakeRecordRequest {
stockTakeRecordId?: number | null;
@@ -109,7 +100,6 @@ export const importStockTake = async (data: FormData) => {
}

export const getStockTakeRecords = async () => {
const stockTakeRecords = await serverFetchJson<AllPickedStockTakeListReponse[]>( // 改为 serverFetchJson
`${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList`,
{
@@ -287,86 +277,28 @@ export const updateStockTakeRecordStatusToNotMatch = async (

export const getInventoryLotDetailsBySectionNotMatch = async (
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) {
url += `&stockTakeId=${stockTakeId}`;
}
const response = await serverFetchJson<RecordsRes<InventoryLotDetailResponse>>(
console.log(' [API] Full URL:', url);
const details = await serverFetchJson<InventoryLotDetailResponse[]>(
url,
{
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;
}

+ 6
- 14
src/app/api/warehouse/client.ts Переглянути файл

@@ -3,31 +3,23 @@
import { NEXT_PUBLIC_API_URL } from "@/config/api";
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 response = await fetch(`${NEXT_PUBLIC_API_URL}/warehouse/export-qrcode`, {
method: "POST",
const response = await fetch(`${NEXT_PUBLIC_API_URL}/warehouse`, {
method: "GET",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
body: JSON.stringify({ warehouseIds }),
});

if (!response.ok) {
if (response.status === 401) {
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();
};

+ 2
- 1
src/components/CreateItem/CreateItem.tsx Переглянути файл

@@ -159,8 +159,9 @@ const CreateItem: React.FC<Props> = ({
console.log(qcCheck);
// return
// do api
console.log("asdad");
const responseI = await saveItem(data);
console.log("asdad");
const responseQ = await saveItemQcChecks(qcCheck);
if (responseI && responseQ) {
if (!Boolean(responseI.id)) {


+ 2
- 13
src/components/CreateItem/CreateItemWrapper.tsx Переглянути файл

@@ -26,18 +26,7 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => {
const item = result.item;
qcChecks = result.qcChecks;
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 = {
type: item?.type,
id: item?.id,
@@ -55,7 +44,7 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => {
warehouse: item?.warehouse,
area: item?.area,
slot: item?.slot,
LocationCode: locationCode,
LocationCode: item?.LocationCode,
isEgg: item?.isEgg,
isFee: item?.isFee,
isBag: item?.isBag,


+ 1
- 8
src/components/CreateItem/ProductDetails.tsx Переглянути файл

@@ -23,7 +23,7 @@ import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
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 { InputDataGridProps, TableRow } from "../InputDataGrid/InputDataGrid";
import { TypeEnum } from "@/app/utils/typeEnum";
@@ -114,13 +114,6 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous
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 (
<Card sx={{ display: "block" }}>
<CardContent component={Stack} spacing={4}>


+ 0
- 8
src/components/DashboardPage/DashboardPage.tsx Переглянути файл

@@ -17,7 +17,6 @@ import CollapsibleCard from "../CollapsibleCard";
// import SupervisorQcApproval, { IQCItems } from "./QC/SupervisorQcApproval";
import { EscalationResult } from "@/app/api/escalation";
import EscalationLogTable from "./escalation/EscalationLogTable";
import { TruckScheduleDashboard } from "./truckSchedule";
type Props = {
// iqc: IQCItems[] | undefined
escalationLogs: EscalationResult[]
@@ -43,13 +42,6 @@ const DashboardPage: React.FC<Props> = ({
return (
<ThemeProvider theme={theme}>
<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}>
<CollapsibleCard
title={`${t("Responsible Escalation List")} (${t("pending")} : ${


+ 0
- 397
src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx Переглянути файл

@@ -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;

+ 0
- 3
src/components/DashboardPage/truckSchedule/index.ts Переглянути файл

@@ -1,3 +0,0 @@
export { default as TruckScheduleDashboard } from './TruckScheduleDashboard';



+ 1
- 0
src/components/EquipmentSearch/EquipmentSearchLoading.tsx Переглянути файл

@@ -6,6 +6,7 @@ import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import React from "react";

// Can make this nicer
export const EquipmentTypeSearchLoading: React.FC = () => {
return (
<>


+ 7
- 1
src/components/EquipmentSearch/EquipmentSearchResults.tsx Переглянути файл

@@ -139,6 +139,7 @@ function isCheckboxColumn<T extends ResultWithId>(
return column.type === "checkbox";
}

// Icon Component Functions
function convertObjectKeysToLowercase<T extends object>(
obj: T,
): object | undefined {
@@ -206,6 +207,7 @@ function EquipmentSearchResults<T extends ResultWithId>({
const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(10);
/// this
const handleChangePage: TablePaginationProps["onPageChange"] = (
_event,
newPage,
@@ -236,6 +238,7 @@ function EquipmentSearchResults<T extends ResultWithId>({
}
};

// checkbox
const currItems = useMemo(() => {
return items.length > 10 ? items
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
@@ -251,6 +254,7 @@ function EquipmentSearchResults<T extends ResultWithId>({

const handleRowClick = useCallback(
(event: MouseEvent<unknown>, item: T, columns: Column<T>[]) => {
// check is disabled or not
let disabled = false;
columns.forEach((col) => {
if (isCheckboxColumn(col) && col.disabled) {
@@ -265,6 +269,7 @@ function EquipmentSearchResults<T extends ResultWithId>({
return;
}

// set id
const id = item.id;
if (setCheckboxIds) {
const selectedIndex = checkboxIds.indexOf(id);
@@ -330,7 +335,7 @@ function EquipmentSearchResults<T extends ResultWithId>({
column.renderHeader()
) : (
column.label.split('\n').map((line, index) => (
<div key={index}>{line}</div>
<div key={index}>{line}</div> // Render each line in a div
))
)}
</TableCell>
@@ -437,6 +442,7 @@ function EquipmentSearchResults<T extends ResultWithId>({
return noWrapper ? table : <Paper sx={{ overflow: "hidden" }}>{table}</Paper>;
}

// Table cells
interface TableCellsProps<T extends ResultWithId> {
column: Column<T>;
columnName: keyof T;


+ 1
- 0
src/components/EquipmentTypeSearch/EquipmentTypeSearchLoading.tsx Переглянути файл

@@ -4,6 +4,7 @@ import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import React from "react";

// Can make this nicer
export const EquipmentTypeSearchLoading: React.FC = () => {
return (
<>


+ 279
- 10
src/components/InventorySearch/InventoryLotLineTable.tsx Переглянути файл

@@ -1,16 +1,21 @@
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 { Column } from "../SearchResults";
import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults";
import { CheckCircleOutline, DoDisturb, EditNote } from "@mui/icons-material";
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 { downloadFile } from "@/app/utils/commonUtil";
import { fetchQrCodeByLotLineId, LotLineToQrcode } from "@/app/api/pdf/actions";
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 {
inventoryLotLines: InventoryLotLineResult[] | null;
@@ -23,8 +28,26 @@ interface Props {
const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory }) => {
const { t } = useTranslation(["inventory"]);
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);
// const postData = { stockInLineIds: [42,43,44] };
const postData: LotLineToQrcode = {
@@ -37,12 +60,24 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
setIsUploading(false);
}, [setIsUploading]);
const handleStockTransfer = useCallback(
(lotLine: InventoryLotLineResult) => {
setSelectedLotLine(lotLine);
setStockTransferModalOpen(true);
setStartLocation(lotLine.warehouse.code || "");
setTargetLocation("");
setTargetLocationInput("");
setQtyToBeTransferred(0);
},
[],
);

const onDetailClick = useCallback(
(lotLine: InventoryLotLineResult) => {
printQrcode(lotLine.id)
downloadQrCode(lotLine.id)
// lot line id to find stock in line
},
[printQrcode],
[downloadQrCode],
);
const columns = useMemo<Column<InventoryLotLineResult>[]>(
() => [
@@ -108,14 +143,32 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
name: "warehouse",
label: t("Warehouse"),
renderCell: (params) => {
return `${params.warehouse.code} - ${params.warehouse.name}`
return `${params.warehouse.code}`
},
},
{
name: "id",
label: t("qrcode"),
label: t("Download QR Code"),
onClick: onDetailClick,
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",
@@ -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 <>
<Typography variant="h6">{inventory ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})` : t("No items are selected yet.")}</Typography>
<SearchResults<InventoryLotLineResult>
@@ -142,6 +226,191 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
setPagingController={setPagingController}
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>

</>
}


+ 53
- 61
src/components/ItemsSearch/ItemsSearch.tsx Переглянути файл

@@ -13,8 +13,6 @@ import { TypeEnum } from "@/app/utils/typeEnum";
import axios from "axios";
import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api";
import axiosInstance from "@/app/(main)/axios/axiosInstance";
import { deleteItem } from "@/app/api/settings/item/actions";
import { deleteDialog, successDialog } from "../Swal/CustomAlerts";

type Props = {
items: ItemsResult[];
@@ -52,6 +50,8 @@ const ItemsSearch: React.FC<Props> = ({ items }) => {
[router],
);

const onDeleteClick = useCallback((item: ItemsResult) => {}, [router]);

const checkItemStatus = useCallback((item: ItemsResult): "complete" | "missing" => {
// Check if type exists and is not empty
const hasType = item.type != null && String(item.type).trim() !== "";
@@ -76,6 +76,48 @@ const ItemsSearch: React.FC<Props> = ({ items }) => {
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(
async (filterObj: SearchQuery) => {
const authHeader = axiosInstance.defaults.headers["Authorization"];
@@ -92,6 +134,8 @@ const ItemsSearch: React.FC<Props> = ({ items }) => {
`${NEXT_PUBLIC_API_URL}/items/getRecordByPage`,
{ 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) {
// Normalize field names and add status to each item
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),
};
console.log("Normalized item:", {
id: normalizedItem.id,
LocationCode: normalizedItem.LocationCode,
qcCategoryId: qcCategoryId,
qcCategory: normalizedItem.qcCategory
});
return {
...normalizedItem,
status: checkItemStatus(normalizedItem),
};
});
console.log("Fetched items data:", itemsWithStatus);
setFilteredItems(itemsWithStatus as ItemsResult[]);
setTotalCount(response.data.total);
return response; // Return the data from the response
@@ -135,64 +185,6 @@ const ItemsSearch: React.FC<Props> = ({ items }) => {
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(() => {
setFilteredItems(items);
}, [items]);


+ 0
- 3
src/components/Jodetail/JodetailSearch.tsx Переглянути файл

@@ -37,7 +37,6 @@ import {
import { fetchPrinterCombo } from "@/app/api/settings/printer";
import { PrinterCombo } from "@/app/api/settings/printer";
import JoPickOrderDetail from "./JoPickOrderDetail";
import MaterialPickStatusTable from "./MaterialPickStatusTable";
interface Props {
pickOrders: PickOrderResult[];
printerCombo: PrinterCombo[];
@@ -490,7 +489,6 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => {
<Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable">
<Tab label={t("Jo Pick Order Detail")} iconPosition="end" />
<Tab label={t("Complete Job Order Record")} iconPosition="end" />
<Tab label={t("Material Pick Status")} iconPosition="end" />
</Tabs>
</Box>

@@ -505,7 +503,6 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => {
printQty={printQty}
/>
)}
{tabIndex === 2 && <MaterialPickStatusTable />}
</Box>
</Box>
);


+ 0
- 381
src/components/Jodetail/MaterialPickStatusTable.tsx Переглянути файл

@@ -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;

+ 14
- 35
src/components/NavigationContent/NavigationContent.tsx Переглянути файл

@@ -26,7 +26,14 @@ import Link from "next/link";
import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig";
import Logo from "../Logo";
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 {
icon: React.ReactNode;
@@ -60,18 +67,15 @@ const NavigationContent: React.FC = () => {
icon: <RequestQuote />,
label: "Store Management",
path: "",
requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ADMIN],
children: [
{
icon: <RequestQuote />,
label: "Purchase Order",
requiredAbility: [AUTH.PURCHASE, AUTH.ADMIN],
path: "/po",
},
{
icon: <RequestQuote />,
label: "Pick Order",
requiredAbility: [AUTH.STOCK, AUTH.ADMIN],
path: "/pickOrder",
},
// {
@@ -97,19 +101,16 @@ const NavigationContent: React.FC = () => {
{
icon: <RequestQuote />,
label: "View item In-out And inventory Ledger",
requiredAbility: [AUTH.STOCK, AUTH.ADMIN],
path: "/inventory",
},
{
icon: <RequestQuote />,
label: "Stock Take Management",
requiredAbility: [AUTH.STOCK_TAKE, AUTH.ADMIN],
path: "/stocktakemanagement",
},
{
icon: <RequestQuote />,
label: "Stock Issue",
requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.ADMIN],
path: "/stockIssue",
},
//TODO: anna
@@ -121,33 +122,24 @@ const NavigationContent: React.FC = () => {
{
icon: <RequestQuote />,
label: "Put Away Scan",
requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.ADMIN],
path: "/putAway",
},
{
icon: <RequestQuote />,
label: "Finished Good Order",
requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN],
path: "/finishedGood",
},
{
icon: <RequestQuote />,
label: "Stock Record",
requiredAbility: [AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN],
path: "/stockRecord",
},
],
},
{
icon: <RequestQuote />,
label: "Delivery",
path: "",
requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN],
//requiredAbility: VIEW_DO,
children: [
{
icon: <RequestQuote />,
label: "Delivery Order",
requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN],
path: "/do",
},
],
@@ -190,7 +182,6 @@ const NavigationContent: React.FC = () => {
icon: <RequestQuote />,
label: "Scheduling",
path: "",
requiredAbility: [AUTH.FORECAST, AUTH.ADMIN],
children: [
{
icon: <RequestQuote />,
@@ -215,30 +206,25 @@ const NavigationContent: React.FC = () => {
icon: <RequestQuote />,
label: "Management Job Order",
path: "",
requiredAbility: [AUTH.JOB_CREATE, AUTH.JOB_PICK, AUTH.JOB_PROD, AUTH.ADMIN],
children: [
{
icon: <RequestQuote />,
label: "Search Job Order/ Create Job Order",
requiredAbility: [AUTH.JOB_CREATE, AUTH.ADMIN],
path: "/jo",
},
{
icon: <RequestQuote />,
label: "Job Order Pickexcution",
requiredAbility: [AUTH.JOB_PICK, AUTH.JOB_MAT, AUTH.ADMIN],
path: "/jodetail",
},
{
icon: <RequestQuote />,
label: "Job Order Production Process",
requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
path: "/productionProcess",
},
{
icon: <RequestQuote />,
label: "Bag Usage",
requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
path: "/bag",
},
],
@@ -247,40 +233,33 @@ const NavigationContent: React.FC = () => {
icon: <BugReportIcon />,
label: "PS",
path: "/ps",
requiredAbility: AUTH.TESTING,
requiredAbility: TESTING,
isHidden: false,
},
{
icon: <BugReportIcon />,
label: "Printer 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,
},
{
icon: <RequestQuote />,
label: "Settings",
path: "",
requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN],
requiredAbility: [VIEW_USER, VIEW_GROUP],
children: [
{
icon: <RequestQuote />,
label: "User",
path: "/settings/user",
requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN],
requiredAbility: VIEW_USER,
},
{
icon: <RequestQuote />,
label: "User Group",
path: "/settings/user",
requiredAbility: [AUTH.VIEW_GROUP, AUTH.ADMIN],
requiredAbility: VIEW_GROUP,
},
// {
// icon: <RequestQuote />,


+ 0
- 324
src/components/ProductionProcess/JobProcessStatus.tsx Переглянути файл

@@ -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;

+ 1
- 1
src/components/ProductionProcess/ProductionProcessList.tsx Переглянути файл

@@ -142,7 +142,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
}
// 3) 更新 JO 状态
// await updateJo({ id: process.jobOrderId, status: "completed" });
await updateJo({ id: process.jobOrderId, status: "completed" });
// 4) 刷新列表
await fetchProcesses();


+ 0
- 5
src/components/ProductionProcess/ProductionProcessPage.tsx Переглянути файл

@@ -8,7 +8,6 @@ import ProductionProcessDetail from "@/components/ProductionProcess/ProductionPr
import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail";
import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan";
import FinishedQcJobOrderList from "@/components/ProductionProcess/FinishedQcJobOrderList";
import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus";
import {
fetchProductProcesses,
fetchProductProcessesByJobOrderId,
@@ -165,7 +164,6 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
<Tabs value={tabIndex} onChange={handleTabChange} sx={{ mb: 2 }}>
<Tab label={t("Production Process")} />
<Tab label={t("Finished QC Job Orders")} />
<Tab label={t("Job Process Status")} />
</Tabs>

{tabIndex === 0 && (
@@ -192,9 +190,6 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
selectedPrinter={selectedPrinter}
/>
)}
{tabIndex === 2 && (
<JobProcessStatus />
)}
</Box>
);
};


+ 2
- 22
src/components/Shop/TruckLaneDetail.tsx Переглянути файл

@@ -69,7 +69,6 @@ const TruckLaneDetail: React.FC = () => {
const [uniqueRemarks, setUniqueRemarks] = useState<string[]>([]);
const [uniqueShopCodes, setUniqueShopCodes] = useState<string[]>([]);
const [uniqueShopNames, setUniqueShopNames] = useState<string[]>([]);
const [shopNameByCodeMap, setShopNameByCodeMap] = useState<Map<string, string>>(new Map());
const [addShopDialogOpen, setAddShopDialogOpen] = useState<boolean>(false);
const [newShop, setNewShop] = useState({
shopName: "",
@@ -87,12 +86,11 @@ const TruckLaneDetail: React.FC = () => {
useEffect(() => {
const fetchAutocompleteData = async () => {
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 }>>,
findAllUniqueRemarksFromTrucksClient() as Promise<string[]>,
findAllUniqueShopCodesFromTrucksClient() 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)
@@ -107,15 +105,6 @@ const TruckLaneDetail: React.FC = () => {
setUniqueRemarks(remarks || []);
setUniqueShopCodes(codes || []);
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) {
console.error("Failed to load autocomplete data:", err);
}
@@ -711,7 +700,6 @@ const TruckLaneDetail: React.FC = () => {
<TableHead>
<TableRow>
<TableCell>{t("Shop Name")}</TableCell>
<TableCell>{t("Shop Branch")}</TableCell>
<TableCell>{t("Shop Code")}</TableCell>
<TableCell>{t("Remark")}</TableCell>
<TableCell>{t("Loading Sequence")}</TableCell>
@@ -721,7 +709,7 @@ const TruckLaneDetail: React.FC = () => {
<TableBody>
{shopsData.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
<TableCell colSpan={5} align="center">
<Typography variant="body2" color="text.secondary">
{t("No shops found using this truck lane")}
</Typography>
@@ -731,14 +719,6 @@ const TruckLaneDetail: React.FC = () => {
shopsData.map((shop, index) => (
<TableRow key={shop.id ?? `shop-${index}`}>
<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 ? (
<Autocomplete
freeSolo


+ 17
- 19
src/components/StockIn/FgStockInForm.tsx Переглянути файл

@@ -365,28 +365,26 @@ return (
</Grid></>
)} */}
<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
{...field}
sx={textfieldSx}
label={t("expiryDate")}
value={expiryDateValue ? dayjs(expiryDateValue) : null} // Use null instead of undefined
value={expiryDate ? dayjs(expiryDate) : undefined}
format={OUTPUT_DATE_FORMAT}
disabled={disabled}
onChange={(date) => {
if (!date) {
setValue("expiryDate", "");
return;
}
if (!date) return;
console.log(date.format(INPUT_DATE_FORMAT));
setValue("expiryDate", date.format(INPUT_DATE_FORMAT));
}}
inputRef={field.ref}
@@ -418,10 +416,10 @@ return (
},
}}
/>
</LocalizationProvider>
);
}}
/>
</LocalizationProvider>
);
}}
/>
</Grid>
{/* <Grid item xs={6}>
{putawayMode ? (


+ 0
- 444
src/components/StockRecord/SearchPage.tsx Переглянути файл

@@ -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;

+ 0
- 26
src/components/StockRecord/index.tsx Переглянути файл

@@ -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;

+ 17
- 1
src/components/StockTakeManagement/ApproverCardList.tsx Переглянути файл

@@ -201,7 +201,23 @@ const ApproverCardList: React.FC<ApproverCardListProps> = ({ onCardClick }) => {
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{t("Control Time")}: <TimeDisplay startTime={session.startTime} endTime={session.endTime} />
</Typography>
{session.totalInventoryLotNumber > 0 && (
<Box sx={{ mt: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="body2" fontWeight={600}>
{t("Progress")}
</Typography>
<Typography variant="body2" fontWeight={600}>
{completionRate}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={completionRate}
sx={{ height: 8, borderRadius: 1 }}
/>
</Box>
)}
</CardContent>

<CardActions sx={{ pt: 0.5 ,justifyContent: "space-between"}}>


+ 253
- 381
src/components/StockTakeManagement/ApproverStockTake.tsx Переглянути файл

@@ -14,14 +14,10 @@ import {
TableHead,
TableRow,
Paper,
Checkbox,
TextField,
FormControlLabel,
Radio,
TablePagination,
ToggleButton
} from "@mui/material";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
AllPickedStockTakeListReponse,
@@ -56,8 +52,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({

const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]);
const [loadingDetails, setLoadingDetails] = useState(false);
const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(false);

// 每个记录的选择状态,key 为 detail.id
const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({});
const [approverQty, setApproverQty] = useState<Record<number, string>>({});
@@ -65,111 +60,28 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
const [saving, setSaving] = useState(false);
const [batchSaving, setBatchSaving] = 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 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(() => {
// 初始化默认选择:如果 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) => {
if (!selectedSession || !currentUserId) {
return;
@@ -223,7 +135,11 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
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) {
console.error("Save approver stock take record error:", e);
let errorMessage = t("Failed to save approver stock take record");
@@ -243,8 +159,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
} finally {
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) => {
if (!detail.stockTakeRecordId) {
onSnackbar(t("Stock take record ID is required"), "error");
@@ -256,6 +171,12 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId);
onSnackbar(t("Stock take record status updated to not match"), "success");
const details = await getInventoryLotDetailsBySection(
selectedSession.stockTakeSession,
selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null
);
setInventoryLotDetails(Array.isArray(details) ? details : []);
} catch (e: any) {
console.error("Update stock take record status error:", e);
let errorMessage = t("Failed to update stock take record status");
@@ -274,20 +195,8 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
onSnackbar(errorMessage, "error");
} finally {
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 () => {
if (!selectedSession || !currentUserId) {
console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId');
@@ -314,7 +223,11 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
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) {
console.error("handleBatchSubmitAll: Error:", e);
let errorMessage = t("Failed to batch save approver stock take records");
@@ -334,12 +247,11 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
} finally {
setBatchSaving(false);
}
}, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]);
}, [selectedSession, t, currentUserId, onSnackbar]);

useEffect(() => {
handleBatchSubmitAllRef.current = handleBatchSubmitAll;
}, [handleBatchSubmitAll]);
const formatNumber = (num: number | null | undefined): string => {
if (num == null) return "0.00";
return num.toLocaleString('en-US', {
@@ -347,7 +259,6 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
maximumFractionDigits: 2
});
};
const uniqueWarehouses = Array.from(
new Set(
inventoryLotDetails
@@ -355,7 +266,6 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
.filter(warehouse => warehouse && warehouse.trim() !== "")
)
).join(", ");
const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => {
// Only allow editing if there's a first stock take qty
if (!detail.firstStockTakeQty || detail.firstStockTakeQty === 0) {
@@ -370,270 +280,232 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
{t("Back to List")}
</Button>
<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 ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</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>
<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>
</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}>
<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>
</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>
);


+ 17
- 1
src/components/StockTakeManagement/PickerCardList.tsx Переглянути файл

@@ -224,7 +224,23 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT
{t("Control Time")}: <TimeDisplay startTime={session.startTime} endTime={session.endTime} />
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Total Item Number")}: {session.totalItemNumber}</Typography>
{session.totalInventoryLotNumber > 0 && (
<Box sx={{ mt: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="body2" fontWeight={600}>
{t("Progress")}
</Typography>
<Typography variant="body2" fontWeight={600}>
{completionRate}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={completionRate}
sx={{ height: 8, borderRadius: 1 }}
/>
</Box>
)}
</CardContent>

<CardActions sx={{ pt: 0.5 ,justifyContent: "space-between"}}>


+ 227
- 255
src/components/StockTakeManagement/PickerReStockTake.tsx Переглянути файл

@@ -15,7 +15,6 @@ import {
TableRow,
Paper,
TextField,
TablePagination,
} from "@mui/material";
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
@@ -34,13 +33,13 @@ import { SessionWithTokens } from "@/config/authConfig";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";

interface PickerReStockTakeProps {
interface PickerStockTakeProps {
selectedSession: AllPickedStockTakeListReponse;
onBack: () => void;
onSnackbar: (message: string, severity: "success" | "error" | "warning") => void;
}

const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
const PickerStockTake: React.FC<PickerStockTakeProps> = ({
selectedSession,
onBack,
onSnackbar,
@@ -61,63 +60,28 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
const [saving, setSaving] = useState(false);
const [batchSaving, setBatchSaving] = useState(false);
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 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(() => {
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) => {
setEditingRecord(detail);
@@ -167,9 +131,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
badQty: parseFloat(badQty),
remark: isSecondSubmit ? (remark || null) : null,
};
console.log('handleSaveStockTake: request:', request);
console.log('handleSaveStockTake: selectedSession.stockTakeId:', selectedSession.stockTakeId);
console.log('handleSaveStockTake: currentUserId:', currentUserId);
console.log('handleSaveStockTake: request:', request);
console.log('handleSaveStockTake: selectedSession.stockTakeId:', selectedSession.stockTakeId);
console.log('handleSaveStockTake: currentUserId:', currentUserId);
await saveStockTakeRecord(
request,
selectedSession.stockTakeId,
@@ -179,7 +143,11 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
onSnackbar(t("Stock take record saved successfully"), "success");
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) {
console.error("Save stock take record error:", e);
let errorMessage = t("Failed to save stock take record");
@@ -199,7 +167,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
} finally {
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 () => {
if (!selectedSession || !currentUserId) {
@@ -227,7 +195,11 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
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) {
console.error("handleBatchSubmitAll: Error:", e);
let errorMessage = t("Failed to batch save stock take records");
@@ -247,7 +219,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
} finally {
setBatchSaving(false);
}
}, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]);
}, [selectedSession, t, currentUserId, onSnackbar]);

useEffect(() => {
handleBatchSubmitAllRef.current = handleBatchSubmitAll;
@@ -353,213 +325,213 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
<CircularProgress />
</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>
<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>
</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 ? (
<>
<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">
{detail.remarks || "-"}
{t("Second")}: {detail.secondStockTakeQty.toFixed(2)}
</Typography>
) : null}
{!detail.firstStockTakeQty && !detail.secondStockTakeQty && !isEditing && (
<Typography variant="body2" color="text.secondary">
-
</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
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>
)}
</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>
);
};

export default PickerReStockTake;
export default PickerStockTake;

+ 282
- 343
src/components/StockTakeManagement/PickerStockTake.tsx Переглянути файл

@@ -15,13 +15,7 @@ import {
TableRow,
Paper,
TextField,
TablePagination,
Select, // Add this
MenuItem, // Add this
FormControl, // Add this
InputLabel,
} from "@mui/material";
import { SelectChangeEvent } from "@mui/material/Select";
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -66,76 +60,29 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
const [saving, setSaving] = useState(false);
const [batchSaving, setBatchSaving] = useState(false);
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 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(() => {
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) => {
setEditingRecord(detail);

@@ -229,9 +176,12 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({

onSnackbar(t("Stock take record saved successfully"), "success");
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) {
console.error("Save stock take record error:", e);
let errorMessage = t("Failed to save stock take record");
@@ -263,9 +213,6 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
t,
currentUserId,
onSnackbar,
loadDetails,
page,
pageSize,
]
);

@@ -296,7 +243,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
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) {
console.error("handleBatchSubmitAll: Error:", e);
let errorMessage = t("Failed to batch save stock take records");
@@ -442,290 +393,278 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
<CircularProgress />
</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>
<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>
</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">
{t("Second")}:{" "}
{formatNumber(
(detail.secondStockTakeQty ?? 0) +
(detail.secondBadQty ?? 0)
)}{" "}
(
=
{formatNumber(
detail.secondBadQty ?? 0
parseFloat(firstQty || "0") -
parseFloat(firstBadQty || "0")
)}
) ={" "}
{formatNumber(detail.secondStockTakeQty ?? 0)}
</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 ? (
<>
<Typography variant="body2">{t("Remark")}</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2">{t("Second")}:</Typography>
<TextField
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">
{detail.remarks || "-"}
{t("Second")}:{" "}
{formatNumber(
(detail.secondStockTakeQty ?? 0) +
(detail.secondBadQty ?? 0)
)}{" "}
(
{formatNumber(
detail.secondBadQty ?? 0
)}
) ={" "}
{formatNumber(detail.secondStockTakeQty ?? 0)}
</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"
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
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>
)}
</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>
);


+ 20
- 0
src/components/WarehouseHandle/WarehouseHandle.tsx Переглянути файл

@@ -53,6 +53,8 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
successDialog(t("Delete Success"), t);
} catch (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, router]);
@@ -74,14 +76,18 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
try {
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 warehouse = searchInputs.warehouse?.trim() || "";
const area = searchInputs.area?.trim() || "";
const slot = searchInputs.slot?.trim() || "";
const stockTakeSection = searchInputs.stockTakeSection?.trim() || "";

// If any field has a value, filter by code pattern and stockTakeSection
if (storeId || warehouse || area || slot || stockTakeSection) {
results = warehouses.filter((warehouseItem) => {
// Filter by stockTakeSection if provided
if (stockTakeSection) {
const itemStockTakeSection = String(warehouseItem.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 (!warehouseItem.code) {
return false;
@@ -96,6 +103,8 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
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("-");
if (codeParts.length >= 4) {
@@ -112,6 +121,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
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 warehouseMatch = !warehouse || codeValue.includes(warehouse.toLowerCase());
const areaMatch = !area || codeValue.includes(area.toLowerCase());
@@ -120,9 +130,11 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
return storeIdMatch && warehouseMatch && areaMatch && slotMatch;
}
// If only stockTakeSection is provided, return true (already filtered above)
return true;
});
} else {
// If no search terms, show all warehouses
results = warehouses;
}

@@ -130,6 +142,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
setPagingController({ pageNum: 1, pageSize: pagingController.pageSize });
} catch (error) {
console.error("Error searching warehouses:", error);
// Fallback: filter by code pattern and stockTakeSection
const storeId = searchInputs.store_id?.trim().toLowerCase() || "";
const warehouse = searchInputs.warehouse?.trim().toLowerCase() || "";
const area = searchInputs.area?.trim().toLowerCase() || "";
@@ -138,6 +151,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
setFilteredWarehouse(
warehouses.filter((warehouseItem) => {
// Filter by stockTakeSection if provided
if (stockTakeSection) {
const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase();
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 (!warehouseItem.code) {
return false;
@@ -252,6 +267,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
justifyContent: "flex-start",
}}
>
{/* 樓層 field with F inside on the right */}
<TextField
label={t("store_id")}
value={searchInputs.store_id}
@@ -269,6 +285,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
<Typography variant="body1" sx={{ mx: 0.5 }}>
-
</Typography>
{/* 倉庫 field */}
<TextField
label={t("warehouse")}
value={searchInputs.warehouse}
@@ -281,6 +298,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
<Typography variant="body1" sx={{ mx: 0.5 }}>
-
</Typography>
{/* 區域 field */}
<TextField
label={t("area")}
value={searchInputs.area}
@@ -293,6 +311,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
<Typography variant="body1" sx={{ mx: 0.5 }}>
-
</Typography>
{/* 儲位 field */}
<TextField
label={t("slot")}
value={searchInputs.slot}
@@ -302,6 +321,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
size="small"
sx={{ width: "150px", minWidth: "120px" }}
/>
{/* 盤點區域 field */}
<Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}>
<TextField
label={t("stockTakeSection")}


+ 1
- 14
src/components/qrCodeHandles/qrCodeHandleTabs.tsx Переглянути файл

@@ -30,24 +30,20 @@ function TabPanel(props: TabPanelProps) {
interface QrCodeHandleTabsProps {
userTabContent: ReactNode;
equipmentTabContent: ReactNode;
warehouseTabContent: ReactNode;
}

const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({
userTabContent,
equipmentTabContent,
warehouseTabContent,
}) => {
const { t } = useTranslation("common");
const { t: tUser } = useTranslation("user");
const { t: tWarehouse } = useTranslation("warehouse");
const searchParams = useSearchParams();
const router = useRouter();
const getInitialTab = () => {
const tab = searchParams.get("tab");
if (tab === "equipment") return 1;
if (tab === "warehouse") return 2;
if (tab === "user") return 0;
return 0;
};
@@ -58,8 +54,6 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({
const tab = searchParams.get("tab");
if (tab === "equipment") {
setCurrentTab(1);
} else if (tab === "warehouse") {
setCurrentTab(2);
} else if (tab === "user") {
setCurrentTab(0);
}
@@ -67,9 +61,7 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({

const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
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());
params.set("tab", tabName);
router.push(`?${params.toString()}`, { scroll: false });
@@ -81,7 +73,6 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({
<Tabs value={currentTab} onChange={handleTabChange}>
<Tab label={tUser("User")} />
<Tab label={t("Equipment")} />
<Tab label={tWarehouse("Warehouse")} />
</Tabs>
</Box>

@@ -92,10 +83,6 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({
<TabPanel value={currentTab} index={1}>
{equipmentTabContent}
</TabPanel>

<TabPanel value={currentTab} index={2}>
{warehouseTabContent}
</TabPanel>
</Box>
);
};


+ 0
- 675
src/components/qrCodeHandles/qrCodeHandleWarehouseSearch.tsx Переглянути файл

@@ -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;

+ 0
- 21
src/components/qrCodeHandles/qrCodeHandleWarehouseSearchWrapper.tsx Переглянути файл

@@ -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
src/config/authConfig.ts Переглянути файл

@@ -2,7 +2,7 @@
import { AuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { LOGIN_API_PATH } from "./api";
import { Session } from "next-auth";
// Extend the built-in types
declare module "next-auth" {
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;

+ 0
- 51
src/config/reportConfig.ts Переглянути файл

@@ -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...
];

+ 0
- 3
src/i18n/en/common.json Переглянути файл

@@ -26,9 +26,6 @@
"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",
"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 code": "Search or select shop code",
"Search or select remark": "Search or select remark",


+ 1
- 76
src/i18n/en/dashboard.json Переглянути файл

@@ -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"
}
{}

+ 1
- 16
src/i18n/zh/common.json Переглянути файл

@@ -4,7 +4,7 @@
"Job Order Production Process": "工單生產流程",
"productionProcess": "生產流程",
"Search Criteria": "搜尋條件",
"Stock Record": "庫存記錄",
"All": "全部",
"No options": "沒有選項",
"Select Another Bag Lot": "選擇另一個包裝袋",
"Finished QC Job Orders": "完成QC工單",
@@ -28,13 +28,6 @@
"Total finished QC job orders": "總完成QC工單數量",
"Over Time": "超時",
"Code": "編號",
"Job Order No.": "工單編號",
"FG / WIP Item": "成品/半成品",
"Production Time Remaining": "生產剩餘時間",
"Process": "工序",
"Start": "開始",
"Finish": "完成",
"Wait Time [minutes]": "等待時間(分鐘)",
"Staff No": "員工編號",
"code": "編號",
"Name": "名稱",
@@ -48,13 +41,6 @@
"No": "沒有",
"Assignment failed: ": "分配失敗: ",
"Unknown error": "未知錯誤",
"Job Process Status": "工單流程狀態",
"Total Time": "總時間",
"Remaining Time": "剩餘時間",
"Wait Time": "等待時間",
"Wait Time [minutes]": "等待時間(分鐘)",
"End Time": "完成時間",
"WIP": "半成品",
"R&D": "研發",
"STF": "樣品",
@@ -322,7 +308,6 @@
"ShopAndTruck": "店鋪路線管理",
"Shop Information": "店鋪資訊",
"Shop Name": "店鋪名稱",
"Shop Branch": "店鋪分店",
"Shop Code": "店鋪編號",
"Truck Lane": "卡車路線",
"Truck Lane Detail": "卡車路線詳情",


+ 1
- 16
src/i18n/zh/dashboard.json Переглянути файл

@@ -57,20 +57,5 @@
"No": "無",
"Responsible Escalation List": "負責的上報列表",
"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
- 31
src/i18n/zh/inventory.json Переглянути файл

@@ -10,23 +10,16 @@
"fg": "成品",
"Back to List": "返回列表",
"Record Status": "記錄狀態",
"Stock take record status updated to not match": "盤點記錄狀態更新為數值不符",
"available": "可用",
"Item-lotNo-ExpiryDate": "貨品-批號-到期日",
"Item-lotNo-ExpiryDate": "貨品-批號-到期日",
"not available": "不可用",
"Batch Submit All": "批量提交所有",
"Batch Save All": "批量保存所有",
"Batch Submit All": "批量提交所有",
"Batch Save All": "批量保存所有",
"not match": "數值不符",
"Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數",
"Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數",
"View ReStockTake": "查看重新盤點",
"Stock Take Qty": "盤點數",
"Stock Take Qty": "盤點數",
"ReStockTake": "重新盤點",
"Stock Taker": "盤點員",
"Total Item Number": "貨品數量",
@@ -38,17 +31,6 @@
"start time": "開始時間",
"end 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": "通過",
"not pass": "不通過",
"Available": "可用",
@@ -57,7 +39,6 @@
"Last Stock Take Date": "上次盤點日期",
"Remark": "備註",
"notMatch": "數值不符",
"notMatch": "數值不符",
"Stock take record saved successfully": "盤點記錄保存成功",
"View Details": "查看詳細",
"Input": "輸入",
@@ -168,16 +149,14 @@
"Stock take adjustment has been confirmed successfully!": "盤點調整確認成功!",
"System Qty": "系統數量",
"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": "提交"
}

+ 2
- 19
src/i18n/zh/jo.json Переглянути файл

@@ -4,14 +4,11 @@
"Edit Job Order Detail": "工單詳情",
"Details": "細節",
"Actions": "操作",
"Process": "工序",
"Create Job Order": "建立工單",
"Code": "工單編號",
"Name": "成品/半成品名稱",
"Picked Qty": "已提料數量",
"Confirm All": "確認所有提料",
"Wait Time [minutes]": "等待時間(分鐘)",
"Job Process Status": "工單流程狀態",
"Search Job Order/ Create Job Order":"搜尋工單/建立工單",
"UoM": "銷售單位",
"Select Another Bag Lot":"選擇另一個包裝袋",
@@ -104,13 +101,6 @@
"Job Order Pickexcution": "工單提料",
"Pick Order Detail": "提料單細節",
"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": "編號",
"Route": "路線",
"Qty": "數量",
@@ -527,13 +517,6 @@

"Start 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": "登出"
}

+ 15
- 24
src/middleware.ts Переглянути файл

@@ -1,4 +1,5 @@
import { NextRequestWithAuth, withAuth } from "next-auth/middleware";
// import { authOptions } from "@/config/authConfig";
import { authOptions } from "./config/authConfig";
import { NextFetchEvent, NextResponse } from "next/server";
import { PRIVATE_ROUTES } from "./routes";
@@ -9,14 +10,15 @@ const authMiddleware = withAuth({
pages: authOptions.pages,
callbacks: {
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;
},
},
@@ -26,9 +28,9 @@ export default async function middleware(
req: NextRequestWithAuth,
event: NextFetchEvent,
) {
// Handle language parameters
const langPref = req.nextUrl.searchParams.get(LANG_QUERY_PARAM);
if (langPref) {
// Redirect to same url without the lang query param + set cookies
const newUrl = new URL(req.nextUrl);
newUrl.searchParams.delete(LANG_QUERY_PARAM);
const response = NextResponse.redirect(newUrl);
@@ -36,19 +38,8 @@ export default async function middleware(
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
}

+ 0
- 3
src/routes.ts Переглянути файл

@@ -2,9 +2,6 @@ export const PRIVATE_ROUTES = [
"/analytics",
"/dashboard",
"/dashboard",
"/testing",
"/ps",
"/report",
"/invoice",
"/projects",
"/tasks",


Завантаження…
Відмінити
Зберегти