瀏覽代碼

update

master
CANCERYS\kw093 1 月之前
父節點
當前提交
abe06be519
共有 21 個文件被更改,包括 3721 次插入785 次删除
  1. +35
    -0
      src/app/(main)/bag/page.tsx
  2. +1
    -1
      src/app/(main)/jo/page.tsx
  3. +75
    -1
      src/app/api/bag/action.ts
  4. +25
    -1
      src/app/api/do/actions.tsx
  5. +39
    -1
      src/app/api/jo/actions.ts
  6. +289
    -4
      src/app/api/stockTake/actions.ts
  7. +312
    -0
      src/components/BagSearch/BagSearch.tsx
  8. +15
    -0
      src/components/BagSearch/BagSearchWrapper.tsx
  9. +1
    -0
      src/components/BagSearch/index.ts
  10. +261
    -161
      src/components/JoSearch/JoSearch.tsx
  11. +7
    -1
      src/components/ProductionProcess/BagConsumptionForm.tsx
  12. +455
    -203
      src/components/ProductionProcess/ProductionProcessDetail.tsx
  13. +154
    -3
      src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx
  14. +1
    -0
      src/components/ProductionProcess/ProductionProcessStepExecution.tsx
  15. +194
    -0
      src/components/StockTakeManagement/ApproverCardList.tsx
  16. +467
    -0
      src/components/StockTakeManagement/ApproverStockTake.tsx
  17. +211
    -0
      src/components/StockTakeManagement/PickerCardList.tsx
  18. +543
    -0
      src/components/StockTakeManagement/PickerReStockTake.tsx
  19. +542
    -0
      src/components/StockTakeManagement/PickerStockTake.tsx
  20. +1
    -1
      src/components/StockTakeManagement/StockTakeManagement.tsx
  21. +93
    -408
      src/components/StockTakeManagement/StockTakeTab.tsx

+ 35
- 0
src/app/(main)/bag/page.tsx 查看文件

@@ -0,0 +1,35 @@
import BagSearchWrapper from "@/components/BagSearch/BagSearchWrapper";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";
import React, { Suspense } from "react";

export const metadata: Metadata = {
title: "Bag Usage Records"
}

const bagPage: React.FC = async () => {
const { t } = await getServerI18n("jo");

return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Bag Usage")}
</Typography>
</Stack>
<I18nProvider namespaces={["jo", "common"]}>
<Suspense fallback={<BagSearchWrapper.Loading />}>
<BagSearchWrapper />
</Suspense>
</I18nProvider>
</>
)
}

export default bagPage;

+ 1
- 1
src/app/(main)/jo/page.tsx 查看文件

@@ -23,7 +23,7 @@ const jo: React.FC = async () => {
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Job Order")}
{t("Search Job Order/ Create Job Order")}
</Typography>
</Stack>
<I18nProvider namespaces={["jo", "common", "purchaseOrder", "dashboard","common"]}> {/* TODO: Improve */}


+ 75
- 1
src/app/api/bag/action.ts 查看文件

@@ -44,4 +44,78 @@ export const getBagInfo = cache(async () => {
body: JSON.stringify(request),
}
);
});
});

export interface BagUsageRecordResponse {
id: number;
bagId: number;
bagLotLineId: number;
jobId: number;
jobOrderCode: string;
stockOutLineId: number;
startQty: number;
consumedQty: number;
scrapQty: number;
endQty: number;
date: string;
time: string;
bagName?: string;
bagCode?: string;
lotNo?: string;
}

// 添加 API 调用函数:

export const getBagUsageRecords = cache(async () => {
return serverFetchJson<BagUsageRecordResponse[]>(
`${BASE_API_URL}/bag/bagUsageRecords`,
{
method: "GET",
next: { tags: ["bagUsageRecords"] },
}
);
});
export interface BagSummaryResponse {
id: number;
bagName: string;
bagCode: string;
takenBagBalance: number;
deleted: boolean;
}
export interface BagLotLineResponse {
id: number;
bagId: number;
lotNo: string;
stockOutLineId: number;
startQty: number;
consumedQty: number;
scrapQty: number;
balanceQty: number;
firstUseDate: string;
lastUseDate: string;
}
export interface BagConsumptionResponse {
id: number;
bagId: number;
bagLotLineId: number;
jobId: number;
jobOrderCode: string;
stockOutLineId: number;
startQty: number;
consumedQty: number;
scrapQty: number;
endQty: number;
date: string;
time: string;
}
export const fetchBags = cache(async () =>
serverFetchJson<BagSummaryResponse[]>(`${BASE_API_URL}/bag/bags`, { method: "GET" })
);

export const fetchBagLotLines = cache(async (bagId: number) =>
serverFetchJson<BagLotLineResponse[]>(`${BASE_API_URL}/bag/bags/${bagId}/lot-lines`, { method: "GET" })
);

export const fetchBagConsumptions = cache(async (bagLotLineId: number) =>
serverFetchJson<BagConsumptionResponse[]>(`${BASE_API_URL}/bag/lot-lines/${bagLotLineId}/consumptions`, { method: "GET" })
);

+ 25
- 1
src/app/api/do/actions.tsx 查看文件

@@ -318,6 +318,30 @@ export async function printDNLabels(request: PrintDNLabelsRequest){

return { success: true, message: "Print job sent successfully (labels)"} as PrintDeliveryNoteResponse
}

export interface Check4FTruckBatchResponse {
hasProblem: boolean;
problems: ProblemDoDto[];
}
export interface ProblemDoDto {
deliveryOrderId: number;
deliveryOrderCode: string;
targetDate: string;
availableTrucks: TruckInfoDto[];
}
export interface TruckInfoDto {
id: number;
truckLanceCode: string;
departureTime: string;
storeId: string;
shopCode: string;
shopName: string;
}
export const check4FTrucksBatch = cache(async (doIds: number[]) => {
return await serverFetchJson<Check4FTruckBatchResponse>(`${BASE_API_URL}/do/check-4f-trucks-batch`, {
method: "POST",
body: JSON.stringify(doIds),
headers: { "Content-Type": "application/json" },
});
});



+ 39
- 1
src/app/api/jo/actions.ts 查看文件

@@ -166,7 +166,19 @@ export const printFGStockInLabel = cache(async(data: PrintFGStockInLabelRequest)
}
);
});
export interface UpdateJoReqQtyRequest {
id: number;
reqQty: number;
}

// 添加更新 reqQty 的函数
export const updateJoReqQty = cache(async (data: UpdateJoReqQtyRequest) => {
return serverFetchJson<SaveJoResponse>(`${BASE_API_URL}/jo/updateReqQty`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
})
})

export const recordSecondScanIssue = cache(async (
pickOrderId: number,
@@ -250,6 +262,7 @@ export interface ProductProcessWithLinesResponse {
bomDescription: string;
jobType: string;
isDark: string;
bomBaseQty: number;
isDense: number;
isFloat: string;
timeSequence: number;
@@ -262,6 +275,7 @@ export interface ProductProcessWithLinesResponse {
outputQty: number;
outputQtyUom: string;
productionPriority: number;
submitedBagRecord?: boolean;
jobOrderLines: JobOrderLineInfo[];

productProcessLines: ProductProcessLineResponse[];
@@ -417,6 +431,7 @@ export interface JobOrderProcessLineDetailResponse {
stopTime: string | number[];
totalPausedTimeMs?: number; // API 返回的是数组格式
status: string;
submitedBagRecord: boolean;
outputFromProcessQty: number;
outputFromProcessUom: string;
defectQty: number;
@@ -779,7 +794,14 @@ export const fetchProductProcessesByJobOrderId = cache(async (jobOrderId: number
}
);
});

export const newProductProcessLine = cache(async (lineId: number) => {
return serverFetchJson<any>(
`${BASE_API_URL}/product-process/Demo/ProcessLine/new/${lineId}`,
{
method: "POST",
}
);
});
// 获取 process 的所有 lines
export const fetchProductProcessLines = cache(async (processId: number) => {
return serverFetchJson<ProductProcessLineResponse[]>(
@@ -1129,4 +1151,20 @@ export const passProductProcessLine = async (lineId: number) => {
headers: { "Content-Type": "application/json" },
}
);
};
export interface UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest {
productProcessLineId: number;
processingTime: number;
setupTime: number;
changeoverTime: number;
}
export const updateProductProcessLineProcessingTimeSetupTimeChangeoverTime = async (lineId: number, request: UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest) => {
return serverFetchJson<any>(
`${BASE_API_URL}/product-process/Demo/ProcessLine/update/processingTimeSetupTimeChangeoverTime/${lineId}`,
{
method: "POST",
body: JSON.stringify(request),
headers: { "Content-Type": "application/json" },
}
);
};

+ 289
- 4
src/app/api/stockTake/actions.ts 查看文件

@@ -1,10 +1,91 @@
// actions.ts
"use server";
import { serverFetchString } from "@/app/utils/fetchUtil";
import { cache } from 'react';
import { serverFetchJson } from "@/app/utils/fetchUtil"; // 改为 serverFetchJson
import { BASE_API_URL } from "@/config/api";
export interface InventoryLotDetailResponse {
id: number;
inventoryLotId: number;
itemId: number;
itemCode: string;
itemName: string;
lotNo: string;
expiryDate: string;
productionDate: string;
stockInDate: string;
inQty: number;
outQty: number;
holdQty: number;
availableQty: number;
uom: string;
warehouseCode: string;
warehouseName: string;
warehouseSlot: string;
warehouseArea: string;
warehouse: string;
varianceQty: number | null;
status: string;
remarks: string | null;
stockTakeRecordStatus: string;
stockTakeRecordId: number | null;
firstStockTakeQty: number | null;
secondStockTakeQty: number | null;
firstBadQty: number | null;
secondBadQty: number | null;
approverQty: number | null;
approverBadQty: number | null;
finalQty: number | null;
}

export const getInventoryLotDetailsBySection = async (
stockTakeSection: string,
stockTakeId?: number | null
) => {
console.log('🌐 [API] getInventoryLotDetailsBySection called with:', {
stockTakeSection,
stockTakeId
});
const encodedSection = encodeURIComponent(stockTakeSection);
let url = `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsBySection?stockTakeSection=${encodedSection}`;
if (stockTakeId != null && stockTakeId > 0) {
url += `&stockTakeId=${stockTakeId}`;
}
console.log(' [API] Full URL:', url);
const details = await serverFetchJson<InventoryLotDetailResponse[]>(
url,
{
method: "GET",
},
);
console.log('[API] Response received:', details);
return details;
}
export interface SaveStockTakeRecordRequest {
stockTakeRecordId?: number | null;
inventoryLotLineId: number;
qty: number;
badQty: number;
//stockTakerName: string;
remark?: string | null;
}
export interface AllPickedStockTakeListReponse {
id: number;
stockTakeSession: string;
lastStockTakeDate: string | null;
status: string|null;
currentStockTakeItemNumber: number;
totalInventoryLotNumber: number;
stockTakeId: number;
stockTakerName: string | null;
totalItemNumber: number;
}

export const importStockTake = async (data: FormData) => {
const importStockTake = await serverFetchString<string>(
const importStockTake = await serverFetchJson<string>(
`${BASE_API_URL}/stockTake/import`,
{
method: "POST",
@@ -12,4 +93,208 @@ export const importStockTake = async (data: FormData) => {
},
);
return importStockTake;
}
}

export const getStockTakeRecords = async () => {
const stockTakeRecords = await serverFetchJson<AllPickedStockTakeListReponse[]>( // 改为 serverFetchJson
`${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList`,
{
method: "GET",
},
);
return stockTakeRecords;
}
export const getApproverStockTakeRecords = async () => {
const stockTakeRecords = await serverFetchJson<AllPickedStockTakeListReponse[]>( // 改为 serverFetchJson
`${BASE_API_URL}/stockTakeRecord/AllApproverStockTakeList`,
{
method: "GET",
},
);
return stockTakeRecords;
}
export const createStockTakeForSections = async () => {
const createStockTakeForSections = await serverFetchJson<Map<string, string>>(
`${BASE_API_URL}/stockTake/createForSections`,
{
method: "POST",
},
);
return createStockTakeForSections;
}
export const saveStockTakeRecord = async (
request: SaveStockTakeRecordRequest,
stockTakeId: number,
stockTakerId: number
) => {
try {
const result = await serverFetchJson<any>(

`${BASE_API_URL}/stockTakeRecord/saveStockTakeRecord?stockTakeId=${stockTakeId}&stockTakerId=${stockTakerId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
},
);
console.log('saveStockTakeRecord: request:', request);
console.log('saveStockTakeRecord: stockTakeId:', stockTakeId);
console.log('saveStockTakeRecord: stockTakerId:', stockTakerId);
return result;
} catch (error: any) {
// 尝试从错误响应中提取消息
if (error?.response) {
try {
const errorData = await error.response.json();
const errorWithMessage = new Error(errorData.message || errorData.error || "Failed to save stock take record");
(errorWithMessage as any).response = error.response;
throw errorWithMessage;
} catch {
throw error;
}
}
throw error;
}
}
export interface BatchSaveStockTakeRecordRequest {
stockTakeId: number;
stockTakeSection: string;
stockTakerId: number;
//stockTakerName: string;
}

export interface BatchSaveStockTakeRecordResponse {
successCount: number;
errorCount: number;
errors: string[];
}
export const batchSaveStockTakeRecords = cache(async (data: BatchSaveStockTakeRecordRequest) => {
return serverFetchJson<BatchSaveStockTakeRecordResponse>(`${BASE_API_URL}/stockTakeRecord/batchSaveStockTakeRecords`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
})
})
// Add these interfaces and functions

export interface SaveApproverStockTakeRecordRequest {
stockTakeRecordId?: number | null;
qty: number;
badQty: number;
approverId?: number | null;
approverQty?: number | null;
approverBadQty?: number | null;
}

export interface BatchSaveApproverStockTakeRecordRequest {
stockTakeId: number;
stockTakeSection: string;
approverId: number;
}

export interface BatchSaveApproverStockTakeRecordResponse {
successCount: number;
errorCount: number;
errors: string[];
}


export const saveApproverStockTakeRecord = async (
request: SaveApproverStockTakeRecordRequest,
stockTakeId: number
) => {
try {
const result = await serverFetchJson<any>(
`${BASE_API_URL}/stockTakeRecord/saveApproverStockTakeRecord?stockTakeId=${stockTakeId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(request),
},
);
return result;
} catch (error: any) {
if (error?.response) {
try {
const errorData = await error.response.json();
const errorWithMessage = new Error(errorData.message || errorData.error || "Failed to save approver stock take record");
(errorWithMessage as any).response = error.response;
throw errorWithMessage;
} catch {
throw error;
}
}
throw error;
}
}

export const batchSaveApproverStockTakeRecords = cache(async (data: BatchSaveApproverStockTakeRecordRequest) => {
return serverFetchJson<BatchSaveApproverStockTakeRecordResponse>(
`${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecords`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
}
)
}
)

export const updateStockTakeRecordStatusToNotMatch = async (
stockTakeRecordId: number
) => {
try {
const result = await serverFetchJson<any>(
`${BASE_API_URL}/stockTakeRecord/updateStockTakeRecordStatusToNotMatch?stockTakeRecordId=${stockTakeRecordId}`,
{
method: "POST",
},
);
return result;
} catch (error: any) {
if (error?.response) {
try {
const errorData = await error.response.json();
const errorWithMessage = new Error(errorData.message || errorData.error || "Failed to update stock take record status");
(errorWithMessage as any).response = error.response;
throw errorWithMessage;
} catch {
throw error;
}
}
throw error;
}
}

export const getInventoryLotDetailsBySectionNotMatch = async (
stockTakeSection: string,
stockTakeId?: number | null
) => {
console.log('🌐 [API] getInventoryLotDetailsBySectionNotMatch called with:', {
stockTakeSection,
stockTakeId
});
const encodedSection = encodeURIComponent(stockTakeSection);
let url = `${BASE_API_URL}/stockTakeRecord/inventoryLotDetailsBySectionNotMatch?stockTakeSection=${encodedSection}`;
if (stockTakeId != null && stockTakeId > 0) {
url += `&stockTakeId=${stockTakeId}`;
}
console.log(' [API] Full URL:', url);
const details = await serverFetchJson<InventoryLotDetailResponse[]>(
url,
{
method: "GET",
},
);
console.log('[API] Response received:', details);
return details;
}

+ 312
- 0
src/components/BagSearch/BagSearch.tsx 查看文件

@@ -0,0 +1,312 @@
"use client";

import { useEffect, useMemo, useState, useCallback } from "react";
import {
fetchBags,
fetchBagLotLines,
fetchBagConsumptions,
BagSummaryResponse,
BagLotLineResponse,
BagConsumptionResponse,
} from "@/app/api/bag/action";
import SearchResults, {
Column,
defaultPagingController,
} from "../SearchResults/SearchResults";
import { useTranslation } from "react-i18next";
import {
integerFormatter,
arrayToDateString,
arrayToDateTimeString,
} from "@/app/utils/formatUtil";
import {
Box,
Typography,
Stack,
IconButton,
Button,
} from "@mui/material";
import VisibilityIcon from "@mui/icons-material/Visibility";

type ViewLevel = "bag" | "lot" | "consumption";

const BagSearch: React.FC = () => {
const { t } = useTranslation("jo");

const [level, setLevel] = useState<ViewLevel>("bag");


const [selectedBag, setSelectedBag] = useState<BagSummaryResponse | null>(null);
const [selectedLotLine, setSelectedLotLine] = useState<BagLotLineResponse | null>(null);


const [bags, setBags] = useState<BagSummaryResponse[]>([]);
const [lotLines, setLotLines] = useState<BagLotLineResponse[]>([]);
const [consumptions, setConsumptions] = useState<BagConsumptionResponse[]>([]);
const [pagingController, setPagingController] = useState(defaultPagingController);
const [totalCount, setTotalCount] = useState(0);

const bagColumns = useMemo<Column<BagSummaryResponse>[]>(() => [
{
name: "bagName",
label: t("Bag Name"),
flex: 2,
},
{
name: "bagCode",
label: t("Bag Code"),
flex: 1,
},
{
name: "takenBagBalance",
label: t("Balance"),
align: "right",
headerAlign: "right",
renderCell: (row) => integerFormatter.format(row.takenBagBalance ?? 0),
},
{
name: "actions" as any,
label: t("Actions"),
align: "center",
headerAlign: "center",
renderCell: (row) => (
<IconButton
size="small"
color="primary"
onClick={() => handleViewBagDetail(row)}
>
<VisibilityIcon fontSize="small" />
</IconButton>
),
},
], [t]);


const lotColumns = useMemo<Column<BagLotLineResponse>[]>(() => [
{
name: "lotNo",
label: t("Lot No"),
flex: 2,
},
{
name: "startQty",
label: t("Start Qty"),
align: "right",
headerAlign: "right",
renderCell: (row) => integerFormatter.format(row.startQty ?? 0),
},
{
name: "consumedQty",
label: t("Consumed Qty"),
align: "right",
headerAlign: "right",
renderCell: (row) => integerFormatter.format(row.consumedQty ?? 0),
},
{
name: "scrapQty",
label: t("Scrap Qty"),
align: "right",
headerAlign: "right",
renderCell: (row) => integerFormatter.format(row.scrapQty ?? 0),
},
{
name: "balanceQty",
label: t("Balance Qty"),
align: "right",
headerAlign: "right",
renderCell: (row) => integerFormatter.format(row.balanceQty ?? 0),
},
{
name: "actions" as any,
label: t("Actions"),
align: "center",
headerAlign: "center",
renderCell: (row) => (
<IconButton
size="small"
color="primary"
onClick={() => handleViewLotDetail(row)}
>
<VisibilityIcon fontSize="small" />
</IconButton>
),
},
], [t]);


const consColumns = useMemo<Column<BagConsumptionResponse>[]>(() => [
{
name: "jobOrderCode",
label: t("Job Order Code"),
flex: 2,
},
{
name: "startQty",
label: t("Start Qty"),
align: "right",
headerAlign: "right",
renderCell: (row) => integerFormatter.format(row.startQty ?? 0),
},
{
name: "consumedQty",
label: t("Consumed Qty"),
align: "right",
headerAlign: "right",
renderCell: (row) => integerFormatter.format(row.consumedQty ?? 0),
},
{
name: "scrapQty",
label: t("Scrap Qty"),
align: "right",
headerAlign: "right",
renderCell: (row) => integerFormatter.format(row.scrapQty ?? 0),
},
{
name: "endQty",
label: t("End Qty"),
align: "right",
headerAlign: "right",
renderCell: (row) => integerFormatter.format(row.endQty ?? 0),
},
{
name: "date",
label: t("Date"),
renderCell: (row) =>
row.date ? arrayToDateString(row.date as any) : "-",
},
{
name: "time",
label: t("Time"),
renderCell: (row) =>
row.time ? arrayToDateTimeString(row.time as any) : "-",
},
], [t]);


useEffect(() => {
const load = async () => {
const data = await fetchBags();
const safe = data ?? [];
setBags(safe);
setTotalCount(safe.length);
setPagingController(defaultPagingController);
setLevel("bag");
setSelectedBag(null);
setSelectedLotLine(null);
setLotLines([]);
setConsumptions([]);
};
load();
}, []);

const handleViewBagDetail = useCallback(async (row: BagSummaryResponse) => {
setSelectedBag(row);
setLevel("lot");
setPagingController(defaultPagingController);
setSelectedLotLine(null);
setConsumptions([]);
const data = await fetchBagLotLines(row.id);
const safe = data ?? [];
setLotLines(safe);
setTotalCount(safe.length);
}, []);


const handleViewLotDetail = useCallback(async (row: BagLotLineResponse) => {
setSelectedLotLine(row);
setLevel("consumption");
setPagingController(defaultPagingController);
const data = await fetchBagConsumptions(row.id);
const safe = data ?? [];
setConsumptions(safe);
setTotalCount(safe.length);
}, []);


const handleBack = useCallback(async () => {
if (level === "consumption" && selectedBag) {

setLevel("lot");
setSelectedLotLine(null);
setPagingController(defaultPagingController);
const data = await fetchBagLotLines(selectedBag.id);
const safe = data ?? [];
setLotLines(safe);
setTotalCount(safe.length);
return;
}
if (level === "lot") {

setLevel("bag");
setSelectedBag(null);
setSelectedLotLine(null);
setPagingController(defaultPagingController);
setTotalCount(bags.length);
return;
}
}, [level, selectedBag, bags]);


const { title, items, columns } = useMemo(() => {
if (level === "bag") {
return {
title: t("Bag List"),
items: bags,
columns: bagColumns,
};
}
if (level === "lot") {
return {
title: `${t("Bag Lot Lines")}${
selectedBag ? ` - ${selectedBag.bagName ?? ""} (${selectedBag.bagCode ?? ""})` : ""
}`,
items: lotLines,
columns: lotColumns,
};
}
return {
title: `${t("Bag Consumption Records")}${
selectedLotLine ? ` - ${selectedLotLine.lotNo ?? ""}` : ""
}`,
items: consumptions,
columns: consColumns,
};
}, [level, t, bags, bagColumns, lotLines, lotColumns, consumptions, consColumns, selectedBag, selectedLotLine]);



return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="h5">
{t(" ")}
</Typography>

<Stack direction="row" spacing={1}>
{level !== "bag" && (
<Button variant="outlined" onClick={handleBack}>
{t("Back")}
</Button>
)}
</Stack>
</Stack>

<Typography variant="h6" gutterBottom>
{title}
</Typography>

<SearchResults<any>
items={items ?? []}
columns={columns as any}
setPagingController={setPagingController}
pagingController={pagingController}
totalCount={totalCount}
isAutoPaging={false}
/>
</Box>
);
};

export default BagSearch;

+ 15
- 0
src/components/BagSearch/BagSearchWrapper.tsx 查看文件

@@ -0,0 +1,15 @@
import React from "react";
import GeneralLoading from "../General/GeneralLoading";
import BagSearch from "./BagSearch";

interface SubComponents {
Loading: typeof GeneralLoading;
}

const BagSearchWrapper: React.FC & SubComponents = async () => {
return <BagSearch />
}

BagSearchWrapper.Loading = GeneralLoading;

export default BagSearchWrapper;

+ 1
- 0
src/components/BagSearch/index.ts 查看文件

@@ -0,0 +1 @@
export { default } from "./BagSearchWrapper"

+ 261
- 161
src/components/JoSearch/JoSearch.tsx 查看文件

@@ -1,5 +1,5 @@
"use client"
import { SearchJoResultRequest, fetchJos, updateJo,updateProductProcessPriority } from "@/app/api/jo/actions";
import { SearchJoResultRequest, fetchJos, updateJo, updateProductProcessPriority, updateJoReqQty } from "@/app/api/jo/actions";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Criterion } from "../SearchBox";
@@ -12,7 +12,7 @@ import { useRouter } from "next/navigation";
import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form";
import { StockInLineInput } from "@/app/api/stockIn";
import { JobOrder, JoDetailPickLine, JoStatus } from "@/app/api/jo";
import { Button, Stack, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, InputAdornment } from "@mui/material";
import { Button, Stack, Dialog, DialogTitle, DialogContent, DialogActions, TextField, IconButton, InputAdornment, Typography, Box } from "@mui/material";
import { BomCombo } from "@/app/api/bom";
import JoCreateFormModal from "./JoCreateFormModal";
import AddIcon from '@mui/icons-material/Add';
@@ -55,14 +55,16 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT

const [inventoryData, setInventoryData] = useState<InventoryResult[]>([]);
const [detailedJos, setDetailedJos] = useState<Map<number, JobOrder>>(new Map());
const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false);
const [operationPriority, setOperationPriority] = useState<number>(50);
const [selectedJo, setSelectedJo] = useState<JobOrder | null>(null);
const [selectedProductProcessId, setSelectedProductProcessId] = useState<number | null>(null);
const [openPlanStartDialog, setOpenPlanStartDialog] = useState(false);
const [planStartDate, setPlanStartDate] = useState<dayjs.Dayjs | null>(null);
const [selectedJoForDate, setSelectedJoForDate] = useState<JobOrder | null>(null);
// 合并后的统一编辑 Dialog 状态
const [openEditDialog, setOpenEditDialog] = useState(false);
const [selectedJoForEdit, setSelectedJoForEdit] = useState<JobOrder | null>(null);
const [editPlanStartDate, setEditPlanStartDate] = useState<dayjs.Dayjs | null>(null);
const [editReqQtyMultiplier, setEditReqQtyMultiplier] = useState<number>(1);
const [editBomForReqQty, setEditBomForReqQty] = useState<BomCombo | null>(null);
const [editProductionPriority, setEditProductionPriority] = useState<number>(50);
const [editProductProcessId, setEditProductProcessId] = useState<number | null>(null);
const fetchJoDetailClient = async (id: number): Promise<JobOrder> => {
const response = await fetch(`/api/jo/detail?id=${id}`);
if (!response.ok) {
@@ -111,32 +113,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
fetchInventoryData();
}, []);
const handleOpenPriorityDialog = useCallback(async (jo: JobOrder) => {
setSelectedJo(jo);
setOperationPriority(jo.productionPriority ?? 50);
// 获取 productProcessId
try {
const { fetchProductProcessesByJobOrderId } = await import("@/app/api/jo/actions");
const processes = await fetchProductProcessesByJobOrderId(jo.id);
if (processes && processes.length > 0) {
setSelectedProductProcessId(processes[0].id);
setOpenOperationPriorityDialog(true);
} else {
msg(t("No product process found for this job order"));
}
} catch (error) {
console.error("Error fetching product process:", error);
msg(t("Error loading product process"));
}
}, [t]);
const handleClosePriorityDialog = useCallback((_event?: object, _reason?: "backdropClick" | "escapeKeyDown") => {
setOpenOperationPriorityDialog(false);
setSelectedJo(null);
setSelectedProductProcessId(null);
}, []);

const getStockAvailable = (pickLine: JoDetailPickLine) => {
const inventory = inventoryData.find(inventory =>
inventory.itemCode === pickLine.code || inventory.itemName === pickLine.name
@@ -175,19 +151,72 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
options: jobTypes.map(jt => jt.name)
},
], [t, jobTypes])
const handleOpenPlanStartDialog = useCallback((jo: JobOrder) => {
setSelectedJoForDate(jo);
// 将 planStart 数组转换为 dayjs 对象
const fetchBomForJo = useCallback(async (jo: JobOrder): Promise<BomCombo | null> => {
try {
const detailedJo = detailedJos.get(jo.id) || await fetchJoDetailClient(jo.id);
const matchingBom = bomCombo.find(bom => {
return true; // 临时占位
});
return matchingBom || null;
} catch (error) {
console.error("Error fetching BOM for JO:", error);
return null;
}
}, [bomCombo, detailedJos]);
// 统一的打开编辑对话框函数
const handleOpenEditDialog = useCallback(async (jo: JobOrder) => {
setSelectedJoForEdit(jo);
// 设置 Plan Start Date
if (jo.planStart && Array.isArray(jo.planStart)) {
setPlanStartDate(arrayToDayjs(jo.planStart));
setEditPlanStartDate(arrayToDayjs(jo.planStart));
} else {
setPlanStartDate(dayjs());
setEditPlanStartDate(dayjs());
}
setOpenPlanStartDialog(true);
// 设置 Production Priority
setEditProductionPriority(jo.productionPriority ?? 50);
// 获取 productProcessId
try {
const { fetchProductProcessesByJobOrderId } = await import("@/app/api/jo/actions");
const processes = await fetchProductProcessesByJobOrderId(jo.id);
if (processes && processes.length > 0) {
setEditProductProcessId(processes[0].id);
}
} catch (error) {
console.error("Error fetching product process:", error);
}
// 设置 ReqQty
const bom = await fetchBomForJo(jo);
if (bom) {
setEditBomForReqQty(bom);
const currentMultiplier = bom.outputQty > 0
? Math.round(jo.reqQty / bom.outputQty)
: 1;
setEditReqQtyMultiplier(currentMultiplier);
}
setOpenEditDialog(true);
}, [fetchBomForJo]);
// 统一的关闭函数
const handleCloseEditDialog = useCallback((_event?: object, _reason?: "backdropClick" | "escapeKeyDown") => {
setOpenEditDialog(false);
setSelectedJoForEdit(null);
setEditPlanStartDate(null);
setEditReqQtyMultiplier(1);
setEditBomForReqQty(null);
setEditProductionPriority(50);
setEditProductProcessId(null);
}, []);
const columns = useMemo<Column<JobOrder>[]>(
() => [
{
name: "planStart",
label: t("Estimated Production Date"),
@@ -196,19 +225,20 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
renderCell: (row) => {
return (
<Stack direction="row" alignItems="center" spacing={1}>
<span>{row.planStart ? arrayToDateString(row.planStart) : '-'}</span>
{row.status == "planning" && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleOpenPlanStartDialog(row);
}}
sx={{ padding: '4px' }}
>
<EditIcon fontSize="small" />
</IconButton>
{row.status == "planning" && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleOpenEditDialog(row);
}}
sx={{ padding: '4px' }}
>
<EditIcon fontSize="small" />
</IconButton>
)}
<span>{row.planStart ? arrayToDateString(row.planStart) : '-'}</span>
</Stack>
);
}
@@ -220,16 +250,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
return (
<Stack direction="row" alignItems="center" spacing={1}>
<span>{integerFormatter.format(row.productionPriority)}</span>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleOpenPriorityDialog(row);
}}
sx={{ padding: '4px' }}
>
<EditIcon fontSize="small" />
</IconButton>
</Stack>
);
}
@@ -239,12 +260,11 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
label: t("Code"),
flex: 2
},

{
name: "item",
label: `${t("Item Name")}`,
renderCell: (row) => {
return row.item ? <>{t(row.item.code)} {t(row.item.name)}</> : '-'
return row.item ? <>{t(row.jobTypeName)} {t(row.item.code)} {t(row.item.name)}</> : '-'
}
},
{
@@ -253,7 +273,12 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
align: "right",
headerAlign: "right",
renderCell: (row) => {
return integerFormatter.format(row.reqQty)
return (
<Stack direction="row" alignItems="center" spacing={1} justifyContent="flex-end">
<span>{integerFormatter.format(row.reqQty)}</span>
</Stack>
);
}
},
{
@@ -288,13 +313,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
</span>
}
},
{
name: "jobTypeName",
label: t("Job Type"),
renderCell: (row) => {
return row.jobTypeName ? t(row.jobTypeName) : '-'
}
},
{
name: "id",
label: t("Actions"),
@@ -313,10 +331,9 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
)
}
},
], [t, inventoryData, detailedJos, handleOpenPriorityDialog,handleOpenPlanStartDialog]
], [t, inventoryData, detailedJos, handleOpenEditDialog]
)

// 按照 PoSearch 的模式:创建 newPageFetch 函数
const newPageFetch = useCallback(
async (
pagingController: { pageNum: number; pageSize: number },
@@ -333,7 +350,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
if (response && response.records) {
console.log("newPageFetch - setting filteredJos with", response.records.length, "records");
setTotalCount(response.total);
// 后端已经按 id DESC 排序,不需要再次排序
setFilteredJos(response.records);
console.log("newPageFetch - filteredJos set, first record id:", response.records[0]?.id);
} else {
@@ -343,21 +359,73 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
},
[],
);
const handleUpdateReqQty = useCallback(async (jobOrderId: number, newReqQty: number) => {
try {
const response = await updateJoReqQty({
id: jobOrderId,
reqQty: newReqQty
});
if (response) {
msg(t("update success"));
await newPageFetch(pagingController, inputs);
}
} catch (error) {
console.error("Error updating reqQty:", error);
msg(t("update failed"));
}
}, [pagingController, inputs, newPageFetch, t]);
const handleUpdatePlanStart = useCallback(async (jobOrderId: number, planStart: string) => {
const response = await updateJoPlanStart({ id: jobOrderId, planStart });
if (response) {
await newPageFetch(pagingController, inputs);
}
}, [pagingController, inputs, newPageFetch]);
const handleUpdateOperationPriority = useCallback(async (productProcessId: number, productionPriority: number) => {
const response = await updateProductProcessPriority(productProcessId, productionPriority)
if (response) {
// 刷新数据
await newPageFetch(pagingController, inputs);
}
}, [pagingController, inputs, newPageFetch]);
const handleConfirmPriority = useCallback(async () => {
if (!selectedProductProcessId) return;
await handleUpdateOperationPriority(selectedProductProcessId, Number(operationPriority));
setOpenOperationPriorityDialog(false);
setSelectedJo(null);
setSelectedProductProcessId(null);
}, [selectedProductProcessId, operationPriority, handleUpdateOperationPriority]);
// 按照 PoSearch 的模式:使用相同的 useEffect 逻辑
// 统一的确认函数
const handleConfirmEdit = useCallback(async () => {
if (!selectedJoForEdit) return;
try {
// 更新 Plan Start
if (editPlanStartDate) {
const dateString = `${dayjsToDateString(editPlanStartDate, "input")}T00:00:00`;
await handleUpdatePlanStart(selectedJoForEdit.id, dateString);
}
// 更新 ReqQty
if (editBomForReqQty) {
const newReqQty = editReqQtyMultiplier * editBomForReqQty.outputQty;
await handleUpdateReqQty(selectedJoForEdit.id, newReqQty);
}
// 更新 Production Priority
if (editProductProcessId) {
await handleUpdateOperationPriority(editProductProcessId, Number(editProductionPriority));
}
setOpenEditDialog(false);
setSelectedJoForEdit(null);
setEditPlanStartDate(null);
setEditReqQtyMultiplier(1);
setEditBomForReqQty(null);
setEditProductionPriority(50);
setEditProductProcessId(null);
} catch (error) {
console.error("Error updating:", error);
msg(t("update failed"));
}
}, [selectedJoForEdit, editPlanStartDate, editBomForReqQty, editReqQtyMultiplier, editProductionPriority, editProductProcessId, handleUpdatePlanStart, handleUpdateReqQty, handleUpdateOperationPriority, t]);
useEffect(() => {
newPageFetch(pagingController, inputs);
}, [newPageFetch, pagingController, inputs]);
@@ -378,7 +446,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
const res = await createStockInLine(postData);
console.log(`%c Created Stock In Line`, "color:lime", res);
msg(t("update success"));
// 重置为默认输入,让 useEffect 自动触发
setInputs(defaultInputs);
setPagingController(defaultPagingController);
}
@@ -427,7 +494,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT

const closeNewModal = useCallback(() => {
setOpenModal(false);
setInputs(defaultInputs);
setPagingController(defaultPagingController);
}, [defaultInputs]);
@@ -440,7 +506,6 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
jobTypeName: query.jobTypeName && query.jobTypeName !== "All" ? query.jobTypeName : ""
};
setInputs({
code: transformedQuery.code,
itemName: transformedQuery.itemName,
@@ -452,38 +517,11 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
setPagingController(defaultPagingController);
}, [defaultInputs])

const handleClosePlanStartDialog = useCallback((_event?: object, _reason?: "backdropClick" | "escapeKeyDown") => {
setOpenPlanStartDialog(false);
setSelectedJoForDate(null);
setPlanStartDate(null);
}, []);
const handleUpdatePlanStart = useCallback(async (jobOrderId: number, planStart: string) => {
const response = await updateJoPlanStart({ id: jobOrderId, planStart });
if (response) {
// 刷新数据
await newPageFetch(pagingController, inputs);
}
}, [pagingController, inputs, newPageFetch]);
const handleConfirmPlanStart = useCallback(async () => {
if (!selectedJoForDate?.id || !planStartDate) return;
// 将日期转换为后端需要的格式 (YYYY-MM-DDTHH:mm:ss)
const dateString = `${dayjsToDateString(planStartDate, "input")}T00:00:00`;
await handleUpdatePlanStart(selectedJoForDate.id, dateString);
setOpenPlanStartDialog(false);
setSelectedJoForDate(null);
setPlanStartDate(null);
}, [selectedJoForDate, planStartDate, handleUpdatePlanStart]);

const onReset = useCallback(() => {
setInputs(defaultInputs);
setPagingController(defaultPagingController);
}, [defaultInputs])


const onOpenCreateJoModal = useCallback(() => {
setIsCreateJoModalOpen(() => true)
}, [])
@@ -526,7 +564,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
jobTypes={jobTypes}
onClose={onCloseCreateJoModal}
onSearch={() => {
setInputs({ ...defaultInputs }); // 创建新对象,确保引用变化
setInputs({ ...defaultInputs });
setPagingController(defaultPagingController);
}}
/>
@@ -538,66 +576,128 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
inputDetail={modalInfo}
printerCombo={printerCombo}
/>
<Dialog
open={openOperationPriorityDialog}
onClose={handleClosePriorityDialog}
fullWidth
maxWidth="xs"
>
<DialogTitle>{t("Update Production Priority")}</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label={t("Production Priority")}
type="number"
fullWidth
value={operationPriority}
onChange={(e) => setOperationPriority(Number(e.target.value))}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClosePriorityDialog}>{t("Cancel")}</Button>
<Button variant="contained" onClick={handleConfirmPriority}>{t("Save")}</Button>
</DialogActions>
</Dialog>
<Dialog
open={openPlanStartDialog}
onClose={handleClosePlanStartDialog}
{/* 合并后的统一编辑 Dialog */}
<Dialog
open={openEditDialog}
onClose={handleCloseEditDialog}
fullWidth
maxWidth="xs"
maxWidth="sm"
>
<DialogTitle>{t("Update Estimated Production Date")}</DialogTitle>
<DialogTitle>{t("Edit Job Order")}</DialogTitle>
<DialogContent>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
label={t("Estimated Production Date")}
value={planStartDate}
onChange={(newValue) => setPlanStartDate(newValue)}
slotProps={{
textField: {
fullWidth: true,
margin: "dense",
autoFocus: true,
<Stack spacing={3} sx={{ mt: 1 }}>
{/* Plan Start Date */}
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
label={t("Estimated Production Date")}
value={editPlanStartDate}
onChange={(newValue) => setEditPlanStartDate(newValue)}
slotProps={{
textField: {
fullWidth: true,
margin: "dense",
}
}}
/>
</LocalizationProvider>
{/* Production Priority */}
<TextField
label={t("Production Priority")}
type="number"
fullWidth
value={editProductionPriority}
onChange={(e) => {
const val = Number(e.target.value);
if (val >= 1 && val <= 100) {
setEditProductionPriority(val);
}
}}
inputProps={{
min: 1,
max: 100,
step: 1
}}
/>
</LocalizationProvider>
{/* ReqQty */}
{editBomForReqQty && (
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<TextField
label={t("Base Qty")}
fullWidth
type="number"
variant="outlined"
value={editBomForReqQty.outputQty}
disabled
InputProps={{
endAdornment: editBomForReqQty.outputQtyUom ? (
<InputAdornment position="end">
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{editBomForReqQty.outputQtyUom}
</Typography>
</InputAdornment>
) : null
}}
sx={{ flex: 1 }}
/>
<Typography variant="body1" sx={{ color: "text.secondary" }}>
×
</Typography>
<TextField
label={t("Batch Count")}
fullWidth
type="number"
variant="outlined"
value={editReqQtyMultiplier}
onChange={(e) => {
const val = e.target.value === "" ? 1 : Math.max(1, Math.floor(Number(e.target.value)));
setEditReqQtyMultiplier(val);
}}
inputProps={{
min: 1,
step: 1
}}
sx={{ flex: 1 }}
/>
<Typography variant="body1" sx={{ color: "text.secondary" }}>
=
</Typography>
<TextField
label={t("Req. Qty")}
fullWidth
variant="outlined"
type="number"
value={editBomForReqQty ? (editReqQtyMultiplier * editBomForReqQty.outputQty) : ""}
disabled
InputProps={{
endAdornment: editBomForReqQty.outputQtyUom ? (
<InputAdornment position="end">
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{editBomForReqQty.outputQtyUom}
</Typography>
</InputAdornment>
) : null
}}
sx={{ flex: 1 }}
/>
</Box>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleClosePlanStartDialog}>{t("Cancel")}</Button>
<Button onClick={handleCloseEditDialog}>{t("Cancel")}</Button>
<Button
variant="contained"
onClick={handleConfirmPlanStart}
disabled={!planStartDate}
onClick={handleConfirmEdit}
disabled={!editPlanStartDate || !editBomForReqQty}
>
{t("Save")}
</Button>
</DialogActions>
</Dialog>
</>


}

export default JoSearch;

+ 7
- 1
src/components/ProductionProcess/BagConsumptionForm.tsx 查看文件

@@ -39,6 +39,7 @@ interface BagConsumptionFormProps {
lineId: number;
bomDescription?: string;
isLastLine: boolean;
submitedBagRecord?: boolean;
onRefresh?: () => void;
}

@@ -47,6 +48,7 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({
lineId,
bomDescription,
isLastLine,
submitedBagRecord,
onRefresh,
}) => {
const { t } = useTranslation(["common", "jo"]);
@@ -59,8 +61,12 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({

// 判断是否显示表单
const shouldShow = useMemo(() => {
// 如果 submitedBagRecord 为 true,则不显示表单
if (submitedBagRecord === true) {
return false;
}
return bomDescription === "FG" && isLastLine;
}, [bomDescription, isLastLine]);
}, [bomDescription, isLastLine, submitedBagRecord]);

// 加载 Bag 列表
useEffect(() => {


+ 455
- 203
src/components/ProductionProcess/ProductionProcessDetail.tsx 查看文件

@@ -1,5 +1,8 @@
"use client";
import React, { useCallback, useEffect, useState, useRef } from "react";
import EditIcon from "@mui/icons-material/Edit";
import AddIcon from '@mui/icons-material/Add';
import Fab from '@mui/material/Fab';
import {
Box,
Button,
@@ -21,6 +24,7 @@ import {
DialogTitle,
DialogContent,
DialogActions,
IconButton
} from "@mui/material";
import QrCodeIcon from '@mui/icons-material/QrCode';
import { useTranslation } from "react-i18next";
@@ -40,9 +44,12 @@ import {
ProductProcessLineInfoResponse,
startProductProcessLine,
fetchProductProcessesByJobOrderId,
ProductProcessWithLinesResponse, // 添加
ProductProcessWithLinesResponse, // 添加
ProductProcessLineResponse,
passProductProcessLine,
newProductProcessLine,
updateProductProcessLineProcessingTimeSetupTimeChangeoverTime,
UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest,
} from "@/app/api/jo/actions";
import { updateProductProcessLineStatus } from "@/app/api/jo/actions";

@@ -61,16 +68,21 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({
onBack,
fromJosave,
}) => {
console.log(" ProductionProcessDetail RENDER", { jobOrderId, fromJosave });
const { t } = useTranslation("common");
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const { values: qrValues, startScan, stopScan, resetScan } = useQrCodeScannerContext();
const [showOutputPage, setShowOutputPage] = useState(false);
// 基本信息
const [processData, setProcessData] = useState<ProductProcessWithLinesResponse | null>(null); // 修改类型
const [lines, setLines] = useState<ProductProcessLineResponse[]>([]); // 修改类型
const [processData, setProcessData] = useState<ProductProcessWithLinesResponse | null>(null); // 修改类型
const [lines, setLines] = useState<ProductProcessLineResponse[]>([]); // 修改类型
const [loading, setLoading] = useState(false);
const linesRef = useRef<ProductProcessLineResponse[]>([]);
const onBackRef = useRef(onBack);
const fetchProcessDetailRef = useRef<() => Promise<void>>();

// 选中的 line 和执行状态
const [selectedLineId, setSelectedLineId] = useState<number | null>(null);
const [isExecutingLine, setIsExecutingLine] = useState(false);
@@ -88,8 +100,14 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({
const [lineDetailForScan, setLineDetailForScan] = useState<JobOrderProcessLineDetailResponse | null>(null);
const [showScanDialog, setShowScanDialog] = useState(false);
const autoSubmitTimerRef = useRef<NodeJS.Timeout | null>(null);
const [openTimeDialog, setOpenTimeDialog] = useState(false);
const [editingLineId, setEditingLineId] = useState<number | null>(null);
const [timeValues, setTimeValues] = useState({
durationInMinutes: 0,
prepTimeInMinutes: 0,
postProdTimeInMinutes: 0,
});

// 产出表单
const [outputData, setOutputData] = useState({
byproductName: "",
byproductQty: "",
@@ -110,43 +128,122 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({
setSelectedLineId(null);
setShowOutputPage(false);
};

useEffect(() => {
onBackRef.current = onBack;
}, [onBack]);
// 获取 process 和 lines 数据
const fetchProcessDetail = useCallback(async () => {
console.log(" fetchProcessDetail CALLED", { jobOrderId, timestamp: new Date().toISOString() });
setLoading(true);
try {
console.log(`🔍 Loading process detail for JobOrderId: ${jobOrderId}`);
console.log(` Loading process detail for JobOrderId: ${jobOrderId}`);
// 使用 fetchProductProcessesByJobOrderId 获取基础数据
const processesWithLines = await fetchProductProcessesByJobOrderId(jobOrderId);
if (!processesWithLines || processesWithLines.length === 0) {
throw new Error("No processes found for this job order");
}
// 如果有多个 process,取第一个(或者可以根据需要选择)
const currentProcess = processesWithLines[0];
setProcessData(currentProcess);
// 使用 productProcessLines 字段(API 返回的字段名)
const lines = currentProcess.productProcessLines || [];
setLines(lines);
console.log(" Process data loaded:", currentProcess);
console.log(" Lines loaded:", lines);
linesRef.current = lines;
console.log(" Process data loaded:", currentProcess);
console.log(" Lines loaded:", lines);
} catch (error) {
console.error("❌ Error loading process detail:", error);
//alert(`无法加载 Job Order ID ${jobOrderId} 的生产流程。该记录可能不存在。`);
onBack();
console.error(" Error loading process detail:", error);
onBackRef.current();
} finally {
setLoading(false);
}
}, [jobOrderId, onBack]);

}, [jobOrderId]);
const handleOpenTimeDialog = useCallback((lineId: number) => {
console.log("🔓 handleOpenTimeDialog CALLED", { lineId, timestamp: new Date().toISOString() });
// 直接使用 linesRef.current,避免触发 setLines
const line = linesRef.current.find(l => l.id === lineId);
if (line) {
console.log(" Found line:", line);
setEditingLineId(lineId);
setTimeValues({
durationInMinutes: line.durationInMinutes || 0,
prepTimeInMinutes: line.prepTimeInMinutes || 0,
postProdTimeInMinutes: line.postProdTimeInMinutes || 0,
});
setOpenTimeDialog(true);
console.log(" Dialog opened");
} else {
console.warn(" Line not found:", lineId);
}
}, []);
useEffect(() => {
fetchProcessDetail();
fetchProcessDetailRef.current = fetchProcessDetail;
}, [fetchProcessDetail]);
const handleCloseTimeDialog = useCallback(() => {
console.log("🔒 handleCloseTimeDialog CALLED", { timestamp: new Date().toISOString() });
setOpenTimeDialog(false);
setEditingLineId(null);
setTimeValues({
durationInMinutes: 0,
prepTimeInMinutes: 0,
postProdTimeInMinutes: 0,
});
console.log(" Dialog closed");
}, []);
const handleConfirmTimeUpdate = useCallback(async () => {
console.log("💾 handleConfirmTimeUpdate CALLED", { editingLineId, timeValues, timestamp: new Date().toISOString() });
if (!editingLineId) return;
try {
const request: UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest = {
productProcessLineId: editingLineId,
processingTime: timeValues.durationInMinutes,
setupTime: timeValues.prepTimeInMinutes,
changeoverTime: timeValues.postProdTimeInMinutes,
};
await updateProductProcessLineProcessingTimeSetupTimeChangeoverTime(editingLineId, request);
await fetchProcessDetail();
handleCloseTimeDialog();
} catch (error) {
console.error("Error updating time:", error);
alert(t("update failed"));
}
}, [editingLineId, timeValues, fetchProcessDetail, handleCloseTimeDialog, t]);
useEffect(() => {
console.log("🔄 useEffect [jobOrderId] TRIGGERED", {
jobOrderId,
timestamp: new Date().toISOString()
});
if (fetchProcessDetailRef.current) {
fetchProcessDetailRef.current();
}
}, [jobOrderId]);

// 添加监听 openTimeDialog 变化的 useEffect
useEffect(() => {
console.log(" openTimeDialog changed:", { openTimeDialog, timestamp: new Date().toISOString() });
}, [openTimeDialog]);

// 添加监听 timeValues 变化的 useEffect
useEffect(() => {
console.log(" timeValues changed:", { timeValues, timestamp: new Date().toISOString() });
}, [timeValues]);

// 添加监听 lines 变化的 useEffect
useEffect(() => {
console.log(" lines changed:", { count: lines.length, lines, timestamp: new Date().toISOString() });
}, [lines]);

// 添加监听 editingLineId 变化的 useEffect
useEffect(() => {
console.log(" editingLineId changed:", { editingLineId, timestamp: new Date().toISOString() });
}, [editingLineId]);

const handlePassLine = useCallback(async (lineId: number) => {
try {
@@ -158,7 +255,16 @@ const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({
alert(t("Failed to pass line. Please try again."));
}
}, [fetchProcessDetail, t]);

const handleCreateNewLine = useCallback(async (lineId: number) => {
try {
await newProductProcessLine(lineId);
// 刷新数据
await fetchProcessDetail();
} catch (error) {
console.error("Error creating new line:", error);
alert(t("Failed to create new line. Please try again."));
}
}, [fetchProcessDetail, t]);
// 提交产出数据
const processQrCode = useCallback((qrValue: string, lineId: number) => {
// 设备快捷格式:{2fitesteXXX} - XXX 直接作为设备代码
@@ -257,7 +363,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
try {
const lineDetail = lineDetailForScan || await fetchProductProcessLineDetail(lineId);
// 统一使用一个最终的 equipmentCode(优先用 scannedEquipmentCode,其次用 scannedEquipmentTypeSubTypeEquipmentNo)
// 统一使用一个最终的 equipmentCode(优先用 scannedEquipmentCode,其次用 scannedEquipmentTypeSubTypeEquipmentNo)
const effectiveEquipmentCode =
scannedEquipmentCode ?? null;
@@ -340,7 +446,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
setProcessedQrCodes(new Set());
setScannedOperatorId(null);
setScannedEquipmentId(null);
setScannedStaffNo(null); // Add this
setScannedStaffNo(null); // Add this
setScannedEquipmentCode(null);
setIsAutoSubmitting(false); // 添加:重置自动提交状态
setLineDetailForScan(null);
@@ -366,7 +472,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
setIsManualScanning(false);
setIsAutoSubmitting(false);
setScannedStaffNo(null); // Add this
setScannedStaffNo(null); // Add this
setScannedEquipmentCode(null);
stopScan();
resetScan();
@@ -392,7 +498,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
isManualScanning,
});

// Update condition to check for either equipmentTypeSubTypeEquipmentNo OR equipmentDetailId
// Update condition to check for either equipmentTypeSubTypeEquipmentNo OR equipmentDetailId
if (
scanningLineId &&
scannedStaffNo !== null &&
@@ -455,6 +561,13 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
};
const selectedLine = lines.find(l => l.id === selectedLineId);

// 添加组件卸载日志
useEffect(() => {
return () => {
console.log("🗑️ ProductionProcessDetail UNMOUNTING");
};
}, []);

if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
@@ -474,188 +587,230 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
{!isExecutingLine ? (
/* ========== 步骤列表视图 ========== */
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Seq")}</TableCell>
<TableCell>{t("Step Name")}</TableCell>
<TableCell>{t("Description")}</TableCell>
<TableCell>{t("EquipmentType-EquipmentName-Code")}</TableCell>
<TableCell>{t("Operator")}</TableCell>
<TableCell>{t("Assume End Time")}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Time Information(mins)")}
</Typography>
</Box>
</TableCell>
<TableCell align="center">{t("Status")}</TableCell>
{!fromJosave&&(<TableCell align="center">{t("Action")}</TableCell>)}
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => {
const status = (line as any).status || '';
const statusLower = status.toLowerCase();
const equipmentName = line.equipment_name || "-";
const isCompleted = statusLower === 'completed';
const isInProgress = statusLower === 'inprogress' || statusLower === 'in progress';
const isPaused = statusLower === 'paused';
const isPending = statusLower === 'pending' || status === '';
const isPass = statusLower === 'pass';
const isPassDisabled = isCompleted || isPass;
return (
<TableRow key={line.id}>
<TableCell>{line.seqNo}</TableCell>
<TableCell>
<Typography fontWeight={500}>{line.name}</Typography>
</TableCell>
<TableCell><Typography fontWeight={500}>{line.description || "-"}</Typography></TableCell>
<TableCell><Typography fontWeight={500}>{line.equipmentDetailCode||equipmentName}</Typography></TableCell>
<TableCell><Typography fontWeight={500}>{line.operatorName}</Typography></TableCell>
<TableCell>
<Typography fontWeight={500}>
{line.startTime && line.durationInMinutes
? dayjs(line.startTime)
.add(line.durationInMinutes, 'minute')
.format('MM-DD HH:mm')
: '-'}
</Typography>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Typography variant="body2" >
{t("Processing Time")}: {line.durationInMinutes}{t("mins")}
</Typography>
<Typography variant="body2" >
{t("Setup Time")}: {line.prepTimeInMinutes} {t("mins")}
</Typography>
<Typography variant="body2" >
{t("Changeover Time")}: {line.postProdTimeInMinutes} {t("mins")}
</Typography>
</Box>
</TableCell>
<TableCell align="center">
{isCompleted ? (
<Chip label={t("Completed")} color="success" size="small"
onClick={async () => {
setSelectedLineId(line.id);
setShowOutputPage(false); // 不显示输出页面
setIsExecutingLine(true);
await fetchProcessDetail();
}}
/>
) : isInProgress ? (
<Chip label={t("In Progress")} color="primary" size="small"
onClick={async () => {
setSelectedLineId(line.id);
setShowOutputPage(false); // 不显示输出页面
setIsExecutingLine(true);
await fetchProcessDetail();
}} />
) : isPending ? (
<Chip label={t("Pending")} color="default" size="small" />
) : isPaused ? (
<Chip label={t("Paused")} color="warning" size="small" />
) : isPass ? (
<Chip label={t("Pass")} color="success" size="small" />
) : (
<Chip label={t("Unknown")} color="error" size="small" />
)
}
</TableCell>
{!fromJosave&&(
<TableCell align="center">
<Stack direction="row" spacing={1} justifyContent="center">
{statusLower === 'pending' ? (
<>
<Button
variant="contained"
size="small"
startIcon={<PlayArrowIcon />}
onClick={() => handleStartLineWithScan(line.id)}
>
{t("Start")}
</Button>
<Button
variant="outlined"
size="small"
color="success"
onClick={() => handlePassLine(line.id)}
disabled={isPassDisabled}
>
{t("Pass")}
</Button>
</>
) : statusLower === 'in_progress' || statusLower === 'in progress' || statusLower === 'paused' ? (
<>
<Button
variant="contained"
size="small"
startIcon={<CheckCircleIcon />}
onClick={async () => {
setSelectedLineId(line.id);
setShowOutputPage(false);
setIsExecutingLine(true);
await fetchProcessDetail();
}}
>
{t("View")}
</Button>
<Button
variant="outlined"
size="small"
color="success"
onClick={() => handlePassLine(line.id)}
disabled={isPassDisabled}
>
{t("Pass")}
</Button>
</>
) : (
<>
<Button
variant="outlined"
size="small"
onClick={async() => {
setSelectedLineId(line.id);
setIsExecutingLine(true);
await fetchProcessDetail();
}}
>
{t("View")}
</Button>
<Button
variant="outlined"
size="small"
color="success"
onClick={() => handlePassLine(line.id)}
disabled={isPassDisabled}
>
{t("Pass")}
</Button>
</>
)}
</Stack>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>{t(" ")}</TableCell>
<TableCell>{t("Seq")}</TableCell>
<TableCell>{t("Step Name")}</TableCell>
<TableCell>{t("Description")}</TableCell>
<TableCell>{t("EquipmentType-EquipmentName-Code")}</TableCell>
<TableCell>{t("Operator")}</TableCell>
<TableCell>{t("Assume End Time")}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{t("Time Information(mins)")}
</Typography>
</Box>
</TableCell>
<TableCell align="center">{t("Status")}</TableCell>
{!fromJosave&&(<TableCell align="center">{t("Action")}</TableCell>)}
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => {
const status = (line as any).status || '';
const statusLower = status.toLowerCase();
const equipmentName = line.equipment_name || "-";
const isCompleted = statusLower === 'completed';
const isInProgress = statusLower === 'inprogress' || statusLower === 'in progress';
const isPaused = statusLower === 'paused';
const isPending = statusLower === 'pending' || status === '';
const isPass = statusLower === 'pass';
const isPassDisabled = isCompleted || isPass;
return (
<TableRow key={line.id}>
<TableCell>
<Fab
size="small"
color="primary"
aria-label={t("Create New Line")}
onClick={() => handleCreateNewLine(line.id)}
sx={{
width: 32,
height: 32,
minHeight: 32,
boxShadow: 1,
'&:hover': { boxShadow: 3 },
}}
>
<AddIcon fontSize="small" />
</Fab>
</TableCell>
<TableCell>
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" textAlign="center">{line.seqNo}</Typography>
</Stack>
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={500}>{line.name}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={500} maxWidth={200} sx={{ wordBreak: 'break-word', whiteSpace: 'normal', lineHeight: 1.5 }}>{line.description || "-"}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={500}>{line.equipmentDetailCode||equipmentName}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={500}>{line.operatorName}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={500}>
{line.startTime && line.durationInMinutes
? dayjs(line.startTime)
.add(line.durationInMinutes, 'minute')
.format('MM-DD HH:mm')
: '-'}
</Typography>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">
{t("Processing Time")}: {line.durationInMinutes || 0}{t("mins")}
</Typography>
{processData?.jobOrderStatus === "planning" && (
<IconButton
size="small"
onClick={() => {
console.log("🖱️ Edit button clicked for line:", line.id);
handleOpenTimeDialog(line.id);
}}
sx={{ padding: 0.5 }}
>
<EditIcon fontSize="small" />
</IconButton>
)}
</Box>
<Typography variant="body2">
{t("Setup Time")}: {line.prepTimeInMinutes || 0} {t("mins")}
</Typography>
<Typography variant="body2">
{t("Changeover Time")}: {line.postProdTimeInMinutes || 0} {t("mins")}
</Typography>
</Box>
</TableCell>
<TableCell align="center">
{isCompleted ? (
<Chip label={t("Completed")} color="success" size="small"
onClick={async () => {
setSelectedLineId(line.id);
setShowOutputPage(false);
setIsExecutingLine(true);
await fetchProcessDetail();
}}
/>
) : isInProgress ? (
<Chip label={t("In Progress")} color="primary" size="small"
onClick={async () => {
setSelectedLineId(line.id);
setShowOutputPage(false);
setIsExecutingLine(true);
await fetchProcessDetail();
}} />
) : isPending ? (
<Chip label={t("Pending")} color="default" size="small" />
) : isPaused ? (
<Chip label={t("Paused")} color="warning" size="small" />
) : isPass ? (
<Chip label={t("Pass")} color="success" size="small" />
) : (
<Chip label={t("Unknown")} color="error" size="small" />
)
}
</TableCell>
{!fromJosave&&(
<TableCell align="center">
<Stack direction="row" spacing={1} justifyContent="center">
{statusLower === 'pending' ? (
<>
<Button
variant="contained"
size="small"
startIcon={<PlayArrowIcon />}
onClick={() => handleStartLineWithScan(line.id)}
>
{t("Start")}
</Button>
<Button
variant="outlined"
size="small"
color="success"
onClick={() => handlePassLine(line.id)}
disabled={isPassDisabled}
>
{t("Pass")}
</Button>
</>
) : statusLower === 'in_progress' || statusLower === 'in progress' || statusLower === 'paused' ? (
<>
<Button
variant="contained"
size="small"
startIcon={<CheckCircleIcon />}
onClick={async () => {
setSelectedLineId(line.id);
setShowOutputPage(false);
setIsExecutingLine(true);
await fetchProcessDetail();
}}
>
{t("View")}
</Button>
<Button
variant="outlined"
size="small"
color="success"
onClick={() => handlePassLine(line.id)}
disabled={isPassDisabled}
>
{t("Pass")}
</Button>
</>
) : (
<>
<Button
variant="outlined"
size="small"
onClick={async() => {
setSelectedLineId(line.id);
setIsExecutingLine(true);
await fetchProcessDetail();
}}
>
{t("View")}
</Button>
<Button
variant="outlined"
size="small"
color="success"
onClick={() => handlePassLine(line.id)}
disabled={isPassDisabled}
>
{t("Pass")}
</Button>
</>
)}
</Stack>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
) : (
/* ========== 步骤执行视图 ========== */
<ProductionProcessStepExecution
lineId={selectedLineId}
onBack={handleBackFromStep}
processData={processData} // ✅ 添加
allLines={lines} // ✅ 添加
jobOrderId={jobOrderId} // ✅ 添加
processData={processData} // 添加
allLines={lines} // 添加
jobOrderId={jobOrderId} // 添加
/>
)}
</Paper>
@@ -703,13 +858,14 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => {
<Button type="button" onClick={() => {
handleStopScan();
setShowScanDialog(false);
}}>
{t("Cancel")}
</Button>
<Button
type="button"
variant="contained"
onClick={() => scanningLineId && handleSubmitScanAndStart(scanningLineId)}
disabled={!scannedStaffNo }
@@ -718,6 +874,102 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
</Button>
</DialogActions>
</Dialog>
<Dialog
open={openTimeDialog}
onClose={handleCloseTimeDialog} // 直接传递函数,不要包装
fullWidth
maxWidth="sm"
>
<DialogTitle>{t("Update Time Information")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label={t("Processing Time (mins)")}
type="number"
fullWidth
value={timeValues.durationInMinutes}
onChange={(e) => {
console.log("⌨️ Processing Time onChange:", {
value: e.target.value,
openTimeDialog,
editingLineId,
timestamp: new Date().toISOString()
});
const value = e.target.value === '' ? 0 : parseInt(e.target.value) || 0;
setTimeValues(prev => ({
...prev,
durationInMinutes: Math.max(0, value)
}));
}}
inputProps={{
min: 0,
step: 1
}}
/>
<TextField
label={t("Setup Time (mins)")}
type="number"
fullWidth
value={timeValues.prepTimeInMinutes}
onChange={(e) => {
console.log("⌨️ Setup Time onChange:", {
value: e.target.value,
openTimeDialog,
editingLineId,
timestamp: new Date().toISOString()
});
const value = e.target.value === '' ? 0 : parseInt(e.target.value) || 0;
setTimeValues(prev => ({
...prev,
prepTimeInMinutes: Math.max(0, value)
}));
}}
inputProps={{
min: 0,
step: 1
}}
/>
<TextField
label={t("Changeover Time (mins)")}
type="number"
fullWidth
value={timeValues.postProdTimeInMinutes}
onChange={(e) => {
console.log("⌨️ Changeover Time onChange:", {
value: e.target.value,
openTimeDialog,
editingLineId,
timestamp: new Date().toISOString()
});
const value = e.target.value === '' ? 0 : parseInt(e.target.value) || 0;
setTimeValues(prev => ({
...prev,
postProdTimeInMinutes: Math.max(0, value)
}));
}}
inputProps={{
min: 0,
step: 1
}}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button
type="button"
onClick={handleCloseTimeDialog}
>
{t("Cancel")}
</Button>
<Button
type="button"
variant="contained"
onClick={handleConfirmTimeUpdate}
>
{t("Save")}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};


+ 154
- 3
src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx 查看文件

@@ -23,8 +23,10 @@ import {
} from "@mui/material";
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useTranslation } from "react-i18next";
import { fetchProductProcessesByJobOrderId ,deleteJobOrder, updateProductProcessPriority, updateJoPlanStart} from "@/app/api/jo/actions";
import { fetchProductProcessesByJobOrderId ,deleteJobOrder, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine} from "@/app/api/jo/actions";
import ProductionProcessDetail from "./ProductionProcessDetail";
import { BomCombo } from "@/app/api/bom";
import { fetchBomCombo } from "@/app/api/bom/index";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT, integerFormatter, arrayToDateString } from "@/app/utils/formatUtil";
import StyledDataGrid from "../StyledDataGrid/StyledDataGrid";
@@ -79,7 +81,11 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp
const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false);
const [openPlanStartDialog, setOpenPlanStartDialog] = useState(false);
const [planStartDate, setPlanStartDate] = useState<dayjs.Dayjs | null>(null);
const [openReqQtyDialog, setOpenReqQtyDialog] = useState(false);
const [reqQtyMultiplier, setReqQtyMultiplier] = useState<number>(1);
const [selectedBomForReqQty, setSelectedBomForReqQty] = useState<BomCombo | null>(null);
const [bomCombo, setBomCombo] = useState<BomCombo[]>([]);

const fetchData = useCallback(async () => {
setLoading(true);
@@ -97,6 +103,61 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp
}
}, [jobOrderId]);

// 4. 添加处理函数(约第 166 行后)
const handleOpenReqQtyDialog = useCallback(async () => {
if (!processData || !processData.outputQty || !processData.outputQtyUom) {
alert(t("BOM data not available"));
return;
}
const baseOutputQty = processData.bomBaseQty;
const currentMultiplier = baseOutputQty > 0
? Math.round(processData.outputQty / baseOutputQty)
: 1;
const bomData = {
id: processData.bomId || 0,
value: processData.bomId || 0,
label: processData.bomDescription || "",
outputQty: baseOutputQty,
outputQtyUom: processData.outputQtyUom,
description: processData.bomDescription || ""
};
setSelectedBomForReqQty(bomData);
setReqQtyMultiplier(currentMultiplier);
setOpenReqQtyDialog(true);
}, [processData, t]);
const handleCloseReqQtyDialog = useCallback(() => {
setOpenReqQtyDialog(false);
setSelectedBomForReqQty(null);
setReqQtyMultiplier(1);
}, []);
const handleUpdateReqQty = useCallback(async (jobOrderId: number, newReqQty: number) => {
try {
const response = await updateJoReqQty({
id: jobOrderId,
reqQty: Math.round(newReqQty)
});
if (response) {
await fetchData();
}
} catch (error) {
console.error("Error updating reqQty:", error);
alert(t("update failed"));
}
}, [fetchData, t]);
const handleConfirmReqQty = useCallback(async () => {
if (!jobOrderId || !selectedBomForReqQty) return;
const newReqQty = reqQtyMultiplier * selectedBomForReqQty.outputQty;
await handleUpdateReqQty(jobOrderId, newReqQty);
setOpenReqQtyDialog(false);
setSelectedBomForReqQty(null);
setReqQtyMultiplier(1);
}, [jobOrderId, selectedBomForReqQty, reqQtyMultiplier, handleUpdateReqQty]);
// 获取库存数据
useEffect(() => {
const fetchInventoryData = async () => {
@@ -302,6 +363,15 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
fullWidth
disabled={true}
value={processData?.outputQty + "(" + processData?.outputQtyUom + ")" || ""}
InputProps={{
endAdornment: (processData?.jobOrderStatus === "planning" ? (
<InputAdornment position="end">
<IconButton size="small" onClick={handleOpenReqQtyDialog}>
<EditIcon fontSize="small" />
</IconButton>
</InputAdornment>
) : null),
}}
/>
</Grid>

@@ -681,7 +751,88 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
</Button>
</DialogActions>
</Dialog>

<Dialog
open={openReqQtyDialog}
onClose={handleCloseReqQtyDialog}
fullWidth
maxWidth="sm"
>
<DialogTitle>{t("Update Required Quantity")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<TextField
label={t("Base Qty")}
fullWidth
type="number"
variant="outlined"
value={selectedBomForReqQty?.outputQty || 0}
disabled
InputProps={{
endAdornment: selectedBomForReqQty?.outputQtyUom ? (
<InputAdornment position="end">
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{selectedBomForReqQty.outputQtyUom}
</Typography>
</InputAdornment>
) : null
}}
sx={{ flex: 1 }}
/>
<Typography variant="body1" sx={{ color: "text.secondary" }}>
×
</Typography>
<TextField
label={t("Batch Count")}
fullWidth
type="number"
variant="outlined"
value={reqQtyMultiplier}
onChange={(e) => {
const val = e.target.value === "" ? 1 : Math.max(1, Math.floor(Number(e.target.value)));
setReqQtyMultiplier(val);
}}
inputProps={{
min: 1,
step: 1
}}
sx={{ flex: 1 }}
/>
<Typography variant="body1" sx={{ color: "text.secondary" }}>
=
</Typography>
<TextField
label={t("Req. Qty")}
fullWidth
variant="outlined"
type="number"
value={selectedBomForReqQty ? (reqQtyMultiplier * selectedBomForReqQty.outputQty) : ""}
disabled
InputProps={{
endAdornment: selectedBomForReqQty?.outputQtyUom ? (
<InputAdornment position="end">
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{selectedBomForReqQty.outputQtyUom}
</Typography>
</InputAdornment>
) : null
}}
sx={{ flex: 1 }}
/>
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseReqQtyDialog}>{t("Cancel")}</Button>
<Button
variant="contained"
onClick={handleConfirmReqQty}
disabled={!selectedBomForReqQty || reqQtyMultiplier < 1}
>
{t("Save")}
</Button>
</DialogActions>
</Dialog>

</Box>
</Box>


+ 1
- 0
src/components/ProductionProcess/ProductionProcessStepExecution.tsx 查看文件

@@ -987,6 +987,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
lineId={lineId}
bomDescription={processData?.bomDescription}
isLastLine={shouldShowBagForm}
submitedBagRecord={lineDetail?.submitedBagRecord}
onRefresh={handleRefreshLineDetail}
/>
)}


+ 194
- 0
src/components/StockTakeManagement/ApproverCardList.tsx 查看文件

@@ -0,0 +1,194 @@
"use client";

import {
Box,
Button,
Card,
CardContent,
CardActions,
Stack,
Typography,
Chip,
CircularProgress,
TablePagination,
Grid,
LinearProgress,
} from "@mui/material";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
getApproverStockTakeRecords,
AllPickedStockTakeListReponse,
} from "@/app/api/stockTake/actions";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";

const PER_PAGE = 6;

interface ApproverCardListProps {
onCardClick: (session: AllPickedStockTakeListReponse) => void;
}

const ApproverCardList: React.FC<ApproverCardListProps> = ({ onCardClick }) => {
const { t } = useTranslation(["inventory", "common"]);

const [loading, setLoading] = useState(false);
const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]);
const [page, setPage] = useState(0);
const [creating, setCreating] = useState(false);

const fetchStockTakeSessions = useCallback(async () => {
setLoading(true);
try {
const data = await getApproverStockTakeRecords();
setStockTakeSessions(Array.isArray(data) ? data : []);
setPage(0);
} catch (e) {
console.error(e);
setStockTakeSessions([]);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
fetchStockTakeSessions();
}, [fetchStockTakeSessions]);

const startIdx = page * PER_PAGE;
const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE);
const getStatusColor = (status: string | null) => {
if (!status) return "default";
const statusLower = status.toLowerCase();
if (statusLower === "completed") return "success";
if (statusLower === "in_progress" || statusLower === "processing") return "primary";
if (statusLower === "no_cycle") return "default";
if (statusLower === "approving") return "info";
return "warning";
};

const getCompletionRate = (session: AllPickedStockTakeListReponse): number => {
if (session.totalInventoryLotNumber === 0) return 0;
return Math.round((session.currentStockTakeItemNumber / session.totalInventoryLotNumber) * 100);
};

if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
);
}

return (
<Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
<Typography variant="body2" color="text.secondary">
{t("Total Sections")}: {stockTakeSessions.length}
</Typography>
</Box>

<Grid container spacing={2}>
{paged.map((session) => {
const statusColor = getStatusColor(session.status);
const lastStockTakeDate = session.lastStockTakeDate
? dayjs(session.lastStockTakeDate).format(OUTPUT_DATE_FORMAT)
: "-";
const completionRate = getCompletionRate(session);
const isDisabled = session.status === null;

return (
<Grid key={session.id} item xs={12} sm={6} md={4}>
<Card
sx={{
minHeight: 200,
display: "flex",
flexDirection: "column",
border: "1px solid",
borderColor: statusColor === "success" ? "success.main" : "primary.main",
cursor: isDisabled ? "not-allowed" : "pointer",
opacity: isDisabled ? 0.6 : 1,
"&:hover": {
boxShadow: isDisabled ? 0 : 4,
},
}}
onClick={() => {
if (!isDisabled && session.status !== null) {
onCardClick(session);
}
}}
>
<CardContent sx={{ pb: 1, flexGrow: 1 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" fontWeight={600}>
{t("Section")}: {session.stockTakeSession}
</Typography>
{session.status ? (
<Chip size="small" label={t(session.status)} color={statusColor as any} />
) : (
<Chip size="small" label={t(" ")} color="default" />
)}
</Stack>

<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{t("Last Stock Take Date")}: {lastStockTakeDate || "-"}
</Typography>

{session.totalInventoryLotNumber > 0 && (
<Box sx={{ mt: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="body2" fontWeight={600}>
{t("Progress")}
</Typography>
<Typography variant="body2" fontWeight={600}>
{completionRate}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={completionRate}
sx={{ height: 8, borderRadius: 1 }}
/>
</Box>
)}
</CardContent>

<CardActions sx={{ pt: 0.5 }}>
<Button
variant="contained"
size="small"
disabled={isDisabled}
onClick={(e) => {
e.stopPropagation();
if (!isDisabled) {
onCardClick(session);
}
}}
>
{t("View Details")}
</Button>
</CardActions>
</Card>
</Grid>
);
})}
</Grid>

{stockTakeSessions.length > 0 && (
<TablePagination
component="div"
count={stockTakeSessions.length}
page={page}
rowsPerPage={PER_PAGE}
onPageChange={(e, p) => setPage(p)}
rowsPerPageOptions={[PER_PAGE]}
/>
)}
</Box>
);
};

export default ApproverCardList;

+ 467
- 0
src/components/StockTakeManagement/ApproverStockTake.tsx 查看文件

@@ -0,0 +1,467 @@
"use client";

import {
Box,
Button,
Stack,
Typography,
Chip,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TextField,
Radio,
} from "@mui/material";
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
AllPickedStockTakeListReponse,
getInventoryLotDetailsBySection,
InventoryLotDetailResponse,
saveApproverStockTakeRecord,
SaveApproverStockTakeRecordRequest,
BatchSaveApproverStockTakeRecordRequest,
batchSaveApproverStockTakeRecords,
updateStockTakeRecordStatusToNotMatch,
} from "@/app/api/stockTake/actions";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";

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

type QtySelectionType = "first" | "second" | "approver";

const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
selectedSession,
onBack,
onSnackbar,
}) => {
const { t } = useTranslation(["inventory", "common"]);
const { data: session } = useSession() as { data: SessionWithTokens | null };

const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]);
const [loadingDetails, setLoadingDetails] = useState(false);
// 每个记录的选择状态,key 为 detail.id
const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({});
const [approverQty, setApproverQty] = useState<Record<number, string>>({});
const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({});
const [saving, setSaving] = useState(false);
const [batchSaving, setBatchSaving] = useState(false);
const [updatingStatus, setUpdatingStatus] = useState(false);
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const handleBatchSubmitAllRef = useRef<() => Promise<void>>();

useEffect(() => {
const loadDetails = async () => {
setLoadingDetails(true);
try {
const details = await getInventoryLotDetailsBySection(
selectedSession.stockTakeSession,
selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null
);
setInventoryLotDetails(Array.isArray(details) ? details : []);
} catch (e) {
console.error(e);
setInventoryLotDetails([]);
} finally {
setLoadingDetails(false);
}
};
loadDetails();
}, [selectedSession]);

const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => {
if (!selectedSession || !currentUserId) {
return;
}
const selection = qtySelection[detail.id] || "first";
let finalQty: number;
let finalBadQty: number;

if (selection === "first") {
if (!detail.firstStockTakeQty || detail.firstStockTakeQty === 0) {
onSnackbar(t("First QTY is not available"), "error");
return;
}
finalQty = detail.firstStockTakeQty;
finalBadQty = detail.firstBadQty || 0;
} else if (selection === "second") {
if (!detail.secondStockTakeQty || detail.secondStockTakeQty === 0) {
onSnackbar(t("Second QTY is not available"), "error");
return;
}
finalQty = detail.secondStockTakeQty;
finalBadQty = detail.secondBadQty || 0;
} else {
// Approver input
const approverQtyValue = approverQty[detail.id];
const approverBadQtyValue = approverBadQty[detail.id];
if (!approverQtyValue || !approverBadQtyValue) {
onSnackbar(t("Please enter Approver QTY and Bad QTY"), "error");
return;
}
finalQty = parseFloat(approverQtyValue);
finalBadQty = parseFloat(approverBadQtyValue);
}
setSaving(true);
try {
const request: SaveApproverStockTakeRecordRequest = {
stockTakeRecordId: detail.stockTakeRecordId || null,
qty: finalQty,
badQty: finalBadQty,
approverId: currentUserId,
approverQty: selection === "approver" ? finalQty : null,
approverBadQty: selection === "approver" ? finalBadQty : null,
};
await saveApproverStockTakeRecord(
request,
selectedSession.stockTakeId
);
onSnackbar(t("Approver stock take record saved successfully"), "success");

const details = await getInventoryLotDetailsBySection(
selectedSession.stockTakeSession,
selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null
);
setInventoryLotDetails(Array.isArray(details) ? details : []);
} catch (e: any) {
console.error("Save approver stock take record error:", e);
let errorMessage = t("Failed to save approver stock take record");
if (e?.message) {
errorMessage = e.message;
} else if (e?.response) {
try {
const errorData = await e.response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// ignore
}
}
onSnackbar(errorMessage, "error");
} finally {
setSaving(false);
}
}, [selectedSession, qtySelection, approverQty, approverBadQty, t, currentUserId, onSnackbar]);
const handleUpdateStatusToNotMatch = useCallback(async (detail: InventoryLotDetailResponse) => {
if (!detail.stockTakeRecordId) {
onSnackbar(t("Stock take record ID is required"), "error");
return;
}
setUpdatingStatus(true);
try {
await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId);
onSnackbar(t("Stock take record status updated to not match"), "success");
const details = await getInventoryLotDetailsBySection(
selectedSession.stockTakeSession,
selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null
);
setInventoryLotDetails(Array.isArray(details) ? details : []);
} catch (e: any) {
console.error("Update stock take record status error:", e);
let errorMessage = t("Failed to update stock take record status");
if (e?.message) {
errorMessage = e.message;
} else if (e?.response) {
try {
const errorData = await e.response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// ignore
}
}
onSnackbar(errorMessage, "error");
} finally {
setUpdatingStatus(false);
}
}, [selectedSession, t, onSnackbar]);
const handleBatchSubmitAll = useCallback(async () => {
if (!selectedSession || !currentUserId) {
console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId');
return;
}

console.log('handleBatchSubmitAll: Starting batch approver save...');
setBatchSaving(true);
try {
const request: BatchSaveApproverStockTakeRecordRequest = {
stockTakeId: selectedSession.stockTakeId,
stockTakeSection: selectedSession.stockTakeSession,
approverId: currentUserId,
};

const result = await batchSaveApproverStockTakeRecords(request);
console.log('handleBatchSubmitAll: Result:', result);

onSnackbar(
t("Batch approver save completed: {{success}} success, {{errors}} errors", {
success: result.successCount,
errors: result.errorCount,
}),
result.errorCount > 0 ? "warning" : "success"
);

const details = await getInventoryLotDetailsBySection(
selectedSession.stockTakeSession,
selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null
);
setInventoryLotDetails(Array.isArray(details) ? details : []);
} catch (e: any) {
console.error("handleBatchSubmitAll: Error:", e);
let errorMessage = t("Failed to batch save approver stock take records");
if (e?.message) {
errorMessage = e.message;
} else if (e?.response) {
try {
const errorData = await e.response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// ignore
}
}
onSnackbar(errorMessage, "error");
} finally {
setBatchSaving(false);
}
}, [selectedSession, t, currentUserId, onSnackbar]);

useEffect(() => {
handleBatchSubmitAllRef.current = handleBatchSubmitAll;
}, [handleBatchSubmitAll]);

const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => {
// Only allow editing if there's a first stock take qty
if (!detail.firstStockTakeQty || detail.firstStockTakeQty === 0) {
return true;
}
return false;
}, []);

return (
<Box>
<Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}>
{t("Back to List")}
</Button>
<Typography variant="h6" sx={{ mb: 2 }}>
{t("Stock Take Section")}: {selectedSession.stockTakeSession}
</Typography>
{loadingDetails ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Warehouse Location")}</TableCell>
<TableCell>{t("Item")}</TableCell>
<TableCell>{t("Stock Take Qty")}</TableCell>
<TableCell>{t("Remark")}</TableCell>
<TableCell>{t("UOM")}</TableCell>
<TableCell>{t("Record Status")}</TableCell>
<TableCell>{t("Action")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{inventoryLotDetails.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data")}
</Typography>
</TableCell>
</TableRow>
) : (
inventoryLotDetails.map((detail) => {
const submitDisabled = isSubmitDisabled(detail);
const hasFirst = detail.firstStockTakeQty != null && detail.firstStockTakeQty > 0;
const hasSecond = detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0;
const selection = qtySelection[detail.id] || "first";

return (
<TableRow key={detail.id}>
<TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell>
<TableCell sx={{
maxWidth: 150,
wordBreak: 'break-word',
whiteSpace: 'normal',
lineHeight: 1.5
}}>
<Stack spacing={0.5}>
<Box>{detail.itemCode || "-"} {detail.itemName || "-"}</Box>
<Box>{detail.lotNo || "-"}</Box>
<Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box>
<Box><Chip size="small" label={t(detail.status)} color="default" /></Box>
</Stack>
</TableCell>
<TableCell sx={{ minWidth: 300 }}>
{detail.finalQty != null ? (
// 提交后只显示差异行
<Stack spacing={0.5}>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
{t("Difference")}: {detail.finalQty?.toFixed(2) || "0.00"} - {(detail.availableQty || 0).toFixed(2)} = {((detail.finalQty || 0) - (detail.availableQty || 0)).toFixed(2)}
</Typography>
</Stack>
) : (
<Stack spacing={1}>
{/* 第一行:First Qty(默认选中) */}
{hasFirst && (
<Stack direction="row" spacing={1} alignItems="center">
<Radio
size="small"
checked={selection === "first"}
onChange={() => setQtySelection({ ...qtySelection, [detail.id]: "first" })}
/>
<Typography variant="body2">
{t("First")}: {(detail.firstStockTakeQty??0)+(detail.firstBadQty??0) || "0.00"} ({detail.firstBadQty??0})
</Typography>
</Stack>
)}
{/* 第二行:Second Qty(如果存在) */}
{hasSecond && (
<Stack direction="row" spacing={1} alignItems="center">
<Radio
size="small"
checked={selection === "second"}
onChange={() => setQtySelection({ ...qtySelection, [detail.id]: "second" })}
/>
<Typography variant="body2">
{t("Second")}: {(detail.secondStockTakeQty??0)+(detail.secondBadQty??0) || "0.00"} ({detail.secondBadQty??0})
</Typography>
</Stack>
)}
{/* 第三行:Approver Input(仅在 second qty 存在时显示) */}
{hasSecond && (
<Stack direction="row" spacing={1} alignItems="center">
<Radio
size="small"
checked={selection === "approver"}
onChange={() => setQtySelection({ ...qtySelection, [detail.id]: "approver" })}
/>
<Typography variant="body2">{t("Approver Input")}:</Typography>
<TextField
size="small"
type="number"
value={approverQty[detail.id] || ""}
onChange={(e) => setApproverQty({ ...approverQty, [detail.id]: e.target.value })}
sx={{ width: 100 }}
disabled={selection !== "approver"}
/>
<Typography variant="body2">-</Typography>
<TextField
size="small"
type="number"
value={approverBadQty[detail.id] || ""}
onChange={(e) => setApproverBadQty({ ...approverBadQty, [detail.id]: e.target.value })}
sx={{ width: 100 }}
disabled={selection !== "approver"}
/>
</Stack>
)}
{/* 差异行:显示 selected qty - bookqty = result */}
{(() => {
let selectedQty = 0;
if (selection === "first") {
selectedQty = detail.firstStockTakeQty || 0;
} else if (selection === "second") {
selectedQty = detail.secondStockTakeQty || 0;
} else if (selection === "approver") {
selectedQty = parseFloat(approverQty[detail.id] || "0") || 0;
}
const bookQty = detail.availableQty || 0;
const difference = selectedQty - bookQty;
return (
<Typography variant="body2" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
{t("Difference")}: {selectedQty.toFixed(2)} - {bookQty.toFixed(2)} = {difference.toFixed(2)}
</Typography>
);
})()}
</Stack>
)}
</TableCell>
<TableCell>
<Typography variant="body2">
{detail.remarks || "-"}
</Typography>
</TableCell>
<TableCell>{detail.uom || "-"}</TableCell>
<TableCell>
{detail.stockTakeRecordStatus === "pass" ? (
<Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" />
) : detail.stockTakeRecordStatus === "notMatch" ? (
<Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" />
) : (
<Chip size="small" label={t(detail.stockTakeRecordStatus || "")} color="default" />
)}
</TableCell>
<TableCell>
{detail.stockTakeRecordId && detail.stockTakeRecordStatus !== "notMatch" && (
<Button
size="small"
variant="outlined"
color="warning"
onClick={() => handleUpdateStatusToNotMatch(detail)}
disabled={updatingStatus || detail.stockTakeRecordStatus === "completed"}
>
{t("ReStockTake")}
</Button>
)}
{detail.finalQty == null && (
<Button
size="small"
variant="contained"
onClick={() => handleSaveApproverStockTake(detail)}
disabled={saving || submitDisabled || detail.stockTakeRecordStatus === "completed"}
>
{t("Save")}
</Button>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
);
};

export default ApproverStockTake;

+ 211
- 0
src/components/StockTakeManagement/PickerCardList.tsx 查看文件

@@ -0,0 +1,211 @@
"use client";

import {
Box,
Button,
Card,
CardContent,
CardActions,
Stack,
Typography,
Chip,
CircularProgress,
TablePagination,
Grid,
LinearProgress,
} from "@mui/material";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
getStockTakeRecords,
AllPickedStockTakeListReponse,
createStockTakeForSections,
} from "@/app/api/stockTake/actions";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";

const PER_PAGE = 6;

interface PickerCardListProps {
onCardClick: (session: AllPickedStockTakeListReponse) => void;
onReStockTakeClick: (session: AllPickedStockTakeListReponse) => void;
}

const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockTakeClick }) => {
const { t } = useTranslation(["inventory", "common"]);

const [loading, setLoading] = useState(false);
const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]);
const [page, setPage] = useState(0);
const [creating, setCreating] = useState(false);

const fetchStockTakeSessions = useCallback(async () => {
setLoading(true);
try {
const data = await getStockTakeRecords();
setStockTakeSessions(Array.isArray(data) ? data : []);
setPage(0);
} catch (e) {
console.error(e);
setStockTakeSessions([]);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
fetchStockTakeSessions();
}, [fetchStockTakeSessions]);

const startIdx = page * PER_PAGE;
const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE);
const handleCreateStockTake = useCallback(async () => {
setCreating(true);
try {
const result = await createStockTakeForSections();
const createdCount = Object.values(result).filter(msg => msg.startsWith("Created:")).length;
const skippedCount = Object.values(result).filter(msg => msg.startsWith("Skipped:")).length;
const errorCount = Object.values(result).filter(msg => msg.startsWith("Error:")).length;
let message = `${t("Created")}: ${createdCount}, ${t("Skipped")}: ${skippedCount}`;
if (errorCount > 0) {
message += `, ${t("Errors")}: ${errorCount}`;
}
console.log(message);
await fetchStockTakeSessions();
} catch (e) {
console.error(e);
} finally {
setCreating(false);
}
}, [fetchStockTakeSessions, t]);
const getStatusColor = (status: string) => {
const statusLower = status.toLowerCase();
if (statusLower === "completed") return "success";
if (statusLower === "in_progress" || statusLower === "processing") return "primary";
if (statusLower === "approving") return "info";
if (statusLower === "no_cycle") return "default";
return "warning";
};

const getCompletionRate = (session: AllPickedStockTakeListReponse): number => {
if (session.totalInventoryLotNumber === 0) return 0;
return Math.round((session.currentStockTakeItemNumber / session.totalInventoryLotNumber) * 100);
};

if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
);
}

return (
<Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
<Typography variant="body2" color="text.secondary">
{t("Total Sections")}: {stockTakeSessions.length}
</Typography>
<Button
variant="contained"
color="primary"
onClick={handleCreateStockTake}
disabled={creating}
>
{creating ? <CircularProgress size={20} /> : t("Create Stock Take for All Sections")}
</Button>
</Box>

<Grid container spacing={2}>
{paged.map((session) => {
const statusColor = getStatusColor(session.status || "");
const lastStockTakeDate = session.lastStockTakeDate
? dayjs(session.lastStockTakeDate).format(OUTPUT_DATE_FORMAT)
: "-";
const completionRate = getCompletionRate(session);

return (
<Grid key={session.id} item xs={12} sm={6} md={4}>
<Card
sx={{
minHeight: 200,
display: "flex",
flexDirection: "column",
border: "1px solid",
borderColor: statusColor === "success" ? "success.main" : "primary.main",
}}
>
<CardContent sx={{ pb: 1, flexGrow: 1 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" fontWeight={600}>
{t("Section")}: {session.stockTakeSession}
</Typography>
<Chip size="small" label={t(session.status || "")} color={statusColor as any} />
</Stack>

<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{t("Last Stock Take Date")}: {lastStockTakeDate || "-"}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Stock Taker")}: {session.stockTakerName}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("Total Item Number")}: {session.totalItemNumber}</Typography>
{session.totalInventoryLotNumber > 0 && (
<Box sx={{ mt: 2 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.5 }}>
<Typography variant="body2" fontWeight={600}>
{t("Progress")}
</Typography>
<Typography variant="body2" fontWeight={600}>
{completionRate}%
</Typography>
</Stack>
<LinearProgress
variant="determinate"
value={completionRate}
sx={{ height: 8, borderRadius: 1 }}
/>
</Box>
)}
</CardContent>

<CardActions sx={{ pt: 0.5 }}>
<Button
variant="contained"
size="small"
onClick={() => onCardClick(session)}
>
{t("View Details")}
</Button>
<Button
variant="contained"
size="small"
onClick={() => onReStockTakeClick(session)}
>
{t("View ReStockTake")}
</Button>
</CardActions>
</Card>
</Grid>
);
})}
</Grid>

{stockTakeSessions.length > 0 && (
<TablePagination
component="div"
count={stockTakeSessions.length}
page={page}
rowsPerPage={PER_PAGE}
onPageChange={(e, p) => setPage(p)}
rowsPerPageOptions={[PER_PAGE]}
/>
)}
</Box>
);
};

export default PickerCardList;

+ 543
- 0
src/components/StockTakeManagement/PickerReStockTake.tsx 查看文件

@@ -0,0 +1,543 @@
"use client";

import {
Box,
Button,
Stack,
Typography,
Chip,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TextField,
} from "@mui/material";
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
AllPickedStockTakeListReponse,
getInventoryLotDetailsBySection,
InventoryLotDetailResponse,
saveStockTakeRecord,
SaveStockTakeRecordRequest,
BatchSaveStockTakeRecordRequest,
batchSaveStockTakeRecords,
getInventoryLotDetailsBySectionNotMatch
} from "@/app/api/stockTake/actions";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";

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

const PickerStockTake: React.FC<PickerStockTakeProps> = ({
selectedSession,
onBack,
onSnackbar,
}) => {
const { t } = useTranslation(["inventory", "common"]);
const { data: session } = useSession() as { data: SessionWithTokens | null };

const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]);
const [loadingDetails, setLoadingDetails] = useState(false);
// 编辑状态
const [editingRecord, setEditingRecord] = useState<InventoryLotDetailResponse | null>(null);
const [firstQty, setFirstQty] = useState<string>("");
const [secondQty, setSecondQty] = useState<string>("");
const [firstBadQty, setFirstBadQty] = useState<string>("");
const [secondBadQty, setSecondBadQty] = useState<string>("");
const [remark, setRemark] = useState<string>("");
const [saving, setSaving] = useState(false);
const [batchSaving, setBatchSaving] = useState(false);
const [shortcutInput, setShortcutInput] = useState<string>("");

const currentUserId = session?.id ? parseInt(session.id) : undefined;
const handleBatchSubmitAllRef = useRef<() => Promise<void>>();

useEffect(() => {
const loadDetails = async () => {
setLoadingDetails(true);
try {
const details = await getInventoryLotDetailsBySectionNotMatch(
selectedSession.stockTakeSession,
selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null
);
setInventoryLotDetails(Array.isArray(details) ? details : []);
} catch (e) {
console.error(e);
setInventoryLotDetails([]);
} finally {
setLoadingDetails(false);
}
};
loadDetails();
}, [selectedSession]);

const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => {
setEditingRecord(detail);
setFirstQty(detail.firstStockTakeQty?.toString() || "");
setSecondQty(detail.secondStockTakeQty?.toString() || "");
setFirstBadQty(detail.firstBadQty?.toString() || "");
setSecondBadQty(detail.secondBadQty?.toString() || "");
setRemark(detail.remarks || "");
}, []);

const handleCancelEdit = useCallback(() => {
setEditingRecord(null);
setFirstQty("");
setSecondQty("");
setFirstBadQty("");
setSecondBadQty("");
setRemark("");
}, []);

const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => {
if (!selectedSession || !currentUserId) {
return;
}
const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty;
const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty;
const qty = isFirstSubmit ? firstQty : secondQty;
const badQty = isFirstSubmit ? firstBadQty : secondBadQty;
if (!qty || !badQty) {
onSnackbar(
isFirstSubmit
? t("Please enter QTY and Bad QTY")
: t("Please enter Second QTY and Bad QTY"),
"error"
);
return;
}
setSaving(true);
try {
const request: SaveStockTakeRecordRequest = {
stockTakeRecordId: detail.stockTakeRecordId || null,
inventoryLotLineId: detail.id,
qty: parseFloat(qty),
badQty: parseFloat(badQty),
remark: isSecondSubmit ? (remark || null) : null,
};
console.log('handleSaveStockTake: request:', request);
console.log('handleSaveStockTake: selectedSession.stockTakeId:', selectedSession.stockTakeId);
console.log('handleSaveStockTake: currentUserId:', currentUserId);
await saveStockTakeRecord(
request,
selectedSession.stockTakeId,
currentUserId
);
onSnackbar(t("Stock take record saved successfully"), "success");
handleCancelEdit();
const details = await getInventoryLotDetailsBySection(
selectedSession.stockTakeSession,
selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null
);
setInventoryLotDetails(Array.isArray(details) ? details : []);
} catch (e: any) {
console.error("Save stock take record error:", e);
let errorMessage = t("Failed to save stock take record");
if (e?.message) {
errorMessage = e.message;
} else if (e?.response) {
try {
const errorData = await e.response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// ignore
}
}
onSnackbar(errorMessage, "error");
} finally {
setSaving(false);
}
}, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar]);

const handleBatchSubmitAll = useCallback(async () => {
if (!selectedSession || !currentUserId) {
console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId');
return;
}

console.log('handleBatchSubmitAll: Starting batch save...');
setBatchSaving(true);
try {
const request: BatchSaveStockTakeRecordRequest = {
stockTakeId: selectedSession.stockTakeId,
stockTakeSection: selectedSession.stockTakeSession,
stockTakerId: currentUserId,
};

const result = await batchSaveStockTakeRecords(request);
console.log('handleBatchSubmitAll: Result:', result);

onSnackbar(
t("Batch save completed: {{success}} success, {{errors}} errors", {
success: result.successCount,
errors: result.errorCount,
}),
result.errorCount > 0 ? "warning" : "success"
);

const details = await getInventoryLotDetailsBySection(
selectedSession.stockTakeSession,
selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null
);
setInventoryLotDetails(Array.isArray(details) ? details : []);
} catch (e: any) {
console.error("handleBatchSubmitAll: Error:", e);
let errorMessage = t("Failed to batch save stock take records");
if (e?.message) {
errorMessage = e.message;
} else if (e?.response) {
try {
const errorData = await e.response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// ignore
}
}
onSnackbar(errorMessage, "error");
} finally {
setBatchSaving(false);
}
}, [selectedSession, t, currentUserId, onSnackbar]);

useEffect(() => {
handleBatchSubmitAllRef.current = handleBatchSubmitAll;
}, [handleBatchSubmitAll]);

useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
)) {
return;
}

if (e.ctrlKey || e.metaKey || e.altKey) {
return;
}

if (e.key.length === 1) {
setShortcutInput(prev => {
const newInput = prev + e.key;
if (newInput === '{2fitestall}') {
console.log('✅ Shortcut {2fitestall} detected!');
setTimeout(() => {
if (handleBatchSubmitAllRef.current) {
console.log('Calling handleBatchSubmitAll...');
handleBatchSubmitAllRef.current().catch(err => {
console.error('Error in handleBatchSubmitAll:', err);
});
} else {
console.error('handleBatchSubmitAllRef.current is null');
}
}, 0);
return "";
}
if (newInput.length > 15) {
return "";
}
if (newInput.length > 0 && !newInput.startsWith('{')) {
return "";
}
if (newInput.length > 5 && !newInput.startsWith('{2fi')) {
return "";
}
return newInput;
});
} else if (e.key === 'Backspace') {
setShortcutInput(prev => prev.slice(0, -1));
} else if (e.key === 'Escape') {
setShortcutInput("");
}
};

window.addEventListener('keydown', handleKeyPress);
return () => {
window.removeEventListener('keydown', handleKeyPress);
};
}, []);

const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => {
if (detail.stockTakeRecordStatus === "pass") {
return true;
}
return false;
}, []);

return (
<Box>
<Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}>
{t("Back to List")}
</Button>
<Typography variant="h6" sx={{ mb: 2 }}>
{t("Stock Take Section")}: {selectedSession.stockTakeSession}
</Typography>
{/*
{shortcutInput && (
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'info.light', borderRadius: 1, border: '1px solid', borderColor: 'info.main' }}>
<Typography variant="body2" color="info.dark" fontWeight={500}>
{t("Shortcut Input")}: <strong style={{ fontFamily: 'monospace', fontSize: '1.1em' }}>{shortcutInput}</strong>
</Typography>
</Box>
)}
*/}
{loadingDetails ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Warehouse Location")}</TableCell>
<TableCell>{t("Item")}</TableCell>
{/*<TableCell>{t("Item Name")}</TableCell>*/}
{/*<TableCell>{t("Lot No")}</TableCell>*/}
<TableCell>{t("Expiry Date")}</TableCell>
<TableCell>{t("Qty")}</TableCell>
<TableCell>{t("Bad Qty")}</TableCell>
{/*{inventoryLotDetails.some(d => editingRecord?.id === d.id) && (*/}
<TableCell>{t("Remark")}</TableCell>
<TableCell>{t("UOM")}</TableCell>
<TableCell>{t("Status")}</TableCell>
<TableCell>{t("Record Status")}</TableCell>
<TableCell>{t("Action")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{inventoryLotDetails.length === 0 ? (
<TableRow>
<TableCell colSpan={12} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data")}
</Typography>
</TableCell>
</TableRow>
) : (
inventoryLotDetails.map((detail) => {
const isEditing = editingRecord?.id === detail.id;
const submitDisabled = isSubmitDisabled(detail);
const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty;
const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty;

return (
<TableRow key={detail.id}>
<TableCell>{detail.warehouseCode || "-"}</TableCell>
<TableCell sx={{
maxWidth: 100,
wordBreak: 'break-word',
whiteSpace: 'normal',
lineHeight: 1.5
}}>{detail.itemCode || "-"}{detail.lotNo || "-"}{detail.itemName ? ` - ${detail.itemName}` : ""}</TableCell>
{/*
<TableCell
sx={{
maxWidth: 200,
wordBreak: 'break-word',
whiteSpace: 'normal',
lineHeight: 1.5
}}
>
{detail.itemName || "-"}
</TableCell>*/}
{/*<TableCell>{detail.lotNo || "-"}</TableCell>*/}
<TableCell>
{detail.expiryDate
? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT)
: "-"}
</TableCell>
<TableCell>
<Stack spacing={0.5}>
{isEditing && isFirstSubmit ? (
<TextField
size="small"
type="number"
value={firstQty}
onChange={(e) => setFirstQty(e.target.value)}
sx={{ width: 100 }}
/>
) : detail.firstStockTakeQty ? (
<Typography variant="body2">
{t("First")}: {detail.firstStockTakeQty.toFixed(2)}
</Typography>
) : null}
{isEditing && isSecondSubmit ? (
<TextField
size="small"
type="number"
value={secondQty}
onChange={(e) => setSecondQty(e.target.value)}
sx={{ width: 100 }}
/>
) : detail.secondStockTakeQty ? (
<Typography variant="body2">
{t("Second")}: {detail.secondStockTakeQty.toFixed(2)}
</Typography>
) : null}
{!detail.firstStockTakeQty && !detail.secondStockTakeQty && !isEditing && (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</Stack>
</TableCell>
<TableCell>
<Stack spacing={0.5}>
{isEditing && isFirstSubmit ? (
<TextField
size="small"
type="number"
value={firstBadQty}
onChange={(e) => setFirstBadQty(e.target.value)}
sx={{ width: 100 }}
/>
) : detail.firstBadQty ? (
<Typography variant="body2">
{t("First")}: {detail.firstBadQty.toFixed(2)}
</Typography>
) : null}
{isEditing && isSecondSubmit ? (
<TextField
size="small"
type="number"
value={secondBadQty}
onChange={(e) => setSecondBadQty(e.target.value)}
sx={{ width: 100 }}
/>
) : detail.secondBadQty ? (
<Typography variant="body2">
{t("Second")}: {detail.secondBadQty.toFixed(2)}
</Typography>
) : null}
{!detail.firstBadQty && !detail.secondBadQty && !isEditing && (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</Stack>
</TableCell>
<TableCell sx={{ width: 180 }}>
{isEditing && isSecondSubmit ? (
<>
<Typography variant="body2">{t("Remark")}</Typography>
<TextField
size="small"
value={remark}
onChange={(e) => setRemark(e.target.value)}
sx={{ width: 150 }}
// If you want a single-line input, remove multiline/rows:
// multiline
// rows={2}
/>
</>
) : (
<Typography variant="body2">
{detail.remarks || "-"}
</Typography>
)}
</TableCell>
<TableCell>{detail.uom || "-"}</TableCell>
<TableCell>
{detail.status ? (
<Chip size="small" label={t(detail.status)} color="default" />
) : (
"-"
)}
</TableCell>
<TableCell>
{detail.stockTakeRecordStatus === "pass" ? (
<Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" />
) : detail.stockTakeRecordStatus === "notMatch" ? (
<Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" />
) : (
<Chip size="small" label={t(detail.stockTakeRecordStatus || "")} color="default" />
)}
</TableCell>
<TableCell>
{isEditing ? (
<Stack direction="row" spacing={1}>
<Button
size="small"
variant="contained"
onClick={() => handleSaveStockTake(detail)}
disabled={saving || submitDisabled}
>
{t("Save")}
</Button>
<Button
size="small"
onClick={handleCancelEdit}
>
{t("Cancel")}
</Button>
</Stack>
) : (
<Button
size="small"
variant="outlined"
onClick={() => handleStartEdit(detail)}
disabled={submitDisabled}
>
{!detail.stockTakeRecordId
? t("Input")
: detail.stockTakeRecordStatus === "notMatch"
? t("Input")
: t("View")}
</Button>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
);
};

export default PickerStockTake;

+ 542
- 0
src/components/StockTakeManagement/PickerStockTake.tsx 查看文件

@@ -0,0 +1,542 @@
"use client";

import {
Box,
Button,
Stack,
Typography,
Chip,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TextField,
} from "@mui/material";
import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
AllPickedStockTakeListReponse,
getInventoryLotDetailsBySection,
InventoryLotDetailResponse,
saveStockTakeRecord,
SaveStockTakeRecordRequest,
BatchSaveStockTakeRecordRequest,
batchSaveStockTakeRecords,
} from "@/app/api/stockTake/actions";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";

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

const PickerStockTake: React.FC<PickerStockTakeProps> = ({
selectedSession,
onBack,
onSnackbar,
}) => {
const { t } = useTranslation(["inventory", "common"]);
const { data: session } = useSession() as { data: SessionWithTokens | null };

const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]);
const [loadingDetails, setLoadingDetails] = useState(false);
// 编辑状态
const [editingRecord, setEditingRecord] = useState<InventoryLotDetailResponse | null>(null);
const [firstQty, setFirstQty] = useState<string>("");
const [secondQty, setSecondQty] = useState<string>("");
const [firstBadQty, setFirstBadQty] = useState<string>("");
const [secondBadQty, setSecondBadQty] = useState<string>("");
const [remark, setRemark] = useState<string>("");
const [saving, setSaving] = useState(false);
const [batchSaving, setBatchSaving] = useState(false);
const [shortcutInput, setShortcutInput] = useState<string>("");

const currentUserId = session?.id ? parseInt(session.id) : undefined;
const handleBatchSubmitAllRef = useRef<() => Promise<void>>();

useEffect(() => {
const loadDetails = async () => {
setLoadingDetails(true);
try {
const details = await getInventoryLotDetailsBySection(
selectedSession.stockTakeSession,
selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null
);
setInventoryLotDetails(Array.isArray(details) ? details : []);
} catch (e) {
console.error(e);
setInventoryLotDetails([]);
} finally {
setLoadingDetails(false);
}
};
loadDetails();
}, [selectedSession]);

const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => {
setEditingRecord(detail);
setFirstQty(detail.firstStockTakeQty?.toString() || "");
setSecondQty(detail.secondStockTakeQty?.toString() || "");
setFirstBadQty(detail.firstBadQty?.toString() || "");
setSecondBadQty(detail.secondBadQty?.toString() || "");
setRemark(detail.remarks || "");
}, []);

const handleCancelEdit = useCallback(() => {
setEditingRecord(null);
setFirstQty("");
setSecondQty("");
setFirstBadQty("");
setSecondBadQty("");
setRemark("");
}, []);

const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => {
if (!selectedSession || !currentUserId) {
return;
}
const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty;
const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty;
const qty = isFirstSubmit ? firstQty : secondQty;
const badQty = isFirstSubmit ? firstBadQty : secondBadQty;
if (!qty || !badQty) {
onSnackbar(
isFirstSubmit
? t("Please enter QTY and Bad QTY")
: t("Please enter Second QTY and Bad QTY"),
"error"
);
return;
}
setSaving(true);
try {
const request: SaveStockTakeRecordRequest = {
stockTakeRecordId: detail.stockTakeRecordId || null,
inventoryLotLineId: detail.id,
qty: parseFloat(qty),
badQty: parseFloat(badQty),
remark: isSecondSubmit ? (remark || null) : null,
};
console.log('handleSaveStockTake: request:', request);
console.log('handleSaveStockTake: selectedSession.stockTakeId:', selectedSession.stockTakeId);
console.log('handleSaveStockTake: currentUserId:', currentUserId);
await saveStockTakeRecord(
request,
selectedSession.stockTakeId,
currentUserId
);
onSnackbar(t("Stock take record saved successfully"), "success");
handleCancelEdit();
const details = await getInventoryLotDetailsBySection(
selectedSession.stockTakeSession,
selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null
);
setInventoryLotDetails(Array.isArray(details) ? details : []);
} catch (e: any) {
console.error("Save stock take record error:", e);
let errorMessage = t("Failed to save stock take record");
if (e?.message) {
errorMessage = e.message;
} else if (e?.response) {
try {
const errorData = await e.response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// ignore
}
}
onSnackbar(errorMessage, "error");
} finally {
setSaving(false);
}
}, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar]);

const handleBatchSubmitAll = useCallback(async () => {
if (!selectedSession || !currentUserId) {
console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId');
return;
}

console.log('handleBatchSubmitAll: Starting batch save...');
setBatchSaving(true);
try {
const request: BatchSaveStockTakeRecordRequest = {
stockTakeId: selectedSession.stockTakeId,
stockTakeSection: selectedSession.stockTakeSession,
stockTakerId: currentUserId,
};

const result = await batchSaveStockTakeRecords(request);
console.log('handleBatchSubmitAll: Result:', result);

onSnackbar(
t("Batch save completed: {{success}} success, {{errors}} errors", {
success: result.successCount,
errors: result.errorCount,
}),
result.errorCount > 0 ? "warning" : "success"
);

const details = await getInventoryLotDetailsBySection(
selectedSession.stockTakeSession,
selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null
);
setInventoryLotDetails(Array.isArray(details) ? details : []);
} catch (e: any) {
console.error("handleBatchSubmitAll: Error:", e);
let errorMessage = t("Failed to batch save stock take records");
if (e?.message) {
errorMessage = e.message;
} else if (e?.response) {
try {
const errorData = await e.response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// ignore
}
}
onSnackbar(errorMessage, "error");
} finally {
setBatchSaving(false);
}
}, [selectedSession, t, currentUserId, onSnackbar]);

useEffect(() => {
handleBatchSubmitAllRef.current = handleBatchSubmitAll;
}, [handleBatchSubmitAll]);

useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
)) {
return;
}

if (e.ctrlKey || e.metaKey || e.altKey) {
return;
}

if (e.key.length === 1) {
setShortcutInput(prev => {
const newInput = prev + e.key;
if (newInput === '{2fitestall}') {
console.log('✅ Shortcut {2fitestall} detected!');
setTimeout(() => {
if (handleBatchSubmitAllRef.current) {
console.log('Calling handleBatchSubmitAll...');
handleBatchSubmitAllRef.current().catch(err => {
console.error('Error in handleBatchSubmitAll:', err);
});
} else {
console.error('handleBatchSubmitAllRef.current is null');
}
}, 0);
return "";
}
if (newInput.length > 15) {
return "";
}
if (newInput.length > 0 && !newInput.startsWith('{')) {
return "";
}
if (newInput.length > 5 && !newInput.startsWith('{2fi')) {
return "";
}
return newInput;
});
} else if (e.key === 'Backspace') {
setShortcutInput(prev => prev.slice(0, -1));
} else if (e.key === 'Escape') {
setShortcutInput("");
}
};

window.addEventListener('keydown', handleKeyPress);
return () => {
window.removeEventListener('keydown', handleKeyPress);
};
}, []);

const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => {
if (detail.stockTakeRecordStatus === "pass") {
return true;
}
return false;
}, []);

return (
<Box>
<Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}>
{t("Back to List")}
</Button>
<Typography variant="h6" sx={{ mb: 2 }}>
{t("Stock Take Section")}: {selectedSession.stockTakeSession}
</Typography>
{/*
{shortcutInput && (
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'info.light', borderRadius: 1, border: '1px solid', borderColor: 'info.main' }}>
<Typography variant="body2" color="info.dark" fontWeight={500}>
{t("Shortcut Input")}: <strong style={{ fontFamily: 'monospace', fontSize: '1.1em' }}>{shortcutInput}</strong>
</Typography>
</Box>
)}
*/}
{loadingDetails ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Warehouse Location")}</TableCell>
<TableCell>{t("Item")}</TableCell>
{/*<TableCell>{t("Item Name")}</TableCell>*/}
{/*<TableCell>{t("Lot No")}</TableCell>*/}
<TableCell>{t("Expiry Date")}</TableCell>
<TableCell>{t("Qty")}</TableCell>
<TableCell>{t("Bad Qty")}</TableCell>
{/*{inventoryLotDetails.some(d => editingRecord?.id === d.id) && (*/}
<TableCell>{t("Remark")}</TableCell>
<TableCell>{t("UOM")}</TableCell>
<TableCell>{t("Status")}</TableCell>
<TableCell>{t("Record Status")}</TableCell>
<TableCell>{t("Action")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{inventoryLotDetails.length === 0 ? (
<TableRow>
<TableCell colSpan={12} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data")}
</Typography>
</TableCell>
</TableRow>
) : (
inventoryLotDetails.map((detail) => {
const isEditing = editingRecord?.id === detail.id;
const submitDisabled = isSubmitDisabled(detail);
const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty;
const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty;

return (
<TableRow key={detail.id}>
<TableCell>{detail.warehouseCode || "-"}</TableCell>
<TableCell sx={{
maxWidth: 100,
wordBreak: 'break-word',
whiteSpace: 'normal',
lineHeight: 1.5
}}>{detail.itemCode || "-"}{detail.lotNo || "-"}{detail.itemName ? ` - ${detail.itemName}` : ""}</TableCell>
{/*
<TableCell
sx={{
maxWidth: 200,
wordBreak: 'break-word',
whiteSpace: 'normal',
lineHeight: 1.5
}}
>
{detail.itemName || "-"}
</TableCell>*/}
{/*<TableCell>{detail.lotNo || "-"}</TableCell>*/}
<TableCell>
{detail.expiryDate
? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT)
: "-"}
</TableCell>
<TableCell>
<Stack spacing={0.5}>
{isEditing && isFirstSubmit ? (
<TextField
size="small"
type="number"
value={firstQty}
onChange={(e) => setFirstQty(e.target.value)}
sx={{ width: 100 }}
/>
) : detail.firstStockTakeQty ? (
<Typography variant="body2">
{t("First")}: {detail.firstStockTakeQty.toFixed(2)}
</Typography>
) : null}
{isEditing && isSecondSubmit ? (
<TextField
size="small"
type="number"
value={secondQty}
onChange={(e) => setSecondQty(e.target.value)}
sx={{ width: 100 }}
/>
) : detail.secondStockTakeQty ? (
<Typography variant="body2">
{t("Second")}: {detail.secondStockTakeQty.toFixed(2)}
</Typography>
) : null}
{!detail.firstStockTakeQty && !detail.secondStockTakeQty && !isEditing && (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</Stack>
</TableCell>
<TableCell>
<Stack spacing={0.5}>
{isEditing && isFirstSubmit ? (
<TextField
size="small"
type="number"
value={firstBadQty}
onChange={(e) => setFirstBadQty(e.target.value)}
sx={{ width: 100 }}
/>
) : detail.firstBadQty ? (
<Typography variant="body2">
{t("First")}: {detail.firstBadQty.toFixed(2)}
</Typography>
) : null}
{isEditing && isSecondSubmit ? (
<TextField
size="small"
type="number"
value={secondBadQty}
onChange={(e) => setSecondBadQty(e.target.value)}
sx={{ width: 100 }}
/>
) : detail.secondBadQty ? (
<Typography variant="body2">
{t("Second")}: {detail.secondBadQty.toFixed(2)}
</Typography>
) : null}
{!detail.firstBadQty && !detail.secondBadQty && !isEditing && (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</Stack>
</TableCell>
<TableCell sx={{ width: 180 }}>
{isEditing && isSecondSubmit ? (
<>
<Typography variant="body2">{t("Remark")}</Typography>
<TextField
size="small"
value={remark}
onChange={(e) => setRemark(e.target.value)}
sx={{ width: 150 }}
// If you want a single-line input, remove multiline/rows:
// multiline
// rows={2}
/>
</>
) : (
<Typography variant="body2">
{detail.remarks || "-"}
</Typography>
)}
</TableCell>
<TableCell>{detail.uom || "-"}</TableCell>
<TableCell>
{detail.status ? (
<Chip size="small" label={t(detail.status)} color="default" />
) : (
"-"
)}
</TableCell>
<TableCell>
{detail.stockTakeRecordStatus === "pass" ? (
<Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" />
) : detail.stockTakeRecordStatus === "notMatch" ? (
<Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" />
) : (
<Chip size="small" label={t(detail.stockTakeRecordStatus || "")} color="default" />
)}
</TableCell>
<TableCell>
{isEditing ? (
<Stack direction="row" spacing={1}>
<Button
size="small"
variant="contained"
onClick={() => handleSaveStockTake(detail)}
disabled={saving || submitDisabled}
>
{t("Save")}
</Button>
<Button
size="small"
onClick={handleCancelEdit}
>
{t("Cancel")}
</Button>
</Stack>
) : (
<Button
size="small"
variant="outlined"
onClick={() => handleStartEdit(detail)}
disabled={submitDisabled}
>
{!detail.stockTakeRecordId
? t("Input")
: detail.stockTakeRecordStatus === "notMatch"
? t("Input")
: t("View")}
</Button>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
);
};

export default PickerStockTake;

+ 1
- 1
src/components/StockTakeManagement/StockTakeManagement.tsx 查看文件

@@ -40,7 +40,7 @@ const StockTakeManagement: React.FC = () => {
return (
<Box sx={{ width: "100%" }}>
<Typography variant="h4" sx={{ mb: 3 }}>
{t("Inventory Exception Management")}
{t("Stock Take Management")}
</Typography>

<Box sx={{ borderBottom: 1, borderColor: "divider" }}>


+ 93
- 408
src/components/StockTakeManagement/StockTakeTab.tsx 查看文件

@@ -1,430 +1,115 @@
"use client";

import {
Box,
Button,
Card,
CardContent,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
Paper,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useState, useMemo, useCallback } from "react";
import { Box, Tab, Tabs, Snackbar, Alert } from "@mui/material";
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import SearchBox, { Criterion } from "../SearchBox";
import SearchResults, { Column } from "../SearchResults";
import { defaultPagingController } from "../SearchResults/SearchResults";

// Fake data types
interface Floor {
id: number;
code: string;
name: string;
warehouseCode: string;
warehouseName: string;
}

interface Zone {
id: number;
floorId: number;
code: string;
name: string;
description: string;
}

interface InventoryLotLineForStockTake {
id: number;
zoneId: number;
itemCode: string;
itemName: string;
lotNo: string;
location: string;
systemQty: number;
countedQty?: number;
variance?: number;
uom: string;
}

// Fake data
const fakeFloors: Floor[] = [
{ id: 1, code: "F1", name: "1st Floor", warehouseCode: "WH001", warehouseName: "Main Warehouse" },
{ id: 2, code: "F2", name: "2nd Floor", warehouseCode: "WH001", warehouseName: "Main Warehouse" },
{ id: 3, code: "F3", name: "3rd Floor", warehouseCode: "WH001", warehouseName: "Main Warehouse" },
];

const fakeZones: Zone[] = [
{ id: 1, floorId: 1, code: "Z-A", name: "Zone A", description: "Row 1-5" },
{ id: 2, floorId: 1, code: "Z-B", name: "Zone B", description: "Row 6-10" },
{ id: 3, floorId: 2, code: "Z-C", name: "Zone C", description: "Row 1-5" },
{ id: 4, floorId: 2, code: "Z-D", name: "Zone D", description: "Row 6-10" },
{ id: 5, floorId: 3, code: "Z-E", name: "Zone E", description: "Row 1-5" },
];

const fakeLots: InventoryLotLineForStockTake[] = [
{ id: 1, zoneId: 1, itemCode: "M001", itemName: "Material A", lotNo: "LOT-2024-001", location: "A-01-01", systemQty: 100, uom: "PCS" },
{ id: 2, zoneId: 1, itemCode: "M002", itemName: "Material B", lotNo: "LOT-2024-002", location: "A-01-02", systemQty: 50, uom: "PCS" },
{ id: 3, zoneId: 1, itemCode: "M003", itemName: "Material C", lotNo: "LOT-2024-003", location: "A-01-03", systemQty: 75, uom: "KG" },
{ id: 4, zoneId: 2, itemCode: "M004", itemName: "Material D", lotNo: "LOT-2024-004", location: "B-01-01", systemQty: 200, uom: "PCS" },
{ id: 5, zoneId: 2, itemCode: "M005", itemName: "Material E", lotNo: "LOT-2024-005", location: "B-01-02", systemQty: 150, uom: "KG" },
];

type FloorSearchQuery = {
floorCode: string;
floorName: string;
warehouseCode: string;
};

type FloorSearchParamNames = keyof FloorSearchQuery;
import { AllPickedStockTakeListReponse } from "@/app/api/stockTake/actions";
import PickerCardList from "./PickerCardList";
import ApproverCardList from "./ApproverCardList";
import PickerStockTake from "./PickerStockTake";
import PickerReStockTake from "./PickerReStockTake";
import ApproverStockTake from "./ApproverStockTake";

const StockTakeTab: React.FC = () => {
const { t } = useTranslation(["inventory"]);

// Search states for floors
const defaultFloorInputs = useMemo(() => ({
floorCode: "",
floorName: "",
warehouseCode: "",
}), []);
const [floorInputs, setFloorInputs] = useState<Record<FloorSearchParamNames, string>>(defaultFloorInputs);

// Selection states
const [selectedFloor, setSelectedFloor] = useState<Floor | null>(null);
const [selectedZone, setSelectedZone] = useState<Zone | null>(null);

// Paging controllers
const [floorsPagingController, setFloorsPagingController] = useState(defaultPagingController);
const [zonesPagingController, setZonesPagingController] = useState(defaultPagingController);
const [lotsPagingController, setLotsPagingController] = useState(defaultPagingController);

// Stock take dialog
const [stockTakeDialogOpen, setStockTakeDialogOpen] = useState(false);
const [selectedLot, setSelectedLot] = useState<InventoryLotLineForStockTake | null>(null);
const [countedQty, setCountedQty] = useState<number>(0);
const [remark, setRemark] = useState("");

// Filtered data
const filteredFloors = useMemo(() => {
return fakeFloors.filter(floor => {
if (floorInputs.floorCode && !floor.code.toLowerCase().includes(floorInputs.floorCode.toLowerCase())) {
return false;
}
if (floorInputs.floorName && !floor.name.toLowerCase().includes(floorInputs.floorName.toLowerCase())) {
return false;
}
if (floorInputs.warehouseCode && !floor.warehouseCode.toLowerCase().includes(floorInputs.warehouseCode.toLowerCase())) {
return false;
}
return true;
});
}, [floorInputs]);

const filteredZones = useMemo(() => {
if (!selectedFloor) return [];
return fakeZones.filter(zone => zone.floorId === selectedFloor.id);
}, [selectedFloor]);

const filteredLots = useMemo(() => {
if (!selectedZone) return [];
return fakeLots.filter(lot => lot.zoneId === selectedZone.id);
}, [selectedZone]);

// Search criteria
const floorSearchCriteria: Criterion<FloorSearchParamNames>[] = useMemo(
() => [
{ label: t("Floor Code"), paramName: "floorCode", type: "text" },
{ label: t("Floor Name"), paramName: "floorName", type: "text" },
{ label: t("Warehouse Code"), paramName: "warehouseCode", type: "text" },
],
[t],
);

// Handlers
const handleFloorSearch = useCallback((query: Record<FloorSearchParamNames, string>) => {
setFloorInputs(() => query);
setFloorsPagingController(() => defaultPagingController);
}, []);

const handleFloorReset = useCallback(() => {
setFloorInputs(() => defaultFloorInputs);
setFloorsPagingController(() => defaultPagingController);
setSelectedFloor(null);
setSelectedZone(null);
}, [defaultFloorInputs]);

const handleFloorClick = useCallback((floor: Floor) => {
setSelectedFloor(floor);
setSelectedZone(null);
setZonesPagingController(() => defaultPagingController);
setLotsPagingController(() => defaultPagingController);
const { t } = useTranslation(["inventory", "common"]);
const [tabValue, setTabValue] = useState(0);
const [selectedSession, setSelectedSession] = useState<AllPickedStockTakeListReponse | null>(null);
const [viewMode, setViewMode] = useState<"details" | "reStockTake">("details");
const [snackbar, setSnackbar] = useState<{
open: boolean;
message: string;
severity: "success" | "error" | "warning"
}>({
open: false,
message: "",
severity: "success",
});

const handleCardClick = useCallback((session: AllPickedStockTakeListReponse) => {
setSelectedSession(session);
setViewMode("details");
}, []);

const handleZoneClick = useCallback((zone: Zone) => {
setSelectedZone(zone);
setLotsPagingController(() => defaultPagingController);
const handleReStockTakeClick = useCallback((session: AllPickedStockTakeListReponse) => {
setSelectedSession(session);
setViewMode("reStockTake");
}, []);

const handleStockTakeClick = useCallback((lot: InventoryLotLineForStockTake) => {
setSelectedLot(lot);
setCountedQty(lot.countedQty || lot.systemQty);
setRemark("");
setStockTakeDialogOpen(true);
const handleBackToList = useCallback(() => {
setSelectedSession(null);
setViewMode("details");
}, []);

const handleStockTakeSubmit = useCallback(() => {
if (!selectedLot) return;

const variance = countedQty - selectedLot.systemQty;
// Here you would call the API to submit stock take
console.log("Stock Take Submitted:", {
lotId: selectedLot.id,
systemQty: selectedLot.systemQty,
countedQty: countedQty,
variance: variance,
remark: remark,
const handleSnackbar = useCallback((message: string, severity: "success" | "error" | "warning") => {
setSnackbar({
open: true,
message,
severity,
});

alert(`${t("Stock take recorded successfully!")}\n${t("Variance")}: ${variance > 0 ? '+' : ''}${variance}`);
// Update the lot with counted qty (in real app, this would come from backend)
selectedLot.countedQty = countedQty;
selectedLot.variance = variance;

setStockTakeDialogOpen(false);
setSelectedLot(null);
}, [selectedLot, countedQty, remark, t]);

const handleDialogClose = useCallback(() => {
setStockTakeDialogOpen(false);
setSelectedLot(null);
}, []);

// Floor columns
const floorColumns: Column<Floor>[] = useMemo(
() => [
{ name: "code", label: t("Floor Code") },
{ name: "name", label: t("Floor Name") },
{
name: "warehouseCode",
label: t("Warehouse"),
renderCell: (params) => `${params.warehouseCode} - ${params.warehouseName}`,
},
],
[t],
);

// Zone columns
const zoneColumns: Column<Zone>[] = useMemo(
() => [
{ name: "code", label: t("Zone Code") },
{ name: "name", label: t("Zone Name") },
{ name: "description", label: t("Description") },
],
[t],
);

// Lot columns
const lotColumns: Column<InventoryLotLineForStockTake>[] = useMemo(
() => [
{ name: "itemCode", label: t("Item Code") },
{ name: "itemName", label: t("Item Name") },
{ name: "lotNo", label: t("Lot No") },
{ name: "location", label: t("Location") },
{ name: "systemQty", label: t("System Qty"), align: "right", type: "integer" },
{
name: "countedQty",
label: t("Counted Qty"),
align: "right",
renderCell: (params) => params.countedQty || "-",
},
{
name: "variance",
label: t("Variance"),
align: "right",
renderCell: (params) => {
if (params.variance === undefined) return "-";
const variance = params.variance;
return (
<Typography
variant="body2"
sx={{
color: variance === 0 ? "inherit" : variance > 0 ? "success.main" : "error.main",
fontWeight: variance !== 0 ? "bold" : "normal",
}}
>
{variance > 0 ? `+${variance}` : variance}
</Typography>
);
},
},
{ name: "uom", label: t("UOM") },
{
name: "id",
label: t("Action"),
renderCell: (params) => (
<Button size="small" variant="outlined" onClick={() => handleStockTakeClick(params)}>
{t("Stock Take")}
</Button>
),
},
],
[t, handleStockTakeClick],
);
if (selectedSession) {
return (
<Box>
{tabValue === 0 ? (
viewMode === "reStockTake" ? (
<PickerReStockTake
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
) : (
<PickerStockTake
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
)
) : (
<ApproverStockTake
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
)}
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.severity}>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
}

return (
<Box>
<Alert severity="info" sx={{ mb: 3 }}>
{t("This is a demo with fake data. API integration pending.")}
</Alert>

{/* Step 1: Select Floor */}
<Typography variant="h6" sx={{ mb: 2 }}>
{t("Step 1: Select Floor")}
</Typography>
<SearchBox
criteria={floorSearchCriteria}
onSearch={handleFloorSearch}
onReset={handleFloorReset}
/>
<SearchResults<Floor>
items={filteredFloors}
columns={floorColumns}
pagingController={floorsPagingController}
setPagingController={setFloorsPagingController}
totalCount={filteredFloors.length}
onRowClick={handleFloorClick}
/>

{/* Step 2: Select Zone */}
{selectedFloor && (
<>
<Typography variant="h6" sx={{ mt: 4, mb: 2 }}>
{t("Step 2: Select Zone")} - {selectedFloor.name}
</Typography>
<SearchResults<Zone>
items={filteredZones}
columns={zoneColumns}
pagingController={zonesPagingController}
setPagingController={setZonesPagingController}
totalCount={filteredZones.length}
onRowClick={handleZoneClick}
/>
</>
)}

{/* Step 3: Stock Take */}
{selectedZone && (
<>
<Typography variant="h6" sx={{ mt: 4, mb: 2 }}>
{t("Step 3: Perform Stock Take")} - {selectedZone.name}
</Typography>
<SearchResults<InventoryLotLineForStockTake>
items={filteredLots}
columns={lotColumns}
pagingController={lotsPagingController}
setPagingController={setLotsPagingController}
totalCount={filteredLots.length}
/>
</>
<Tabs value={tabValue} onChange={(e, newValue) => setTabValue(newValue)} sx={{ mb: 2 }}>
<Tab label={t("Picker")} />
<Tab label={t("Approver")} />
</Tabs>

{tabValue === 0 ? (
<PickerCardList
onCardClick={handleCardClick}
onReStockTakeClick={handleReStockTakeClick}
/>
) : (
<ApproverCardList onCardClick={handleCardClick} />
)}

{/* Stock Take Dialog */}
<Dialog open={stockTakeDialogOpen} onClose={handleDialogClose} maxWidth="sm" fullWidth>
<DialogTitle>{t("Stock Take")}</DialogTitle>
<DialogContent>
{selectedLot && (
<Stack spacing={3} sx={{ mt: 2 }}>
<Box>
<Typography variant="subtitle2" color="text.secondary">
{t("Item")}
</Typography>
<Typography variant="body1">
{selectedLot.itemCode} - {selectedLot.itemName}
</Typography>
</Box>

<Box>
<Typography variant="subtitle2" color="text.secondary">
{t("Lot No")}
</Typography>
<Typography variant="body1">{selectedLot.lotNo}</Typography>
</Box>

<Box>
<Typography variant="subtitle2" color="text.secondary">
{t("Location")}
</Typography>
<Typography variant="body1">{selectedLot.location}</Typography>
</Box>

<Box>
<Typography variant="subtitle2" color="text.secondary">
{t("System Qty")}
</Typography>
<Typography variant="body1">
{selectedLot.systemQty} {selectedLot.uom}
</Typography>
</Box>

<TextField
label={t("Counted Qty")}
type="number"
value={countedQty}
onChange={(e) => setCountedQty(parseInt(e.target.value) || 0)}
fullWidth
autoFocus
/>

<Box>
<Typography variant="subtitle2" color="text.secondary">
{t("Variance")}
</Typography>
<Typography
variant="h6"
sx={{
color:
countedQty - selectedLot.systemQty === 0
? "inherit"
: countedQty - selectedLot.systemQty > 0
? "success.main"
: "error.main",
}}
>
{countedQty - selectedLot.systemQty > 0 ? "+" : ""}
{countedQty - selectedLot.systemQty}
</Typography>
</Box>

<TextField
label={t("Remark")}
multiline
rows={3}
value={remark}
onChange={(e) => setRemark(e.target.value)}
fullWidth
/>
</Stack>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleDialogClose}>{t("Cancel")}</Button>
<Button onClick={handleStockTakeSubmit} variant="contained" color="primary">
{t("Submit Stock Take")}
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.severity}>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
);
};


Loading…
取消
儲存