From 10dbc666f253e753a837468f7ab60241f89c04ea Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 23 Mar 2026 19:32:16 +0800 Subject: [PATCH] update --- src/app/api/bom/client.ts | 12 + src/app/api/bom/index.ts | 52 ++ src/app/api/stockTake/actions.ts | 28 + .../ImportBom/ImportBomDetailTab.tsx | 831 +++++++++++++++++- .../ApproverStockTakeAll.tsx | 68 +- .../StockTakeManagement/StockTakeTab.tsx | 104 ++- 6 files changed, 982 insertions(+), 113 deletions(-) diff --git a/src/app/api/bom/client.ts b/src/app/api/bom/client.ts index 1dcf663..2d17ffc 100644 --- a/src/app/api/bom/client.ts +++ b/src/app/api/bom/client.ts @@ -8,6 +8,7 @@ import type { ImportBomItemPayload, BomCombo, BomDetailResponse, + EditBomRequest, } from "./index"; export async function uploadBomFiles( @@ -87,6 +88,17 @@ export async function fetchBomComboClient(): Promise { ); return response.data; } + + export async function editBomClient( + id: number, + request: EditBomRequest, + ): Promise { + const response = await axiosInstance.put( + `${NEXT_PUBLIC_API_URL}/bom/${id}`, + request, + ); + return response.data; + } export type BomExcelCheckProgress = { batchId: string; totalFiles: number; diff --git a/src/app/api/bom/index.ts b/src/app/api/bom/index.ts index 0b1ceef..968a0d9 100644 --- a/src/app/api/bom/index.ts +++ b/src/app/api/bom/index.ts @@ -61,6 +61,7 @@ export const fetchBomScores = cache(async () => { export interface BomMaterialDto { itemCode?: string; itemName?: string; + isConsumable?: boolean; baseQty?: number; baseUom?: string; stockQty?: number; @@ -71,8 +72,10 @@ export interface BomMaterialDto { export interface BomProcessDto { seqNo?: number; + processCode?: string; processName?: string; processDescription?: string; + equipmentCode?: string; equipmentName?: string; durationInMinute?: number; prepTimeInMinute?: number; @@ -97,4 +100,53 @@ export interface BomDetailResponse { outputQtyUom?: string; materials: BomMaterialDto[]; processes: BomProcessDto[]; +} + +export interface EditBomRequest { + // basic fields + description?: string; + outputQty?: number; + outputQtyUom?: string; + yield?: number; + + // baseScore inputs (server will recalculate) + isDark?: number; + isFloat?: number; + isDense?: number; + scrapRate?: number; + allergicSubstances?: number; + timeSequence?: number; + complexity?: number; + isDrink?: boolean; + + materials?: EditBomMaterialRequest[]; + processes?: EditBomProcessRequest[]; +} + +export interface EditBomMaterialRequest { + id?: number; + // At least one of itemId/itemCode should be present. + itemId?: number; + itemCode?: string; + qty: number; + isConsumable?: boolean; +} + +export interface EditBomProcessRequest { + id?: number; + seqNo?: number; + processId?: number; + processCode?: string; + equipmentId?: number; + equipmentCode?: string; + newEquipment?: { + code: string; + name: string; + description?: string; + equipmentTypeId?: number; + }; + description?: string; + durationInMinute?: number; + prepTimeInMinute?: number; + postProdTimeInMinute?: number; } \ No newline at end of file diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index e0c156a..0151196 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -122,6 +122,34 @@ export const getApproverInventoryLotDetailsAll = async ( ); return response; } +export const getApproverInventoryLotDetailsAllPending = async ( + stockTakeId?: number | null, + pageNum: number = 0, + pageSize: number = 2147483647 +) => { + const params = new URLSearchParams(); + params.append("pageNum", String(pageNum)); + params.append("pageSize", String(pageSize)); + if (stockTakeId != null && stockTakeId > 0) { + params.append("stockTakeId", String(stockTakeId)); + } + const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAllPending?${params.toString()}`; + return serverFetchJson>(url, { method: "GET" }); +} +export const getApproverInventoryLotDetailsAllApproved = async ( + stockTakeId?: number | null, + pageNum: number = 0, + pageSize: number = 50 +) => { + const params = new URLSearchParams(); + params.append("pageNum", String(pageNum)); + params.append("pageSize", String(pageSize)); + if (stockTakeId != null && stockTakeId > 0) { + params.append("stockTakeId", String(stockTakeId)); + } + const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAllApproved?${params.toString()}`; + return serverFetchJson>(url, { method: "GET" }); +} export const importStockTake = async (data: FormData) => { const importStockTake = await serverFetchJson( diff --git a/src/components/ImportBom/ImportBomDetailTab.tsx b/src/components/ImportBom/ImportBomDetailTab.tsx index ccfdd7f..f4754ac 100644 --- a/src/components/ImportBom/ImportBomDetailTab.tsx +++ b/src/components/ImportBom/ImportBomDetailTab.tsx @@ -16,13 +16,27 @@ import { TableRow, TableCell, TableBody, + Button, + TextField, + Checkbox, + FormControlLabel, + IconButton, } from "@mui/material"; import type { BomCombo, BomDetailResponse } from "@/app/api/bom"; -import { fetchBomComboClient, fetchBomDetailClient } from "@/app/api/bom/client"; +import { + editBomClient, + fetchBomComboClient, + fetchBomDetailClient, +} from "@/app/api/bom/client"; import type { SelectChangeEvent } from "@mui/material/Select"; import { useTranslation } from "react-i18next"; import SearchBox, { Criterion } from "../SearchBox"; import { useMemo, useCallback } from "react"; +import AddIcon from "@mui/icons-material/Add"; +import SaveIcon from "@mui/icons-material/Save"; +import CancelIcon from "@mui/icons-material/Cancel"; +import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; const ImportBomDetailTab: React.FC = () => { const { t } = useTranslation( "common" ); const [bomList, setBomList] = useState([]); @@ -32,6 +46,89 @@ const ImportBomDetailTab: React.FC = () => { const [loadingDetail, setLoadingDetail] = useState(false); const [filteredBoms, setFilteredBoms] = useState([]) const [currentBom, setCurrentBom] = useState(null); + + type EditMaterialRow = { + key: string; + id?: number; + itemCode?: string; + itemName?: string; + qty: number; + isConsumable: boolean; + + baseUom?: string; + stockQty?: number; + stockUom?: string; + salesQty?: number; + salesUom?: string; + }; + + type EditProcessRow = { + key: string; + id?: number; + seqNo?: number; + processCode?: string; + processName?: string; + description: string; + equipmentCode?: string; + durationInMinute: number; + prepTimeInMinute: number; + postProdTimeInMinute: number; + }; + + const [isEditing, setIsEditing] = useState(false); + const [editLoading, setEditLoading] = useState(false); + const [editError, setEditError] = useState(null); + const [editBasic, setEditBasic] = useState<{ + description: string; + outputQty: number; + outputQtyUom: string; + + isDark: number; + isFloat: number; + isDense: number; + scrapRate: number; + allergicSubstances: number; + timeSequence: number; + complexity: number; + isDrink: boolean; + } | null>(null); + + const [editMaterials, setEditMaterials] = useState([]); + const [editProcesses, setEditProcesses] = useState([]); + + // Process add form (uses dropdown selections). + const [processAddForm, setProcessAddForm] = useState<{ + processCode: string; + equipmentCode: string; + description: string; + durationInMinute: number; + prepTimeInMinute: number; + postProdTimeInMinute: number; + }>({ + processCode: "", + equipmentCode: "", + description: "", + durationInMinute: 0, + prepTimeInMinute: 0, + postProdTimeInMinute: 0, + }); + + const processCodeOptions = useMemo(() => { + const codes = new Set(); + (detail?.processes ?? []).forEach((p) => { + if (p.processCode) codes.add(p.processCode); + }); + return Array.from(codes); + }, [detail]); + + const equipmentCodeOptions = useMemo(() => { + const codes = new Set(); + (detail?.processes ?? []).forEach((p) => { + if (p.equipmentCode) codes.add(p.equipmentCode); + }); + return Array.from(codes); + }, [detail]); + useEffect(() => { const loadList = async () => { setLoadingList(true); @@ -143,6 +240,201 @@ const ImportBomDetailTab: React.FC = () => { } }; + const genKey = () => Math.random().toString(36).slice(2); + + const startEdit = useCallback(() => { + if (!detail) return; + + setEditError(null); + setEditBasic({ + description: detail.description ?? "", + outputQty: detail.outputQty ?? 0, + outputQtyUom: detail.outputQtyUom ?? "", + + isDark: detail.isDark ?? 0, + isFloat: detail.isFloat ?? 0, + isDense: detail.isDense ?? 0, + scrapRate: detail.scrapRate ?? 0, + allergicSubstances: detail.allergicSubstances ?? 0, + timeSequence: detail.timeSequence ?? 0, + complexity: detail.complexity ?? 0, + isDrink: detail.isDrink ?? false, + }); + + setEditMaterials( + (detail.materials ?? []).map((m) => ({ + key: genKey(), + id: undefined, + itemCode: m.itemCode ?? "", + itemName: m.itemName ?? "", + qty: m.baseQty ?? 0, + isConsumable: m.isConsumable ?? false, + baseUom: m.baseUom, + stockQty: m.stockQty, + stockUom: m.stockUom, + salesQty: m.salesQty, + salesUom: m.salesUom, + })), + ); + + setEditProcesses( + (detail.processes ?? []).map((p) => ({ + key: genKey(), + id: undefined, + seqNo: p.seqNo, + processCode: p.processCode ?? "", + processName: p.processName, + description: p.processDescription ?? "", + equipmentCode: p.equipmentCode ?? p.equipmentName ?? "", + durationInMinute: p.durationInMinute ?? 0, + prepTimeInMinute: p.prepTimeInMinute ?? 0, + postProdTimeInMinute: p.postProdTimeInMinute ?? 0, + })), + ); + + setIsEditing(true); + }, [detail]); + + const cancelEdit = useCallback(() => { + setIsEditing(false); + setEditLoading(false); + setEditError(null); + setEditBasic(null); + setEditMaterials([]); + setEditProcesses([]); + setProcessAddForm({ + processCode: "", + equipmentCode: "", + description: "", + durationInMinute: 0, + prepTimeInMinute: 0, + postProdTimeInMinute: 0, + }); + }, []); + + const addMaterialRow = useCallback(() => { + setEditMaterials((prev) => [ + ...prev, + { + key: genKey(), + itemCode: "", + itemName: "", + qty: 0, + isConsumable: false, + baseUom: "", + stockQty: undefined, + stockUom: "", + salesQty: undefined, + salesUom: "", + }, + ]); + }, []); + + const addProcessRow = useCallback(() => { + setEditProcesses((prev) => [ + ...prev, + { + key: genKey(), + seqNo: undefined, + processCode: "", + processName: "", + description: "", + equipmentCode: "", + durationInMinute: 0, + prepTimeInMinute: 0, + postProdTimeInMinute: 0, + }, + ]); + }, []); + + const addProcessFromForm = useCallback(() => { + const pCode = processAddForm.processCode.trim(); + if (!pCode) { + setEditError("請先選擇工序 Process Code"); + return; + } + + setEditProcesses((prev) => [ + ...prev, + { + key: genKey(), + seqNo: undefined, + processCode: pCode, + processName: "", + description: processAddForm.description ?? "", + equipmentCode: processAddForm.equipmentCode.trim(), + durationInMinute: processAddForm.durationInMinute ?? 0, + prepTimeInMinute: processAddForm.prepTimeInMinute ?? 0, + postProdTimeInMinute: processAddForm.postProdTimeInMinute ?? 0, + }, + ]); + + setProcessAddForm({ + processCode: "", + equipmentCode: "", + description: "", + durationInMinute: 0, + prepTimeInMinute: 0, + postProdTimeInMinute: 0, + }); + setEditError(null); + }, [processAddForm]); + + const deleteMaterialRow = useCallback((key: string) => { + setEditMaterials((prev) => prev.filter((r) => r.key !== key)); + }, []); + + const deleteProcessRow = useCallback((key: string) => { + setEditProcesses((prev) => prev.filter((r) => r.key !== key)); + }, []); + + const handleSaveEdit = useCallback(async () => { + if (!detail || !editBasic) return; + setEditLoading(true); + setEditError(null); + + try { + for (const p of editProcesses) { + if (!p.processCode?.trim()) { + throw new Error("工序行 Process Code 不能为空"); + } + } + + const payload: any = { + description: editBasic.description || undefined, + outputQty: editBasic.outputQty, + outputQtyUom: editBasic.outputQtyUom || undefined, + + isDark: editBasic.isDark, + isFloat: editBasic.isFloat, + isDense: editBasic.isDense, + scrapRate: editBasic.scrapRate, + allergicSubstances: editBasic.allergicSubstances, + timeSequence: editBasic.timeSequence, + complexity: editBasic.complexity, + isDrink: editBasic.isDrink, + processes: editProcesses.map((p) => ({ + id: p.id, + seqNo: p.seqNo, + processCode: p.processCode?.trim() || undefined, + equipmentCode: p.equipmentCode?.trim() || undefined, + description: p.description || undefined, + durationInMinute: p.durationInMinute, + prepTimeInMinute: p.prepTimeInMinute, + postProdTimeInMinute: p.postProdTimeInMinute, + })), + }; + + const updated = await editBomClient(detail.id, payload); + setDetail(updated); + setIsEditing(false); + } catch (e: any) { + setEditError(e?.message || "保存失败"); + } finally { + setEditLoading(false); + } + }, [detail, editBasic, editProcesses]); + return ( @@ -175,33 +467,253 @@ const ImportBomDetailTab: React.FC = () => { {/* Basic Info 列表 */} - - {t("Basic Info")} - - - - {/* 第一行:輸出數量 + 類型 */} - - {t("Output Quantity")}:{" "} - {detail.outputQty} {detail.outputQtyUom} - {" "} - {t("Type")}: {detail.description ?? "-"} + + + {t("Basic Info")} - {/* 第二行:各種指標,排成一行 key:value, key:value */} - - {t("Allergic Substances")}: {renderAllergic(detail.allergicSubstances)} - {" "}{t("Depth")}: {detail.isDark ?? "-"} - {" "}{t("Float")}: {renderIsFloat(detail.isFloat)} - {" "}{t("Density")}: {renderIsDense(detail.isDense)} - + {!isEditing ? ( + + ) : ( + + + + + )} + - - {t("Time Sequence")}: {renderTimeSequence(detail.timeSequence)} - {" "}{t("Complexity")}: {renderComplexity(detail.complexity)} - {" "}{t("Base Score")}: {detail.baseScore ?? "-"} + {editError && ( + + {editError} - + )} + + {!isEditing && ( + + {/* 第一行:輸出數量 + 類型 */} + + {t("Output Quantity")}: {detail.outputQty} {detail.outputQtyUom} + {" "} + {t("Type")}: {detail.description ?? "-"} + + + {/* 第二行:各種指標,排成一行 key:value, key:value */} + + {t("Allergic Substances")}:{" "} + {renderAllergic(detail.allergicSubstances)} + {" "}{t("Depth")}: {detail.isDark ?? "-"} + {" "}{t("Float")}: {renderIsFloat(detail.isFloat)} + {" "}{t("Density")}: {renderIsDense(detail.isDense)} + + + + {t("Time Sequence")}: {renderTimeSequence(detail.timeSequence)} + {" "}{t("Complexity")}: {renderComplexity(detail.complexity)} + {" "}{t("Base Score")}: {detail.baseScore ?? "-"} + + + )} + + {isEditing && editBasic && ( + + + setEditBasic((p) => (p ? { ...p, description: e.target.value } : p)) + } + fullWidth + /> + + + + setEditBasic((p) => + p ? { ...p, outputQty: Number(e.target.value) } : p + ) + } + /> + + setEditBasic((p) => + p ? { ...p, outputQtyUom: e.target.value } : p + ) + } + /> + + + + + setEditBasic((p) => + p ? { ...p, scrapRate: Number(e.target.value) } : p + ) + } + /> + + {t("Allergic Substances")} + + + + + + + setEditBasic((p) => + p ? { ...p, isDark: Number(e.target.value) } : p + ) + } + inputProps={{ min: 1, max: 5, step: 1 }} + /> + + {t("Float")} + + + + {t("Density")} + + + + + + + {t("Time Sequence")} + + + + {t("Complexity")} + + + + + + setEditBasic((p) => + p ? { ...p, isDrink: e.target.checked } : p + ) + } + /> + } + label={t("Is Drink")} + /> + + )} {/* 材料列表 */} @@ -243,36 +755,271 @@ const ImportBomDetailTab: React.FC = () => { {t("Process & Equipment")} + {isEditing && ( + + + + {t("Process Code")} + + + + + {t("Equipment Code")} + + + + + setProcessAddForm((p) => ({ + ...p, + description: e.target.value, + })) + } + /> + + + setProcessAddForm((p) => ({ + ...p, + durationInMinute: Number(e.target.value), + })) + } + /> + + setProcessAddForm((p) => ({ + ...p, + prepTimeInMinute: Number(e.target.value), + })) + } + /> + + setProcessAddForm((p) => ({ + ...p, + postProdTimeInMinute: Number(e.target.value), + })) + } + /> + + + + + )} {t("Sequence")} {t("Process Name")} {t("Process Description")} - {t("Equipment Name")} + {t("Process Code")} + {t("Equipment Code")} {t("Duration (Minutes)")} {t("Prep Time (Minutes)")} {t("Post Prod Time (Minutes)")} + {isEditing && ( + {t("Actions")} + )} - {detail.processes.map((p, i) => ( - - {p.seqNo} - {p.processName} - {p.processDescription} - {p.equipmentName} - - {p.durationInMinute} - - - {p.prepTimeInMinute} - - - {p.postProdTimeInMinute} - - - ))} + {isEditing + ? editProcesses.map((p) => ( + + {p.seqNo ?? "-"} + {p.processName || "-"} + + + setEditProcesses((prev) => + prev.map((x) => + x.key === p.key + ? { ...x, description: e.target.value } + : x, + ), + ) + } + /> + + + + + + + + + + + + + + setEditProcesses((prev) => + prev.map((x) => + x.key === p.key + ? { ...x, durationInMinute: Number(e.target.value) } + : x, + ), + ) + } + /> + + + + setEditProcesses((prev) => + prev.map((x) => + x.key === p.key + ? { ...x, prepTimeInMinute: Number(e.target.value) } + : x, + ), + ) + } + /> + + + + setEditProcesses((prev) => + prev.map((x) => + x.key === p.key + ? { + ...x, + postProdTimeInMinute: Number(e.target.value), + } + : x, + ), + ) + } + /> + + + deleteProcessRow(p.key)}> + + + + + )) + : detail.processes.map((p, i) => ( + + {p.seqNo} + {p.processName} + {p.processDescription} + {p.processCode ?? "-"} + {p.equipmentCode ?? p.equipmentName} + {p.durationInMinute} + {p.prepTimeInMinute} + + {p.postProdTimeInMinute} + + + ))}
diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index d5c65fe..5d20138 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -25,7 +25,8 @@ import { InventoryLotDetailResponse, SaveApproverStockTakeRecordRequest, saveApproverStockTakeRecord, - getApproverInventoryLotDetailsAll, + getApproverInventoryLotDetailsAllPending, + getApproverInventoryLotDetailsAllApproved, BatchSaveApproverStockTakeAllRequest, batchSaveApproverStockTakeRecordsAll, updateStockTakeRecordStatusToNotMatch, @@ -37,7 +38,8 @@ import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; interface ApproverStockTakeAllProps { selectedSession: AllPickedStockTakeListReponse; - onBack: () => void; + mode: "pending" | "approved"; + onBack?: () => void; onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; } @@ -45,6 +47,7 @@ type QtySelectionType = "first" | "second" | "approver"; const ApproverStockTakeAll: React.FC = ({ selectedSession, + mode, onBack, onSnackbar, }) => { @@ -100,11 +103,17 @@ const ApproverStockTakeAll: React.FC = ({ actualSize = typeof size === "string" ? parseInt(size, 10) : size; } - const response = await getApproverInventoryLotDetailsAll( - selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, - pageNum, - actualSize - ); + const response = mode === "approved" + ? await getApproverInventoryLotDetailsAllApproved( + selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, + pageNum, + actualSize + ) + : await getApproverInventoryLotDetailsAllPending( + selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, + pageNum, + actualSize + ); setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); setTotal(response.total || 0); } catch (e) { @@ -115,7 +124,7 @@ const ApproverStockTakeAll: React.FC = ({ setLoadingDetails(false); } }, - [selectedSession, total] + [selectedSession, total, mode] ); useEffect(() => { @@ -188,6 +197,7 @@ const ApproverStockTakeAll: React.FC = ({ const handleSaveApproverStockTake = useCallback( async (detail: InventoryLotDetailResponse) => { + if (mode === "approved") return; if (!selectedSession || !currentUserId) { return; } @@ -285,11 +295,12 @@ const ApproverStockTakeAll: React.FC = ({ setSaving(false); } }, - [selectedSession, currentUserId, qtySelection, approverQty, approverBadQty, t, onSnackbar] + [selectedSession, currentUserId, qtySelection, approverQty, approverBadQty, t, onSnackbar, mode] ); const handleUpdateStatusToNotMatch = useCallback( async (detail: InventoryLotDetailResponse) => { + if (mode === "approved") return; if (!detail.stockTakeRecordId) { onSnackbar(t("Stock take record ID is required"), "error"); return; @@ -328,6 +339,7 @@ const ApproverStockTakeAll: React.FC = ({ ); const handleBatchSubmitAll = useCallback(async () => { + if (mode === "approved") return; if (!selectedSession || !currentUserId) { return; } @@ -369,7 +381,7 @@ const ApproverStockTakeAll: React.FC = ({ } finally { setBatchSaving(false); } - }, [selectedSession, currentUserId, variancePercentTolerance, t, onSnackbar, loadDetails, page, pageSize]); + }, [selectedSession, currentUserId, variancePercentTolerance, t, onSnackbar, loadDetails, page, pageSize, mode]); const formatNumber = (num: number | null | undefined): string => { if (num == null) return "0"; @@ -393,12 +405,14 @@ const ApproverStockTakeAll: React.FC = ({ return ( - + {onBack && ( + + )} = ({ sx={{ width: 100 }} inputProps={{ min: 0, max: 100, step: 0.1 }} /> - + {mode === "pending" && ( + + )}
{loadingDetails ? ( @@ -748,7 +764,7 @@ const ApproverStockTakeAll: React.FC = ({ )} - {detail.stockTakeRecordId && + {mode === "pending" && detail.stockTakeRecordId && detail.stockTakeRecordStatus !== "notMatch" && (