From c0bfbe8053d0f4628e3e5fcb5641c64ab79a1537 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 9 Jan 2026 11:46:24 +0800 Subject: [PATCH 01/12] update stock take --- src/app/api/stockTake/actions.ts | 4 + .../GoodPickExecutiondetail.tsx | 20 +- .../PickOrderSearch/PickExecution.tsx | 8 +- .../StockTakeManagement/ApproverCardList.tsx | 86 ++- .../StockTakeManagement/ApproverStockTake.tsx | 81 +- .../StockTakeManagement/PickerCardList.tsx | 85 ++- .../StockTakeManagement/PickerReStockTake.tsx | 176 +++-- .../StockTakeManagement/PickerStockTake.tsx | 695 +++++++++++------- .../StockTakeManagementWrapper.tsx | 4 +- src/i18n/zh/inventory.json | 19 +- 10 files changed, 757 insertions(+), 421 deletions(-) diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index 5e0c0e6..c092195 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -77,11 +77,15 @@ export interface AllPickedStockTakeListReponse { stockTakeSession: string; lastStockTakeDate: string | null; status: string|null; + approverName: string | null; currentStockTakeItemNumber: number; totalInventoryLotNumber: number; stockTakeId: number; stockTakerName: string | null; totalItemNumber: number; + startTime: string | null; + endTime: string | null; + reStockTakeTrueFalse: boolean; } export const importStockTake = async (data: FormData) => { diff --git a/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx b/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx index 99b1d6f..56a41e4 100644 --- a/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx +++ b/src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx @@ -552,7 +552,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO try { const userIdToUse = userId || currentUserId; - console.log("🔍 fetchAllCombinedLotData called with userId:", userIdToUse); + console.log(" fetchAllCombinedLotData called with userId:", userIdToUse); if (!userIdToUse) { console.warn("⚠️ No userId available, skipping API call"); @@ -620,9 +620,9 @@ const fgOrder: FGPickOrderResponse = { }; setFgPickOrders([fgOrder]); - console.log("🔍 DEBUG fgOrder.lineCountsPerPickOrder:", fgOrder.lineCountsPerPickOrder); -console.log("🔍 DEBUG fgOrder.pickOrderCodes:", fgOrder.pickOrderCodes); -console.log("🔍 DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); + console.log(" DEBUG fgOrder.lineCountsPerPickOrder:", fgOrder.lineCountsPerPickOrder); +console.log(" DEBUG fgOrder.pickOrderCodes:", fgOrder.pickOrderCodes); +console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); // 移除:不需要 doPickOrderDetail 和 switcher 逻辑 // if (hierarchicalData.pickOrders.length > 1) { ... } @@ -749,7 +749,7 @@ console.log("🔍 DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); }); console.log(" Transformed flat lot data:", flatLotData); - console.log("🔍 Total items (including null stock):", flatLotData.length); + console.log(" Total items (including null stock):", flatLotData.length); setCombinedLotData(flatLotData); setOriginalCombinedData(flatLotData); @@ -766,7 +766,7 @@ console.log("🔍 DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); }, [currentUserId, checkAllLotsCompleted]); // 移除 selectedPickOrderId 依赖 // Add effect to check completion when lot data changes const handleManualLotConfirmation = useCallback(async (currentLotNo: string, newLotNo: string) => { - console.log(`🔍 Manual lot confirmation: Current=${currentLotNo}, New=${newLotNo}`); + console.log(` Manual lot confirmation: Current=${currentLotNo}, New=${newLotNo}`); // 使用第一个输入框的 lot number 查找当前数据 const currentLot = combinedLotData.find(lot => @@ -1387,7 +1387,7 @@ console.log("🔍 DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos); return; } - console.log(`🔍 Lot mismatch: Expected ${expectedLot.lotNo}, Scanned ${scanned?.lotNo}`); + console.log(` Lot mismatch: Expected ${expectedLot.lotNo}, Scanned ${scanned?.lotNo}`); setSelectedLotForQr(expectedLot); handleLotMismatch( { @@ -1424,7 +1424,7 @@ useEffect(() => { return; } if (latestQr === "{2fic}") { - console.log("🔍 Detected {2fic} shortcut - opening manual lot confirmation form"); + console.log(" Detected {2fic} shortcut - opening manual lot confirmation form"); setManualLotConfirmationOpen(true); resetScan(); setLastProcessedQr(latestQr); @@ -1432,7 +1432,7 @@ useEffect(() => { return; // 直接返回,不继续处理其他逻辑 } if (latestQr && latestQr !== lastProcessedQr) { - console.log(`🔍 Processing new QR code with enhanced validation: ${latestQr}`); + console.log(` Processing new QR code with enhanced validation: ${latestQr}`); setLastProcessedQr(latestQr); setProcessedQrCodes(prev => new Set(prev).add(latestQr)); @@ -1921,7 +1921,7 @@ const handleSubmitPickQtyWithQty = useCallback(async (lot: any, submitQty: numbe setPickOrderSwitching(true); try { - console.log("🔍 Switching to pick order:", pickOrderId); + console.log(" Switching to pick order:", pickOrderId); setSelectedPickOrderId(pickOrderId); // 强制刷新数据,确保显示正确的 pick order 数据 diff --git a/src/components/PickOrderSearch/PickExecution.tsx b/src/components/PickOrderSearch/PickExecution.tsx index 486b52e..50f6d92 100644 --- a/src/components/PickOrderSearch/PickExecution.tsx +++ b/src/components/PickOrderSearch/PickExecution.tsx @@ -362,8 +362,8 @@ const PickExecution: React.FC = ({ filterArgs }) => { // FIXED: 计算累计拣货数量 const totalPickedForThisLot = (selectedLot.actualPickQty || 0) + qty; console.log(" DEBUG - Previous picked:", selectedLot.actualPickQty || 0); - console.log("🔍 DEBUG - Current submit:", qty); - console.log("🔍 DEBUG - Total picked:", totalPickedForThisLot); + console.log(" DEBUG - Current submit:", qty); + console.log(" DEBUG - Total picked:", totalPickedForThisLot); console.log("�� DEBUG - Required qty:", selectedLot.requiredQty); // FIXED: 状态应该基于累计拣货数量 @@ -428,7 +428,7 @@ const PickExecution: React.FC = ({ filterArgs }) => { if (currentConsoCode) { try { - console.log(`🔍 Checking completion for consoCode: ${currentConsoCode}`); + console.log(` Checking completion for consoCode: ${currentConsoCode}`); const completionResponse = await checkAndCompletePickOrderByConsoCode(currentConsoCode); console.log("�� Completion response:", completionResponse); @@ -788,7 +788,7 @@ const handleIssueNoLotStockOutLine = useCallback(async (stockOutLineId: number) const foundLine = pickOrder.pickOrderLines.find(line => line.id === selectedRowId); if (foundLine) { correctConsoCode = pickOrder.consoCode; - console.log(`🔍 Found consoCode for line ${selectedRowId}: ${correctConsoCode} (from pick order ${pickOrder.id})`); + console.log(` Found consoCode for line ${selectedRowId}: ${correctConsoCode} (from pick order ${pickOrder.id})`); break; } } diff --git a/src/components/StockTakeManagement/ApproverCardList.tsx b/src/components/StockTakeManagement/ApproverCardList.tsx index 1baad2a..153f5a7 100644 --- a/src/components/StockTakeManagement/ApproverCardList.tsx +++ b/src/components/StockTakeManagement/ApproverCardList.tsx @@ -58,7 +58,66 @@ const ApproverCardList: React.FC = ({ onCardClick }) => { const startIdx = page * PER_PAGE; const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); + const TimeDisplay: React.FC<{ startTime: string | null; endTime: string | null }> = ({ startTime, endTime }) => { + const [currentTime, setCurrentTime] = useState(dayjs()); + useEffect(() => { + if (!endTime && startTime) { + const interval = setInterval(() => { + setCurrentTime(dayjs()); + }, 1000); // 每秒更新一次 + + return () => clearInterval(interval); + } + }, [startTime, endTime]); + + if (endTime && startTime) { + // 当有结束时间时,计算从开始到结束的持续时间 + const start = dayjs(startTime); + const end = dayjs(endTime); + const duration = dayjs.duration(end.diff(start)); + const hours = Math.floor(duration.asHours()); + const minutes = duration.minutes(); + const seconds = duration.seconds(); + + return ( + <> + {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')} + + ); + } else if (startTime) { + // 当没有结束时间时,显示实时计时器 + const start = dayjs(startTime); + const duration = dayjs.duration(currentTime.diff(start)); + const hours = Math.floor(duration.asHours()); + const minutes = duration.minutes(); + const seconds = duration.seconds(); + + return ( + <> + {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')} + + ); + } else { + return <>-; + } + }; + const startTimeDisplay = (startTime: string | null) => { + if (startTime) { + const start = dayjs(startTime); + return start.format("HH:mm"); + } else { + return "-"; + } + }; + const endTimeDisplay = (endTime: string | null) => { + if (endTime) { + const end = dayjs(endTime); + return end.format("HH:mm"); + } else { + return "-"; + } + }; const getStatusColor = (status: string | null) => { if (!status) return "default"; @@ -126,17 +185,22 @@ const ApproverCardList: React.FC = ({ onCardClick }) => { {t("Section")}: {session.stockTakeSession} - {session.status ? ( - - ) : ( - - )} + + {t("Last Stock Take Date")}: {lastStockTakeDate || "-"} + + + {t("Stock Taker")}: {session.stockTakerName || "-"} + {t("Approver")}: {session.approverName || "-"} + + + {t("start time")}: {startTimeDisplay(session.startTime) || "-"} + {t("end time")}: {endTimeDisplay(session.endTime) || "-"} + - {t("Last Stock Take Date")}: {lastStockTakeDate || "-"} + {t("Control Time")}: - {session.totalInventoryLotNumber > 0 && ( @@ -156,7 +220,7 @@ const ApproverCardList: React.FC = ({ onCardClick }) => { )} - + + {session.status ? ( + + ) : ( + + )} diff --git a/src/components/StockTakeManagement/ApproverStockTake.tsx b/src/components/StockTakeManagement/ApproverStockTake.tsx index 4426965..a036bd0 100644 --- a/src/components/StockTakeManagement/ApproverStockTake.tsx +++ b/src/components/StockTakeManagement/ApproverStockTake.tsx @@ -252,7 +252,20 @@ const ApproverStockTake: React.FC = ({ useEffect(() => { handleBatchSubmitAllRef.current = handleBatchSubmitAll; }, [handleBatchSubmitAll]); - + const formatNumber = (num: number | null | undefined): string => { + if (num == null) return "0.00"; + return num.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + }; + const uniqueWarehouses = Array.from( + new Set( + inventoryLotDetails + .map(detail => detail.warehouse) + .filter(warehouse => warehouse && warehouse.trim() !== "") + ) + ).join(", "); const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { // Only allow editing if there's a first stock take qty if (!detail.firstStockTakeQty || detail.firstStockTakeQty === 0) { @@ -266,9 +279,18 @@ const ApproverStockTake: React.FC = ({ + {t("Stock Take Section")}: {selectedSession.stockTakeSession} + {uniqueWarehouses && ( + <> {t("Warehouse")}: {uniqueWarehouses} + )} + + + {loadingDetails ? ( @@ -279,8 +301,8 @@ const ApproverStockTake: React.FC = ({ {t("Warehouse Location")} - {t("Item")} - {t("Stock Take Qty")} + {t("Item-lotNo-ExpiryDate")} + {t("Stock Take Qty(include Bad Qty)= Available Qty")} {t("Remark")} {t("UOM")} {t("Record Status")} @@ -316,21 +338,21 @@ const ApproverStockTake: React.FC = ({ {detail.itemCode || "-"} {detail.itemName || "-"} {detail.lotNo || "-"} {detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"} - + {/**/} {detail.finalQty != null ? ( - // 提交后只显示差异行 + - {t("Difference")}: {detail.finalQty?.toFixed(2) || "0.00"} - {(detail.availableQty || 0).toFixed(2)} = {((detail.finalQty || 0) - (detail.availableQty || 0)).toFixed(2)} + {t("Difference")}: {formatNumber(detail.finalQty)} - {formatNumber(detail.availableQty)} = {formatNumber((detail.finalQty || 0) - (detail.availableQty || 0))} ) : ( - {/* 第一行:First Qty(默认选中) */} + {hasFirst && ( = ({ onChange={() => setQtySelection({ ...qtySelection, [detail.id]: "first" })} /> - {t("First")}: {(detail.firstStockTakeQty??0)+(detail.firstBadQty??0) || "0.00"} ({detail.firstBadQty??0}) + {t("First")}: {formatNumber((detail.firstStockTakeQty??0)+(detail.firstBadQty??0))} ({detail.firstBadQty??0}) = {formatNumber(detail.firstStockTakeQty??0)} )} - {/* 第二行:Second Qty(如果存在) */} + {hasSecond && ( = ({ onChange={() => setQtySelection({ ...qtySelection, [detail.id]: "second" })} /> - {t("Second")}: {(detail.secondStockTakeQty??0)+(detail.secondBadQty??0) || "0.00"} ({detail.secondBadQty??0}) + {t("Second")}: {formatNumber((detail.secondStockTakeQty??0)+(detail.secondBadQty??0))} ({detail.secondBadQty??0}) = {formatNumber(detail.secondStockTakeQty??0)} )} - {/* 第三行:Approver Input(仅在 second qty 存在时显示) */} + {hasSecond && ( = ({ type="number" value={approverQty[detail.id] || ""} onChange={(e) => setApproverQty({ ...approverQty, [detail.id]: e.target.value })} - sx={{ width: 100 }} + sx={{ + width: 130, + minWidth: 130, + '& .MuiInputBase-input': { + height: '1.4375em', + + padding: '4px 8px' + } + }} + placeholder={t("Stock Take Qty") } disabled={selection !== "approver"} /> - - + setApproverBadQty({ ...approverBadQty, [detail.id]: e.target.value })} - sx={{ width: 100 }} + sx={{ + width: 130, + minWidth: 130, + '& .MuiInputBase-input': { + height: '1.4375em', + padding: '4px 8px' + } + }} + placeholder={t("Bad Qty")} disabled={selection !== "approver"} /> + + ={(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} + )} - {/* 差异行:显示 selected qty - bookqty = result */} + {(() => { let selectedQty = 0; @@ -396,7 +438,7 @@ const ApproverStockTake: React.FC = ({ } else if (selection === "second") { selectedQty = detail.secondStockTakeQty || 0; } else if (selection === "approver") { - selectedQty = parseFloat(approverQty[detail.id] || "0") || 0; + selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))|| 0; } const bookQty = detail.availableQty || 0; @@ -404,7 +446,7 @@ const ApproverStockTake: React.FC = ({ return ( - {t("Difference")}: {selectedQty.toFixed(2)} - {bookQty.toFixed(2)} = {difference.toFixed(2)} + {t("Difference")}: {t("selected stock take qty")}({formatNumber(selectedQty)}) - {t("book qty")}({formatNumber(bookQty)}) = {formatNumber(difference)} ); })()} @@ -431,6 +473,7 @@ const ApproverStockTake: React.FC = ({ {detail.stockTakeRecordId && detail.stockTakeRecordStatus !== "notMatch" && ( + + )} +
{detail.finalQty == null && ( + + )}
diff --git a/src/components/StockTakeManagement/PickerCardList.tsx b/src/components/StockTakeManagement/PickerCardList.tsx index 9b10871..a6affe8 100644 --- a/src/components/StockTakeManagement/PickerCardList.tsx +++ b/src/components/StockTakeManagement/PickerCardList.tsx @@ -16,6 +16,7 @@ import { } from "@mui/material"; import { useState, useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; +import duration from "dayjs/plugin/duration"; import { getStockTakeRecords, AllPickedStockTakeListReponse, @@ -33,7 +34,9 @@ interface PickerCardListProps { const PickerCardList: React.FC = ({ onCardClick, onReStockTakeClick }) => { const { t } = useTranslation(["inventory", "common"]); + dayjs.extend(duration); + const PER_PAGE = 6; const [loading, setLoading] = useState(false); const [stockTakeSessions, setStockTakeSessions] = useState([]); const [page, setPage] = useState(0); @@ -88,10 +91,70 @@ const PickerCardList: React.FC = ({ onCardClick, onReStockT if (statusLower === "completed") return "success"; if (statusLower === "in_progress" || statusLower === "processing") return "primary"; if (statusLower === "approving") return "info"; + if (statusLower === "stockTaking") return "primary"; if (statusLower === "no_cycle") return "default"; return "warning"; }; - + const TimeDisplay: React.FC<{ startTime: string | null; endTime: string | null }> = ({ startTime, endTime }) => { + const [currentTime, setCurrentTime] = useState(dayjs()); + + useEffect(() => { + if (!endTime && startTime) { + const interval = setInterval(() => { + setCurrentTime(dayjs()); + }, 1000); // 每秒更新一次 + + return () => clearInterval(interval); + } + }, [startTime, endTime]); + + if (endTime && startTime) { + // 当有结束时间时,计算从开始到结束的持续时间 + const start = dayjs(startTime); + const end = dayjs(endTime); + const duration = dayjs.duration(end.diff(start)); + const hours = Math.floor(duration.asHours()); + const minutes = duration.minutes(); + const seconds = duration.seconds(); + + return ( + <> + {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')} + + ); + } else if (startTime) { + // 当没有结束时间时,显示实时计时器 + const start = dayjs(startTime); + const duration = dayjs.duration(currentTime.diff(start)); + const hours = Math.floor(duration.asHours()); + const minutes = duration.minutes(); + const seconds = duration.seconds(); + + return ( + <> + {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')} + + ); + } else { + return <>-; + } + }; + const startTimeDisplay = (startTime: string | null) => { + if (startTime) { + const start = dayjs(startTime); + return start.format("HH:mm"); + } else { + return "-"; + } + }; + const endTimeDisplay = (endTime: string | null) => { + if (endTime) { + const end = dayjs(endTime); + return end.format("HH:mm"); + } else { + return "-"; + } + }; const getCompletionRate = (session: AllPickedStockTakeListReponse): number => { if (session.totalInventoryLotNumber === 0) return 0; return Math.round((session.currentStockTakeItemNumber / session.totalInventoryLotNumber) * 100); @@ -145,13 +208,21 @@ const PickerCardList: React.FC = ({ onCardClick, onReStockT {t("Section")}: {session.stockTakeSession} - + + {t("Last Stock Take Date")}: {lastStockTakeDate || "-"} + + + {t("Stock Taker")}: {session.stockTakerName} + + + {t("start time")}: {startTimeDisplay(session.startTime) || "-"} + {t("end time")}: {endTimeDisplay(session.endTime) || "-"} + - {t("Last Stock Take Date")}: {lastStockTakeDate || "-"} + {t("Control Time")}: - {t("Stock Taker")}: {session.stockTakerName} {t("Total Item Number")}: {session.totalItemNumber} {session.totalInventoryLotNumber > 0 && ( @@ -172,7 +243,8 @@ const PickerCardList: React.FC = ({ onCardClick, onReStockT )} - + + + + diff --git a/src/components/StockTakeManagement/PickerReStockTake.tsx b/src/components/StockTakeManagement/PickerReStockTake.tsx index fee9a6b..e47dbe8 100644 --- a/src/components/StockTakeManagement/PickerReStockTake.tsx +++ b/src/components/StockTakeManagement/PickerReStockTake.tsx @@ -292,14 +292,24 @@ const PickerStockTake: React.FC = ({ } return false; }, []); - + + const uniqueWarehouses = Array.from( + new Set( + inventoryLotDetails + .map(detail => detail.warehouse) + .filter(warehouse => warehouse && warehouse.trim() !== "") + ) + ).join(", "); return ( - {t("Stock Take Section")}: {selectedSession.stockTakeSession} + {t("Stock Take Section")}: {selectedSession.stockTakeSession} + {uniqueWarehouses && ( + <> {t("Warehouse")}: {uniqueWarehouses} + )} {/* {shortcutInput && ( @@ -320,17 +330,15 @@ const PickerStockTake: React.FC = ({ {t("Warehouse Location")} - {t("Item")} - {/*{t("Item Name")}*/} - {/*{t("Lot No")}*/} - {t("Expiry Date")} + {t("Item-lotNo-ExpiryDate")} + {t("Qty")} {t("Bad Qty")} {/*{inventoryLotDetails.some(d => editingRecord?.id === d.id) && (*/} {t("Remark")} {t("UOM")} - {t("Status")} + {t("Record Status")} {t("Action")} @@ -353,29 +361,19 @@ const PickerStockTake: React.FC = ({ return ( - {detail.warehouseCode || "-"} + {detail.warehouseArea || "-"}{detail.warehouseSlot || "-"} {detail.itemCode || "-"}{detail.lotNo || "-"}{detail.itemName ? ` - ${detail.itemName}` : ""} - {/* - - {detail.itemName || "-"} - */} - {/*{detail.lotNo || "-"}*/} - - {detail.expiryDate - ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) - : "-"} + }}> + + {detail.itemCode || "-"} {detail.itemName || "-"} + {detail.lotNo || "-"} + {detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"} + {/**/} + @@ -418,73 +416,69 @@ const PickerStockTake: React.FC = ({ - - {isEditing && isFirstSubmit ? ( - setFirstBadQty(e.target.value)} - sx={{ width: 100 }} - - /> - ) : detail.firstBadQty ? ( - - {t("First")}: {detail.firstBadQty.toFixed(2)} - - ) : null} - - {isEditing && isSecondSubmit ? ( - setSecondBadQty(e.target.value)} - sx={{ width: 100 }} - - /> - ) : detail.secondBadQty ? ( - - {t("Second")}: {detail.secondBadQty.toFixed(2)} - - ) : null} - - {!detail.firstBadQty && !detail.secondBadQty && !isEditing && ( - - - - - )} - - - - - {isEditing && isSecondSubmit ? ( - <> - {t("Remark")} - setRemark(e.target.value)} - sx={{ width: 150 }} - // If you want a single-line input, remove multiline/rows: - // multiline - // rows={2} - /> - - ) : ( - - {detail.remarks || "-"} - - )} - - {detail.uom || "-"} - - {detail.status ? ( - + + {isEditing && isFirstSubmit ? ( + setFirstBadQty(e.target.value)} + sx={{ width: 100 }} + /> + ) : detail.firstBadQty != null && detail.firstBadQty > 0 ? ( + + {t("First")}: {detail.firstBadQty.toFixed(2)} + ) : ( - "-" + + + {t("First")}: 0.00 + )} - + + {isEditing && isSecondSubmit ? ( + setSecondBadQty(e.target.value)} + sx={{ width: 100 }} + /> + ) : detail.secondBadQty != null && detail.secondBadQty > 0 ? ( + + {t("Second")}: {detail.secondBadQty.toFixed(2)} + + ) : null} + + {!detail.firstBadQty && !detail.secondBadQty && !isEditing && ( + + - + + )} + + + + {isEditing && isSecondSubmit ? ( + <> + {t("Remark")} + setRemark(e.target.value)} + sx={{ width: 150 }} + // If you want a single-line input, remove multiline/rows: + // multiline + // rows={2} + /> + + ) : ( + + {detail.remarks || "-"} + + )} + + {detail.uom || "-"} + {detail.stockTakeRecordStatus === "pass" ? ( diff --git a/src/components/StockTakeManagement/PickerStockTake.tsx b/src/components/StockTakeManagement/PickerStockTake.tsx index d480718..e1dfa1b 100644 --- a/src/components/StockTakeManagement/PickerStockTake.tsx +++ b/src/components/StockTakeManagement/PickerStockTake.tsx @@ -18,8 +18,8 @@ import { } from "@mui/material"; import { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { - AllPickedStockTakeListReponse, +import { + AllPickedStockTakeListReponse, getInventoryLotDetailsBySection, InventoryLotDetailResponse, saveStockTakeRecord, @@ -51,6 +51,7 @@ const PickerStockTake: React.FC = ({ // 编辑状态 const [editingRecord, setEditingRecord] = useState(null); + // firstQty / secondQty 保存的是 total = available + bad const [firstQty, setFirstQty] = useState(""); const [secondQty, setSecondQty] = useState(""); const [firstBadQty, setFirstBadQty] = useState(""); @@ -84,8 +85,19 @@ const PickerStockTake: React.FC = ({ const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { setEditingRecord(detail); - setFirstQty(detail.firstStockTakeQty?.toString() || ""); - setSecondQty(detail.secondStockTakeQty?.toString() || ""); + + // 编辑时,输入 total = qty + badQty + const firstTotal = + detail.firstStockTakeQty != null + ? (detail.firstStockTakeQty + (detail.firstBadQty ?? 0)).toString() + : ""; + const secondTotal = + detail.secondStockTakeQty != null + ? (detail.secondStockTakeQty + (detail.secondBadQty ?? 0)).toString() + : ""; + + setFirstQty(firstTotal); + setSecondQty(secondTotal); setFirstBadQty(detail.firstBadQty?.toString() || ""); setSecondBadQty(detail.secondBadQty?.toString() || ""); setRemark(detail.remarks || ""); @@ -100,125 +112,164 @@ const PickerStockTake: React.FC = ({ 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 + const formatNumber = (num: number | null | undefined): string => { + if (num == null || Number.isNaN(num)) return "0.00"; + return num.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + }; + + const handleSaveStockTake = useCallback( + async (detail: InventoryLotDetailResponse) => { + if (!selectedSession || !currentUserId) { + return; + } + + const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; + const isSecondSubmit = + detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; + + // 现在用户输入的是 total 和 bad,需要算 available = total - bad + const totalQtyStr = isFirstSubmit ? firstQty : secondQty; + const badQtyStr = isFirstSubmit ? firstBadQty : secondBadQty; + + if (!totalQtyStr || !badQtyStr) { + onSnackbar( + isFirstSubmit + ? t("Please enter QTY and Bad QTY") + : t("Please enter Second QTY and Bad QTY"), + "error" + ); + return; + } + + const totalQty = parseFloat(totalQtyStr); + const badQty = parseFloat(badQtyStr); + + if (Number.isNaN(totalQty) || Number.isNaN(badQty)) { + onSnackbar(t("Invalid QTY or Bad QTY"), "error"); + return; + } + + const availableQty = totalQty - badQty; + + if (availableQty < 0) { + onSnackbar(t("Available QTY cannot be negative"), "error"); + return; + } + + setSaving(true); + try { + const request: SaveStockTakeRecordRequest = { + stockTakeRecordId: detail.stockTakeRecordId || null, + inventoryLotLineId: detail.id, + qty: availableQty, // 保存 available qty + badQty: badQty, // 保存 bad qty + 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); } - - onSnackbar(errorMessage, "error"); - } finally { - setSaving(false); - } - }, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar]); + }, + [ + 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; - } + 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" + ); - 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 + 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); } - - onSnackbar(errorMessage, "error"); - } finally { - setBatchSaving(false); - } - }, [selectedSession, t, currentUserId, onSnackbar]); + }, + [selectedSession, t, currentUserId, onSnackbar] + ); useEffect(() => { handleBatchSubmitAllRef.current = handleBatchSubmitAll; @@ -227,11 +278,12 @@ const PickerStockTake: React.FC = ({ useEffect(() => { const handleKeyPress = (e: KeyboardEvent) => { const target = e.target as HTMLElement; - if (target && ( - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.isContentEditable - )) { + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { return; } @@ -240,48 +292,48 @@ const PickerStockTake: React.FC = ({ } if (e.key.length === 1) { - setShortcutInput(prev => { + setShortcutInput((prev) => { const newInput = prev + e.key; - - if (newInput === '{2fitestall}') { - console.log('✅ Shortcut {2fitestall} detected!'); + + 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); + console.log("Calling handleBatchSubmitAll..."); + handleBatchSubmitAllRef.current().catch((err) => { + console.error("Error in handleBatchSubmitAll:", err); }); } else { - console.error('handleBatchSubmitAllRef.current is null'); + console.error("handleBatchSubmitAllRef.current is null"); } }, 0); return ""; } - + if (newInput.length > 15) { return ""; } - - if (newInput.length > 0 && !newInput.startsWith('{')) { + + if (newInput.length > 0 && !newInput.startsWith("{")) { return ""; } - - if (newInput.length > 5 && !newInput.startsWith('{2fi')) { + + 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') { + } else if (e.key === "Backspace") { + setShortcutInput((prev) => prev.slice(0, -1)); + } else if (e.key === "Escape") { setShortcutInput(""); } }; - window.addEventListener('keydown', handleKeyPress); + window.addEventListener("keydown", handleKeyPress); return () => { - window.removeEventListener('keydown', handleKeyPress); + window.removeEventListener("keydown", handleKeyPress); }; }, []); @@ -292,19 +344,46 @@ const PickerStockTake: React.FC = ({ return false; }, []); + const uniqueWarehouses = Array.from( + new Set( + inventoryLotDetails + .map((detail) => detail.warehouse) + .filter((warehouse) => warehouse && warehouse.trim() !== "") + ) + ).join(", "); + return ( - {t("Stock Take Section")}: {selectedSession.stockTakeSession} + {uniqueWarehouses && ( + <> {t("Warehouse")}: {uniqueWarehouses} + )} + {/* 如果需要显示快捷键输入,可以把这块注释打开 */} {/* {shortcutInput && ( - + - {t("Shortcut Input")}: {shortcutInput} + {t("Shortcut Input")}:{" "} + + {shortcutInput} + )} @@ -319,17 +398,10 @@ const PickerStockTake: React.FC = ({ {t("Warehouse Location")} - {t("Item")} - {/*{t("Item Name")}*/} - {/*{t("Lot No")}*/} - {t("Expiry Date")} - {t("Qty")} - {t("Bad Qty")} - {/*{inventoryLotDetails.some(d => editingRecord?.id === d.id) && (*/} - {t("Remark")} - + {t("Item-lotNo-ExpiryDate")} + {t("Stock Take Qty(include Bad Qty)= Available Qty")} + {t("Remark")} {t("UOM")} - {t("Status")} {t("Record Status")} {t("Action")} @@ -337,7 +409,7 @@ const PickerStockTake: React.FC = ({ {inventoryLotDetails.length === 0 ? ( - + {t("No data")} @@ -347,152 +419,215 @@ const PickerStockTake: React.FC = ({ 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; + const isFirstSubmit = + !detail.stockTakeRecordId || !detail.firstStockTakeQty; + const isSecondSubmit = + detail.stockTakeRecordId && + detail.firstStockTakeQty && + !detail.secondStockTakeQty; return ( - {detail.warehouseCode || "-"} - {detail.itemCode || "-"}{detail.lotNo || "-"}{detail.itemName ? ` - ${detail.itemName}` : ""} - {/* - - {detail.itemName || "-"} - */} - {/*{detail.lotNo || "-"}*/} - {detail.expiryDate - ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) - : "-"} + {detail.warehouseArea || "-"} + {detail.warehouseSlot || "-"} - - + - {isEditing && isFirstSubmit ? ( - setFirstQty(e.target.value)} - sx={{ width: 100 }} - - /> - ) : detail.firstStockTakeQty ? ( - - {t("First")}: {detail.firstStockTakeQty.toFixed(2)} - - ) : null} - - {isEditing && isSecondSubmit ? ( - setSecondQty(e.target.value)} - sx={{ width: 100 }} - - /> - ) : detail.secondStockTakeQty ? ( - - {t("Second")}: {detail.secondStockTakeQty.toFixed(2)} - - ) : null} - - {!detail.firstStockTakeQty && !detail.secondStockTakeQty && !isEditing && ( - - - - - )} + + {detail.itemCode || "-"} {detail.itemName || "-"} + + {detail.lotNo || "-"} + + {detail.expiryDate + ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) + : "-"} + - - + + {/* Qty + Bad Qty 合并显示/输入 */} + + + {/* First */} {isEditing && isFirstSubmit ? ( - setFirstBadQty(e.target.value)} - sx={{ width: 100 }} - - /> - ) : detail.firstBadQty ? ( + + {t("First")}: + setFirstQty(e.target.value)} + sx={{ + width: 130, + minWidth: 130, + "& .MuiInputBase-input": { + height: "1.4375em", + padding: "4px 8px", + }, + }} + placeholder={t("Stock Take Qty")} + /> + setFirstBadQty(e.target.value)} + sx={{ + width: 130, + minWidth: 130, + "& .MuiInputBase-input": { + height: "1.4375em", + padding: "4px 8px", + }, + }} + placeholder={t("Bad Qty")} + /> + + = + {formatNumber( + parseFloat(firstQty || "0") - + parseFloat(firstBadQty || "0") + )} + + + ) : detail.firstStockTakeQty != null ? ( - {t("First")}: {detail.firstBadQty.toFixed(2)} + {t("First")}:{" "} + {formatNumber( + (detail.firstStockTakeQty ?? 0) + + (detail.firstBadQty ?? 0) + )}{" "} + ( + {formatNumber( + detail.firstBadQty ?? 0 + )} + ) ={" "} + {formatNumber(detail.firstStockTakeQty ?? 0)} ) : null} - + + {/* Second */} {isEditing && isSecondSubmit ? ( - setSecondBadQty(e.target.value)} - sx={{ width: 100 }} - - /> - ) : detail.secondBadQty ? ( + + {t("Second")}: + setSecondQty(e.target.value)} + sx={{ + width: 130, + minWidth: 130, + "& .MuiInputBase-input": { + height: "1.4375em", + padding: "4px 8px", + }, + }} + placeholder={t("Stock Take Qty")} + /> + setSecondBadQty(e.target.value)} + sx={{ + width: 130, + minWidth: 130, + "& .MuiInputBase-input": { + height: "1.4375em", + padding: "4px 8px", + }, + }} + placeholder={t("Bad Qty")} + /> + + = + {formatNumber( + parseFloat(secondQty || "0") - + parseFloat(secondBadQty || "0") + )} + + + ) : detail.secondStockTakeQty != null ? ( - {t("Second")}: {detail.secondBadQty.toFixed(2)} + {t("Second")}:{" "} + {formatNumber( + (detail.secondStockTakeQty ?? 0) + + (detail.secondBadQty ?? 0) + )}{" "} + ( + {formatNumber( + detail.secondBadQty ?? 0 + )} + ) ={" "} + {formatNumber(detail.secondStockTakeQty ?? 0)} ) : null} - - {!detail.firstBadQty && !detail.secondBadQty && !isEditing && ( - - - - - )} - + + {!detail.firstStockTakeQty && + !detail.secondStockTakeQty && + !isEditing && ( + + - + + )} + + {/* Remark */} - {isEditing && isSecondSubmit ? ( - <> - {t("Remark")} - setRemark(e.target.value)} - sx={{ width: 150 }} - // If you want a single-line input, remove multiline/rows: - // multiline - // rows={2} - /> - - ) : ( - - {detail.remarks || "-"} - - )} - - {detail.uom || "-"} - - {detail.status ? ( - + {isEditing && isSecondSubmit ? ( + <> + {t("Remark")} + setRemark(e.target.value)} + sx={{ width: 150 }} + /> + ) : ( - "-" + + {detail.remarks || "-"} + )} + + {detail.uom || "-"} + {detail.stockTakeRecordStatus === "pass" ? ( - + ) : detail.stockTakeRecordStatus === "notMatch" ? ( - + ) : ( - + )} + {isEditing ? ( @@ -504,13 +639,9 @@ const PickerStockTake: React.FC = ({ > {t("Save")} - - ) : ( )} diff --git a/src/components/StockTakeManagement/StockTakeManagementWrapper.tsx b/src/components/StockTakeManagement/StockTakeManagementWrapper.tsx index d306255..c39bc1f 100644 --- a/src/components/StockTakeManagement/StockTakeManagementWrapper.tsx +++ b/src/components/StockTakeManagement/StockTakeManagementWrapper.tsx @@ -1,13 +1,13 @@ import React from "react"; import GeneralLoading from "../General/GeneralLoading"; import StockTakeManagement from "./StockTakeManagement"; - +import StockTakeTabs from "./StockTakeTab"; interface SubComponents { Loading: typeof GeneralLoading; } const StockTakeManagementWrapper: React.FC & SubComponents = async () => { - return ; + return ; }; StockTakeManagementWrapper.Loading = GeneralLoading; diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index bb0f8da..be32870 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -11,11 +11,26 @@ "Back to List": "返回列表", "Record Status": "記錄狀態", "available": "可用", + "Item-lotNo-ExpiryDate": "貨品-批號-到期日", "not available": "不可用", + "Batch Submit All": "批量提交所有", + "Batch Save All": "批量保存所有", "not match": "數值不符", - "Stock Take Qty": "盤點數量(含壞盤點數量)", + "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數", "View ReStockTake": "查看重新盤點", + "Stock Take Qty": "盤點數", + "ReStockTake": "重新盤點", + "Stock Taker": "盤點員", + "Total Item Number": "貨品數量", + "Start Time": "開始時間", + "Difference": "差異", + "stockTaking": "盤點中", + "selected stock take qty": "已選擇盤點數量", + "book qty": "帳面庫存", + "start time": "開始時間", + "end time": "結束時間", + "Control Time": "操作時間", "pass": "通過", "not pass": "不通過", "Available": "可用", @@ -23,7 +38,7 @@ "pending": "待處理", "Last Stock Take Date": "上次盤點日期", "Remark": "備註", - "notMatch": "不匹配", + "notMatch": "數值不符", "Stock take record saved successfully": "盤點記錄保存成功", "View Details": "查看詳細", "Input": "輸入", From 187c88d1e5f77efb271a7da52373f358d11f5dae Mon Sep 17 00:00:00 2001 From: "Tommy\\2Fi-Staff" Date: Fri, 9 Jan 2026 17:48:12 +0800 Subject: [PATCH 02/12] Add "add shop in trucklane"&"add trucklane" --- src/app/api/shop/actions.ts | 81 ++- src/app/api/shop/client.ts | 39 +- src/components/Shop/TruckLane.tsx | 208 +++++++- src/components/Shop/TruckLaneDetail.tsx | 633 +++++++++++++++++++++++- src/i18n/en/common.json | 16 +- src/i18n/zh/common.json | 14 +- 6 files changed, 961 insertions(+), 30 deletions(-) diff --git a/src/app/api/shop/actions.ts b/src/app/api/shop/actions.ts index ab12a8d..a927342 100644 --- a/src/app/api/shop/actions.ts +++ b/src/app/api/shop/actions.ts @@ -46,6 +46,8 @@ export interface Truck{ districtReference: Number; storeId: Number | String; remark?: String | null; + shopName?: String | null; + shopCode?: String | null; } export interface SaveTruckLane { @@ -62,9 +64,13 @@ export interface DeleteTruckLane { id: number; } -export interface UpdateLoadingSequenceRequest { +export interface UpdateTruckShopDetailsRequest { id: number; + shopId?: number | null; + shopName: string | null; + shopCode: string | null; loadingSequence: number; + remark?: string | null; } export interface SaveTruckRequest { @@ -80,6 +86,15 @@ export interface SaveTruckRequest { remark?: string | null; } +export interface CreateTruckWithoutShopRequest { + store_id: string; + truckLanceCode: string; + departureTime: string; + loadingSequence?: number; + districtReference?: number | null; + remark?: string | null; +} + export interface MessageResponse { id: number | null; name: string | null; @@ -137,7 +152,7 @@ export const deleteTruckLaneAction = async (data: DeleteTruckLane) => { }; export const createTruckAction = async (data: SaveTruckRequest) => { - const endpoint = `${BASE_API_URL}/truck/create`; + const endpoint = `${BASE_API_URL}/truck/createTruckInShop`; return serverFetchJson(endpoint, { method: "POST", @@ -175,12 +190,68 @@ export const findAllShopsByTruckLanceCodeAction = cache(async (truckLanceCode: s }); }); -export const updateLoadingSequenceAction = async (data: UpdateLoadingSequenceRequest) => { - const endpoint = `${BASE_API_URL}/truck/updateLoadingSequence`; +export const findAllByTruckLanceCodeAndDeletedFalseAction = cache(async (truckLanceCode: string) => { + const endpoint = `${BASE_API_URL}/truck/findAllByTruckLanceCodeAndDeletedFalse`; + const url = `${endpoint}?truckLanceCode=${encodeURIComponent(truckLanceCode)}`; + + return serverFetchJson(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); + +export const updateTruckShopDetailsAction = async (data: UpdateTruckShopDetailsRequest) => { + const endpoint = `${BASE_API_URL}/truck/updateTruckShopDetails`; + + return serverFetchJson(endpoint, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); +}; + +export const createTruckWithoutShopAction = async (data: CreateTruckWithoutShopRequest) => { + const endpoint = `${BASE_API_URL}/truck/createTruckWithoutShop`; return serverFetchJson(endpoint, { method: "POST", body: JSON.stringify(data), headers: { "Content-Type": "application/json" }, }); -}; \ No newline at end of file +}; + +export const findAllUniqueShopNamesAndCodesFromTrucksAction = cache(async () => { + const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopNamesAndCodesFromTrucks`; + + return serverFetchJson>(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); + +export const findAllUniqueRemarksFromTrucksAction = cache(async () => { + const endpoint = `${BASE_API_URL}/truck/findAllUniqueRemarksFromTrucks`; + + return serverFetchJson(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); + +export const findAllUniqueShopCodesFromTrucksAction = cache(async () => { + const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopCodesFromTrucks`; + + return serverFetchJson(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); + +export const findAllUniqueShopNamesFromTrucksAction = cache(async () => { + const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopNamesFromTrucks`; + + return serverFetchJson(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); \ No newline at end of file diff --git a/src/app/api/shop/client.ts b/src/app/api/shop/client.ts index 4d046d6..5b9fa87 100644 --- a/src/app/api/shop/client.ts +++ b/src/app/api/shop/client.ts @@ -9,11 +9,18 @@ import { findAllUniqueTruckLaneCombinationsAction, findAllShopsByTruckLanceCodeAndRemarkAction, findAllShopsByTruckLanceCodeAction, - updateLoadingSequenceAction, + createTruckWithoutShopAction, + updateTruckShopDetailsAction, + findAllUniqueShopNamesAndCodesFromTrucksAction, + findAllUniqueRemarksFromTrucksAction, + findAllUniqueShopCodesFromTrucksAction, + findAllUniqueShopNamesFromTrucksAction, + findAllByTruckLanceCodeAndDeletedFalseAction, type SaveTruckLane, type DeleteTruckLane, type SaveTruckRequest, - type UpdateLoadingSequenceRequest, + type UpdateTruckShopDetailsRequest, + type CreateTruckWithoutShopRequest, type MessageResponse } from "./actions"; @@ -49,8 +56,32 @@ export const findAllShopsByTruckLanceCodeClient = async (truckLanceCode: string) return await findAllShopsByTruckLanceCodeAction(truckLanceCode); }; -export const updateLoadingSequenceClient = async (data: UpdateLoadingSequenceRequest): Promise => { - return await updateLoadingSequenceAction(data); +export const findAllByTruckLanceCodeAndDeletedFalseClient = async (truckLanceCode: string) => { + return await findAllByTruckLanceCodeAndDeletedFalseAction(truckLanceCode); +}; + +export const updateTruckShopDetailsClient = async (data: UpdateTruckShopDetailsRequest): Promise => { + return await updateTruckShopDetailsAction(data); +}; + +export const createTruckWithoutShopClient = async (data: CreateTruckWithoutShopRequest): Promise => { + return await createTruckWithoutShopAction(data); +}; + +export const findAllUniqueShopNamesAndCodesFromTrucksClient = async () => { + return await findAllUniqueShopNamesAndCodesFromTrucksAction(); +}; + +export const findAllUniqueRemarksFromTrucksClient = async () => { + return await findAllUniqueRemarksFromTrucksAction(); +}; + +export const findAllUniqueShopCodesFromTrucksClient = async () => { + return await findAllUniqueShopCodesFromTrucksAction(); +}; + +export const findAllUniqueShopNamesFromTrucksClient = async () => { + return await findAllUniqueShopNamesFromTrucksAction(); }; export default fetchAllShopsClient; diff --git a/src/components/Shop/TruckLane.tsx b/src/components/Shop/TruckLane.tsx index dd29e6a..3f3d60b 100644 --- a/src/components/Shop/TruckLane.tsx +++ b/src/components/Shop/TruckLane.tsx @@ -16,11 +16,24 @@ import { Button, CircularProgress, Alert, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Grid, + FormControl, + InputLabel, + Select, + MenuItem, + Snackbar, } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import SaveIcon from "@mui/icons-material/Save"; import { useState, useEffect, useMemo } from "react"; import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; -import { findAllUniqueTruckLaneCombinationsClient } from "@/app/api/shop/client"; +import { findAllUniqueTruckLaneCombinationsClient, createTruckWithoutShopClient } from "@/app/api/shop/client"; import type { Truck } from "@/app/api/shop/actions"; import SearchBox, { Criterion } from "../SearchBox"; @@ -50,6 +63,20 @@ const formatDepartureTime = (time: string | number[] | null | undefined): string return timeStr; }; +// Utility function to convert HH:mm format to the format expected by backend +const parseDepartureTimeForBackend = (time: string): string => { + if (!time) return ""; + + const timeStr = String(time).trim(); + // If already in HH:mm format, return as is + if (/^\d{1,2}:\d{2}$/.test(timeStr)) { + return timeStr; + } + + // Try to format it + return formatDepartureTime(timeStr); +}; + type SearchQuery = { truckLanceCode: string; departureTime: string; @@ -67,6 +94,15 @@ const TruckLane: React.FC = () => { const [filters, setFilters] = useState>({}); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [newTruck, setNewTruck] = useState({ + truckLanceCode: "", + departureTime: "", + storeId: "2F", + }); + const [saving, setSaving] = useState(false); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(""); useEffect(() => { const fetchTruckLanes = async () => { @@ -158,6 +194,78 @@ const TruckLane: React.FC = () => { } }; + const handleOpenAddDialog = () => { + setNewTruck({ + truckLanceCode: "", + departureTime: "", + storeId: "2F", + }); + setAddDialogOpen(true); + setError(null); + }; + + const handleCloseAddDialog = () => { + setAddDialogOpen(false); + setNewTruck({ + truckLanceCode: "", + departureTime: "", + storeId: "2F", + }); + }; + + const handleCreateTruck = async () => { + // Validate all required fields + const missingFields: string[] = []; + + if (!newTruck.truckLanceCode.trim()) { + missingFields.push(t("TruckLance Code")); + } + + if (!newTruck.departureTime) { + missingFields.push(t("Departure Time")); + } + + if (missingFields.length > 0) { + const message = `${t("Please fill in the following required fields:")} ${missingFields.join(", ")}`; + setSnackbarMessage(message); + setSnackbarOpen(true); + return; + } + + setSaving(true); + setError(null); + try { + const departureTime = parseDepartureTimeForBackend(newTruck.departureTime); + + await createTruckWithoutShopClient({ + store_id: newTruck.storeId, + truckLanceCode: newTruck.truckLanceCode.trim(), + departureTime: departureTime, + loadingSequence: 0, + districtReference: null, + remark: null, + }); + + // Refresh truck data after create + const data = await findAllUniqueTruckLaneCombinationsClient() as Truck[]; + const uniqueCodes = new Map(); + (data || []).forEach((truck) => { + const code = String(truck.truckLanceCode || "").trim(); + if (code && !uniqueCodes.has(code)) { + uniqueCodes.set(code, truck); + } + }); + setTruckData(Array.from(uniqueCodes.values())); + + handleCloseAddDialog(); + } catch (err: any) { + console.error("Failed to create truck:", err); + setError(err?.message ?? String(err) ?? t("Failed to create truck")); + } finally { + setSaving(false); + } + }; + if (loading) { return ( @@ -198,7 +306,17 @@ const TruckLane: React.FC = () => { - {t("Truck Lane")} + + {t("Truck Lane")} + + @@ -269,6 +387,92 @@ const TruckLane: React.FC = () => { + + {/* Add Truck Dialog */} + + {t("Add New Truck Lane")} + + + + + setNewTruck({ ...newTruck, truckLanceCode: e.target.value })} + disabled={saving} + /> + + + setNewTruck({ ...newTruck, departureTime: e.target.value })} + disabled={saving} + InputLabelProps={{ + shrink: true, + }} + inputProps={{ + step: 300, // 5 minutes + }} + /> + + + + {t("Store ID")} + + + + + + + + + + + + + {/* Snackbar for notifications */} + setSnackbarOpen(false)} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + setSnackbarOpen(false)} + severity="warning" + sx={{ width: '100%' }} + > + {snackbarMessage} + + ); }; diff --git a/src/components/Shop/TruckLaneDetail.tsx b/src/components/Shop/TruckLaneDetail.tsx index 7b37704..85659f7 100644 --- a/src/components/Shop/TruckLaneDetail.tsx +++ b/src/components/Shop/TruckLaneDetail.tsx @@ -19,16 +19,22 @@ import { IconButton, Snackbar, TextField, + Autocomplete, + Dialog, + DialogTitle, + DialogContent, + DialogActions, } from "@mui/material"; import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; import SaveIcon from "@mui/icons-material/Save"; import CancelIcon from "@mui/icons-material/Cancel"; +import AddIcon from "@mui/icons-material/Add"; import { useState, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslation } from "react-i18next"; -import { findAllUniqueTruckLaneCombinationsClient, findAllShopsByTruckLanceCodeClient, deleteTruckLaneClient, updateLoadingSequenceClient } from "@/app/api/shop/client"; -import type { Truck, ShopAndTruck } from "@/app/api/shop/actions"; +import { findAllUniqueTruckLaneCombinationsClient, findAllShopsByTruckLanceCodeClient, deleteTruckLaneClient, updateTruckShopDetailsClient, fetchAllShopsClient, findAllUniqueShopNamesAndCodesFromTrucksClient, findAllUniqueRemarksFromTrucksClient, findAllUniqueShopCodesFromTrucksClient, findAllUniqueShopNamesFromTrucksClient, createTruckClient, findAllByTruckLanceCodeAndDeletedFalseClient } from "@/app/api/shop/client"; +import type { Truck, ShopAndTruck, Shop } from "@/app/api/shop/actions"; // Utility function to format departureTime to HH:mm format const formatDepartureTime = (time: string | number[] | null | undefined): string => { @@ -72,12 +78,78 @@ const TruckLaneDetail: React.FC = () => { const [shopsLoading, setShopsLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [allShops, setAllShops] = useState([]); + const [uniqueRemarks, setUniqueRemarks] = useState([]); + const [uniqueShopCodes, setUniqueShopCodes] = useState([]); + const [uniqueShopNames, setUniqueShopNames] = useState([]); + const [addShopDialogOpen, setAddShopDialogOpen] = useState(false); + const [newShop, setNewShop] = useState({ + shopName: "", + shopCode: "", + loadingSequence: 0, + remark: "", + }); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: "success" | "error" }>({ open: false, message: "", severity: "success", }); + useEffect(() => { + // Fetch unique shop names and codes from truck table + const fetchShopNamesFromTrucks = async () => { + try { + const shopData = await findAllUniqueShopNamesAndCodesFromTrucksClient() as Array<{ name: string; code: string }>; + + // Convert to Shop format (id will be 0 since we don't have shop IDs from truck table) + const shopList: Shop[] = shopData.map((shop) => ({ + id: 0, // No shop ID available from truck table + name: shop.name || "", + code: shop.code || "", + addr3: "", + })); + setAllShops(shopList); + } catch (err: any) { + console.error("Failed to load shop names from trucks:", err); + } + }; + + // Fetch unique remarks from truck table + const fetchRemarksFromTrucks = async () => { + try { + const remarks = await findAllUniqueRemarksFromTrucksClient() as string[]; + setUniqueRemarks(remarks || []); + } catch (err: any) { + console.error("Failed to load remarks from trucks:", err); + } + }; + + // Fetch unique shop codes from truck table + const fetchShopCodesFromTrucks = async () => { + try { + const codes = await findAllUniqueShopCodesFromTrucksClient() as string[]; + setUniqueShopCodes(codes || []); + } catch (err: any) { + console.error("Failed to load shop codes from trucks:", err); + } + }; + + // Fetch unique shop names from truck table + const fetchShopNamesFromTrucksOnly = async () => { + try { + const names = await findAllUniqueShopNamesFromTrucksClient() as string[]; + setUniqueShopNames(names || []); + } catch (err: any) { + console.error("Failed to load shop names from trucks:", err); + } + }; + + fetchShopNamesFromTrucks(); + fetchRemarksFromTrucks(); + fetchShopCodesFromTrucks(); + fetchShopNamesFromTrucksOnly(); + }, []); + useEffect(() => { // Wait a bit to ensure searchParams are fully available if (!truckLanceCodeParam) { @@ -183,28 +255,55 @@ const TruckLaneDetail: React.FC = () => { setSaving(true); setError(null); try { - // Get LoadingSequence from edited data - handle both PascalCase and camelCase + // Get values from edited data const editedShop = editedShopsData[index]; const loadingSeq = (editedShop as any)?.LoadingSequence ?? (editedShop as any)?.loadingSequence; const loadingSequenceValue = (loadingSeq !== null && loadingSeq !== undefined) ? Number(loadingSeq) : 0; + + // Get shopName and shopCode from edited data + const shopNameValue = editedShop.name ? String(editedShop.name).trim() : null; + const shopCodeValue = editedShop.code ? String(editedShop.code).trim() : null; + const remarkValue = editedShop.remark ? String(editedShop.remark).trim() : null; + + // Get shopId from editedShop.id (which was set when shopName or shopCode was selected) + // If not found, try to find it from shop table by shopCode + let shopIdValue: number | null = null; + if (editedShop.id && editedShop.id > 0) { + shopIdValue = editedShop.id; + } else if (shopCodeValue) { + // If shopId is 0 (from truck table), try to find it from shop table + try { + const allShopsFromShopTable = await fetchAllShopsClient() as ShopAndTruck[]; + const foundShop = allShopsFromShopTable.find(s => String(s.code).trim() === shopCodeValue); + if (foundShop) { + shopIdValue = foundShop.id; + } + } catch (err) { + console.error("Failed to lookup shopId:", err); + } + } if (!shop.truckId) { setSnackbar({ open: true, - message: "Truck ID is required", + message: t("Truck ID is required"), severity: "error", }); return; } - await updateLoadingSequenceClient({ + await updateTruckShopDetailsClient({ id: shop.truckId, + shopId: shopIdValue, + shopName: shopNameValue, + shopCode: shopCodeValue, loadingSequence: loadingSequenceValue, + remark: remarkValue || null, }); setSnackbar({ open: true, - message: t("Loading sequence updated successfully"), + message: t("Truck shop details updated successfully"), severity: "success", }); @@ -214,10 +313,10 @@ const TruckLaneDetail: React.FC = () => { } setEditingRowIndex(null); } catch (err: any) { - console.error("Failed to save loading sequence:", err); + console.error("Failed to save truck shop details:", err); setSnackbar({ open: true, - message: err?.message ?? String(err) ?? t("Failed to save loading sequence"), + message: err?.message ?? String(err) ?? t("Failed to save truck shop details"), severity: "error", }); } finally { @@ -235,6 +334,53 @@ const TruckLaneDetail: React.FC = () => { setEditedShopsData(updated); }; + const handleShopNameChange = (index: number, shop: Shop | null) => { + const updated = [...editedShopsData]; + if (shop) { + updated[index] = { + ...updated[index], + name: shop.name, + code: shop.code, + id: shop.id, // Store shopId for later use + }; + } else { + updated[index] = { + ...updated[index], + name: "", + code: "", + }; + } + setEditedShopsData(updated); + }; + + const handleShopCodeChange = (index: number, shop: Shop | null) => { + const updated = [...editedShopsData]; + if (shop) { + updated[index] = { + ...updated[index], + name: shop.name, + code: shop.code, + id: shop.id, // Store shopId for later use + }; + } else { + updated[index] = { + ...updated[index], + name: "", + code: "", + }; + } + setEditedShopsData(updated); + }; + + const handleRemarkChange = (index: number, value: string) => { + const updated = [...editedShopsData]; + updated[index] = { + ...updated[index], + remark: value, + }; + setEditedShopsData(updated); + }; + const handleDelete = async (truckIdToDelete: number) => { if (!window.confirm(t("Are you sure you want to delete this truck lane?"))) { return; @@ -266,6 +412,208 @@ const TruckLaneDetail: React.FC = () => { router.push("/settings/shop"); }; + const handleOpenAddShopDialog = () => { + setNewShop({ + shopName: "", + shopCode: "", + loadingSequence: 0, + remark: "", + }); + setAddShopDialogOpen(true); + setError(null); + }; + + const handleCloseAddShopDialog = () => { + setAddShopDialogOpen(false); + setNewShop({ + shopName: "", + shopCode: "", + loadingSequence: 0, + remark: "", + }); + }; + + const handleNewShopNameChange = (newValue: string | null) => { + if (newValue && typeof newValue === 'string') { + // When a name is selected, try to find matching shop code + const matchingShop = allShops.find(s => String(s.name) === newValue); + if (matchingShop) { + setNewShop({ + ...newShop, + shopName: newValue, + shopCode: String(matchingShop.code || ""), + }); + } else { + setNewShop({ + ...newShop, + shopName: newValue, + }); + } + } else if (newValue === null) { + setNewShop({ + ...newShop, + shopName: "", + }); + } + }; + + const handleNewShopCodeChange = (newValue: string | null) => { + if (newValue && typeof newValue === 'string') { + // When a code is selected, try to find matching shop name + const matchingShop = allShops.find(s => String(s.code) === newValue); + if (matchingShop) { + setNewShop({ + ...newShop, + shopCode: newValue, + shopName: String(matchingShop.name || ""), + }); + } else { + setNewShop({ + ...newShop, + shopCode: newValue, + }); + } + } else if (newValue === null) { + setNewShop({ + ...newShop, + shopCode: "", + }); + } + }; + + const handleCreateShop = async () => { + // Validate required fields + const missingFields: string[] = []; + + if (!newShop.shopName.trim()) { + missingFields.push(t("Shop Name")); + } + + if (!newShop.shopCode.trim()) { + missingFields.push(t("Shop Code")); + } + + if (missingFields.length > 0) { + setSnackbar({ + open: true, + message: `${t("Please fill in the following required fields:")} ${missingFields.join(", ")}`, + severity: "error", + }); + return; + } + + if (!truckData || !truckLanceCode) { + setSnackbar({ + open: true, + message: t("Truck lane information is required"), + severity: "error", + }); + return; + } + + setSaving(true); + setError(null); + try { + // Get storeId from truckData + const storeId = truckData.storeId; + const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : "2F"; + const displayStoreId = storeIdStr === "2" || storeIdStr === "2F" ? "2F" + : storeIdStr === "4" || storeIdStr === "4F" ? "4F" + : storeIdStr; + + // Get departureTime from truckData + let departureTimeStr = ""; + if (truckData.departureTime) { + if (Array.isArray(truckData.departureTime)) { + const [hours, minutes] = truckData.departureTime; + departureTimeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; + } else { + departureTimeStr = String(truckData.departureTime); + } + } + + // Look up shopId from shop table by shopCode + let shopIdValue: number | null = null; + try { + const allShopsFromShopTable = await fetchAllShopsClient() as ShopAndTruck[]; + const foundShop = allShopsFromShopTable.find(s => String(s.code).trim() === newShop.shopCode.trim()); + if (foundShop) { + shopIdValue = foundShop.id; + } + } catch (err) { + console.error("Failed to lookup shopId:", err); + } + + // Get remark - only if storeId is "4F" + const remarkValue = displayStoreId === "4F" ? (newShop.remark?.trim() || null) : null; + + // Check if there's an "Unassign" row for this truck lane that should be replaced + let unassignTruck: Truck | null = null; + try { + const allTrucks = await findAllByTruckLanceCodeAndDeletedFalseClient(String(truckData.truckLanceCode || "")) as Truck[]; + unassignTruck = allTrucks.find(t => + String(t.shopName || "").trim() === "Unassign" && + String(t.shopCode || "").trim() === "Unassign" + ) || null; + } catch (err) { + console.error("Failed to check for Unassign truck:", err); + } + + if (unassignTruck && unassignTruck.id) { + // Update the existing "Unassign" row instead of creating a new one + await updateTruckShopDetailsClient({ + id: unassignTruck.id, + shopId: shopIdValue || null, + shopName: newShop.shopName.trim(), + shopCode: newShop.shopCode.trim(), + loadingSequence: newShop.loadingSequence, + remark: remarkValue, + }); + + setSnackbar({ + open: true, + message: t("Shop added to truck lane successfully"), + severity: "success", + }); + } else { + // No "Unassign" row found, create a new one + await createTruckClient({ + store_id: displayStoreId, + truckLanceCode: String(truckData.truckLanceCode || ""), + departureTime: departureTimeStr, + shopId: shopIdValue || 0, + shopName: newShop.shopName.trim(), + shopCode: newShop.shopCode.trim(), + loadingSequence: newShop.loadingSequence, + remark: remarkValue, + districtReference: null, + }); + + setSnackbar({ + open: true, + message: t("Shop added to truck lane successfully"), + severity: "success", + }); + } + + // Refresh the shops list + if (truckLanceCode) { + await fetchShopsByTruckLane(truckLanceCode); + } + + handleCloseAddShopDialog(); + } catch (err: any) { + console.error("Failed to create shop in truck lane:", err); + setSnackbar({ + open: true, + message: err?.message ?? String(err) ?? t("Failed to create shop in truck lane"), + severity: "error", + }); + } finally { + setSaving(false); + } + }; + if (loading) { return ( @@ -361,9 +709,19 @@ const TruckLaneDetail: React.FC = () => { - - {t("Shops Using This Truck Lane")} - + + + {t("Shops Using This Truck Lane")} + + + {shopsLoading ? ( @@ -394,13 +752,143 @@ const TruckLaneDetail: React.FC = () => { shopsData.map((shop, index) => ( - {String(shop.name || "-")} + {editingRowIndex === index ? ( + { + if (newValue && typeof newValue === 'string') { + // When a name is selected, try to find matching shop code + const matchingShop = allShops.find(s => String(s.name) === newValue); + if (matchingShop) { + handleShopNameChange(index, matchingShop); + } else { + // If no matching shop found, just update the name + const updated = [...editedShopsData]; + updated[index] = { + ...updated[index], + name: newValue, + }; + setEditedShopsData(updated); + } + } else if (newValue === null) { + handleShopNameChange(index, null); + } + }} + onInputChange={(event, newInputValue, reason) => { + if (reason === 'input') { + // Allow free text input + const updated = [...editedShopsData]; + updated[index] = { + ...updated[index], + name: newInputValue, + }; + setEditedShopsData(updated); + } + }} + renderInput={(params) => ( + + )} + /> + ) : ( + String(shop.name || "-") + )} - {String(shop.code || "-")} + {editingRowIndex === index ? ( + { + if (newValue && typeof newValue === 'string') { + // When a code is selected, try to find matching shop name + const matchingShop = allShops.find(s => String(s.code) === newValue); + if (matchingShop) { + handleShopCodeChange(index, matchingShop); + } else { + // If no matching shop found, just update the code + const updated = [...editedShopsData]; + updated[index] = { + ...updated[index], + code: newValue, + }; + setEditedShopsData(updated); + } + } else if (newValue === null) { + handleShopCodeChange(index, null); + } + }} + onInputChange={(event, newInputValue, reason) => { + if (reason === 'input') { + // Allow free text input + const updated = [...editedShopsData]; + updated[index] = { + ...updated[index], + code: newInputValue, + }; + setEditedShopsData(updated); + } + }} + renderInput={(params) => ( + + )} + /> + ) : ( + String(shop.code || "-") + )} - {String(shop.remark || "-")} + {editingRowIndex === index ? ( + (() => { + const storeId = truckData.storeId; + const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : ""; + const isEditable = storeIdStr === "4F" || storeIdStr === "4"; + return ( + { + if (isEditable) { + const remarkValue = typeof newValue === 'string' ? newValue : (newValue || ""); + handleRemarkChange(index, remarkValue); + } + }} + onInputChange={(event, newInputValue, reason) => { + if (isEditable && reason === 'input') { + handleRemarkChange(index, newInputValue); + } + }} + disabled={saving || !isEditable} + renderInput={(params) => ( + + )} + /> + ); + })() + ) : ( + String(shop.remark || "-") + )} {editingRowIndex === index ? ( @@ -454,7 +942,7 @@ const TruckLaneDetail: React.FC = () => { size="small" color="primary" onClick={() => handleEdit(index)} - title={t("Edit loading sequence")} + title={t("Edit shop details")} > @@ -482,6 +970,121 @@ const TruckLaneDetail: React.FC = () => { + {/* Add Shop Dialog */} + + {t("Add Shop to Truck Lane")} + + + + + { + handleNewShopNameChange(newValue); + }} + onInputChange={(event, newInputValue, reason) => { + if (reason === 'input') { + setNewShop({ ...newShop, shopName: newInputValue }); + } + }} + renderInput={(params) => ( + + )} + /> + + + { + handleNewShopCodeChange(newValue); + }} + onInputChange={(event, newInputValue, reason) => { + if (reason === 'input') { + setNewShop({ ...newShop, shopCode: newInputValue }); + } + }} + renderInput={(params) => ( + + )} + /> + + + setNewShop({ ...newShop, loadingSequence: parseInt(e.target.value) || 0 })} + disabled={saving} + /> + + {(() => { + const storeId = truckData?.storeId; + const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : ""; + const isEditable = storeIdStr === "4F" || storeIdStr === "4"; + return isEditable ? ( + + { + setNewShop({ ...newShop, remark: typeof newValue === 'string' ? newValue : (newValue || "") }); + }} + onInputChange={(event, newInputValue, reason) => { + if (reason === 'input') { + setNewShop({ ...newShop, remark: newInputValue }); + } + }} + renderInput={(params) => ( + + )} + /> + + ) : null; + })()} + + + + + + + + + Date: Fri, 9 Jan 2026 19:16:42 +0800 Subject: [PATCH 03/12] Fix tab issue --- src/app/utils/formatUtil.ts | 42 ++++++ src/components/Shop/Shop.tsx | 176 ++++++++++++++---------- src/components/Shop/ShopDetail.tsx | 77 +---------- src/components/Shop/TruckLane.tsx | 175 +++++++++-------------- src/components/Shop/TruckLaneDetail.tsx | 132 +++++++----------- 5 files changed, 270 insertions(+), 332 deletions(-) diff --git a/src/app/utils/formatUtil.ts b/src/app/utils/formatUtil.ts index 2b3708c..60ecdc6 100644 --- a/src/app/utils/formatUtil.ts +++ b/src/app/utils/formatUtil.ts @@ -151,3 +151,45 @@ export const calculateWeight = (qty: number, uom: Uom) => { export const returnWeightUnit = (uom: Uom) => { return uom.unit4 || uom.unit3 || uom.unit2 || uom.unit1; }; + +/** + * Formats departure time to HH:mm format + * Handles array format [hours, minutes] from API and string formats + */ +export const formatDepartureTime = (time: string | number[] | String | Number | null | undefined): string => { + if (!time) return "-"; + + // Handle array format [hours, minutes] from API + if (Array.isArray(time) && time.length >= 2) { + const hours = time[0]; + const minutes = time[1]; + if (typeof hours === 'number' && typeof minutes === 'number' && + hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { + return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; + } + } + + const timeStr = String(time).trim(); + if (!timeStr || timeStr === "-") return "-"; + + // If already in HH:mm format, return as is + if (/^\d{1,2}:\d{2}$/.test(timeStr)) { + const [hours, minutes] = timeStr.split(":"); + return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`; + } + + return timeStr; +}; + +/** + * Normalizes store ID to display format (2F or 4F) + */ +export const normalizeStoreId = (storeId: string | number | String | Number | null | undefined): string => { + if (!storeId) return "-"; + const storeIdStr = typeof storeId === 'string' || storeId instanceof String + ? String(storeId) + : String(storeId); + if (storeIdStr === "2" || storeIdStr === "2F") return "2F"; + if (storeIdStr === "4" || storeIdStr === "4F") return "4F"; + return storeIdStr; +}; \ No newline at end of file diff --git a/src/components/Shop/Shop.tsx b/src/components/Shop/Shop.tsx index 7c827ab..07b07cb 100644 --- a/src/components/Shop/Shop.tsx +++ b/src/components/Shop/Shop.tsx @@ -18,7 +18,7 @@ import { InputLabel, } from "@mui/material"; import { useState, useMemo, useCallback, useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useTranslation } from "react-i18next"; import SearchBox, { Criterion } from "../SearchBox"; import SearchResults, { Column } from "../SearchResults"; @@ -43,6 +43,7 @@ type SearchParamNames = keyof SearchQuery; const Shop: React.FC = () => { const { t } = useTranslation("common"); const router = useRouter(); + const searchParams = useSearchParams(); const [activeTab, setActiveTab] = useState(0); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); @@ -235,26 +236,33 @@ const Shop: React.FC = () => { name: "id", label: t("id"), type: "integer", + sx: { width: "100px", minWidth: "100px", maxWidth: "100px" }, renderCell: (item) => String(item.id ?? ""), }, { name: "code", label: t("Code"), + sx: { width: "150px", minWidth: "150px", maxWidth: "150px" }, renderCell: (item) => String(item.code ?? ""), }, { name: "name", label: t("Name"), + sx: { width: "200px", minWidth: "200px", maxWidth: "200px" }, renderCell: (item) => String(item.name ?? ""), }, { name: "addr3", label: t("Addr3"), + sx: { width: "200px", minWidth: "200px", maxWidth: "200px" }, renderCell: (item) => String((item as any).addr3 ?? ""), }, { name: "truckLanceStatus", label: t("TruckLance Status"), + align: "center", + headerAlign: "center", + sx: { width: "150px", minWidth: "150px", maxWidth: "150px" }, renderCell: (item) => { const status = item.truckLanceStatus; if (status === "complete") { @@ -269,7 +277,9 @@ const Shop: React.FC = () => { { name: "actions", label: t("Actions"), + align: "right", headerAlign: "right", + sx: { width: "150px", minWidth: "150px", maxWidth: "150px" }, renderCell: (item) => ( + ); } @@ -493,7 +439,7 @@ const ShopDetail: React.FC = () => { {t("Shop not found")} - + ); } @@ -504,7 +450,7 @@ const ShopDetail: React.FC = () => { {t("Shop Information")} - + @@ -682,22 +628,13 @@ const ShopDetail: React.FC = () => { ) : ( - (() => { - const storeId = truck.storeId; - if (storeId === null || storeId === undefined) return "-"; - const storeIdStr = typeof storeId === 'string' ? storeId : String(storeId); - // Convert numeric values to display format - if (storeIdStr === "2" || storeIdStr === "2F") return "2F"; - if (storeIdStr === "4" || storeIdStr === "4F") return "4F"; - return storeIdStr; - })() + normalizeStoreId(truck.storeId) )} {isEditing ? ( (() => { - const storeId = displayTruck?.storeId; - const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId) === "2" ? "2F" : String(storeId) === "4" ? "4F" : String(storeId)) : "2F"; + const storeIdStr = normalizeStoreId(displayTruck?.storeId) || "2F"; const isEditable = storeIdStr === "4F"; return ( diff --git a/src/components/Shop/TruckLane.tsx b/src/components/Shop/TruckLane.tsx index 3f3d60b..c1ae0ce 100644 --- a/src/components/Shop/TruckLane.tsx +++ b/src/components/Shop/TruckLane.tsx @@ -36,46 +36,7 @@ import { useTranslation } from "react-i18next"; import { findAllUniqueTruckLaneCombinationsClient, createTruckWithoutShopClient } from "@/app/api/shop/client"; import type { Truck } from "@/app/api/shop/actions"; import SearchBox, { Criterion } from "../SearchBox"; - -// Utility function to format departureTime to HH:mm format -const formatDepartureTime = (time: string | number[] | null | undefined): string => { - if (!time) return "-"; - - // Handle array format [hours, minutes] from API - if (Array.isArray(time) && time.length >= 2) { - const hours = time[0]; - const minutes = time[1]; - if (typeof hours === 'number' && typeof minutes === 'number' && - hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { - return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; - } - } - - const timeStr = String(time).trim(); - if (!timeStr || timeStr === "-") return "-"; - - // If already in HH:mm format, return as is - if (/^\d{1,2}:\d{2}$/.test(timeStr)) { - const [hours, minutes] = timeStr.split(":"); - return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`; - } - - return timeStr; -}; - -// Utility function to convert HH:mm format to the format expected by backend -const parseDepartureTimeForBackend = (time: string): string => { - if (!time) return ""; - - const timeStr = String(time).trim(); - // If already in HH:mm format, return as is - if (/^\d{1,2}:\d{2}$/.test(timeStr)) { - return timeStr; - } - - // Try to format it - return formatDepartureTime(timeStr); -}; +import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil"; type SearchQuery = { truckLanceCode: string; @@ -128,39 +89,34 @@ const TruckLane: React.FC = () => { }; fetchTruckLanes(); - }, []); + }, [t]); // Client-side filtered rows (contains-matching) const filteredRows = useMemo(() => { - const fKeys = Object.keys(filters || {}).filter((k) => String((filters as any)[k]).trim() !== ""); - const normalized = (truckData || []).filter((r) => { - // Apply contains matching for each active filter - for (const k of fKeys) { - const v = String((filters as any)[k] ?? "").trim(); + const fKeys = Object.keys(filters).filter((k) => String(filters[k] ?? "").trim() !== ""); + if (fKeys.length === 0) return truckData; + + return truckData.filter((truck) => { + for (const key of fKeys) { + const filterValue = String(filters[key] ?? "").trim().toLowerCase(); - if (k === "truckLanceCode") { - const rv = String((r as any).truckLanceCode ?? "").trim(); - if (!rv.toLowerCase().includes(v.toLowerCase())) return false; - } else if (k === "departureTime") { + if (key === "truckLanceCode") { + const truckCode = String(truck.truckLanceCode ?? "").trim().toLowerCase(); + if (!truckCode.includes(filterValue)) return false; + } else if (key === "departureTime") { const formattedTime = formatDepartureTime( - Array.isArray(r.departureTime) - ? r.departureTime - : (r.departureTime ? String(r.departureTime) : null) + Array.isArray(truck.departureTime) + ? truck.departureTime + : (truck.departureTime ? String(truck.departureTime) : null) ); - if (!formattedTime.toLowerCase().includes(v.toLowerCase())) return false; - } else if (k === "storeId") { - const rv = String((r as any).storeId ?? "").trim(); - const storeIdStr = typeof rv === 'string' ? rv : String(rv); - // Convert numeric values to display format for comparison - let displayStoreId = storeIdStr; - if (storeIdStr === "2" || storeIdStr === "2F") displayStoreId = "2F"; - if (storeIdStr === "4" || storeIdStr === "4F") displayStoreId = "4F"; - if (!displayStoreId.toLowerCase().includes(v.toLowerCase())) return false; + if (!formattedTime.toLowerCase().includes(filterValue)) return false; + } else if (key === "storeId") { + const displayStoreId = normalizeStoreId(truck.storeId); + if (!displayStoreId.toLowerCase().includes(filterValue)) return false; } } return true; }); - return normalized; }, [truckData, filters]); // Paginated rows @@ -235,12 +191,10 @@ const TruckLane: React.FC = () => { setSaving(true); setError(null); try { - const departureTime = parseDepartureTimeForBackend(newTruck.departureTime); - await createTruckWithoutShopClient({ store_id: newTruck.storeId, truckLanceCode: newTruck.truckLanceCode.trim(), - departureTime: departureTime, + departureTime: newTruck.departureTime.trim(), loadingSequence: 0, districtReference: null, remark: null, @@ -249,8 +203,8 @@ const TruckLane: React.FC = () => { // Refresh truck data after create const data = await findAllUniqueTruckLaneCombinationsClient() as Truck[]; const uniqueCodes = new Map(); - (data || []).forEach((truck) => { - const code = String(truck.truckLanceCode || "").trim(); + data.forEach((truck) => { + const code = String(truck.truckLanceCode ?? "").trim(); if (code && !uniqueCodes.has(code)) { uniqueCodes.set(code, truck); } @@ -258,9 +212,10 @@ const TruckLane: React.FC = () => { setTruckData(Array.from(uniqueCodes.values())); handleCloseAddDialog(); - } catch (err: any) { + } catch (err: unknown) { console.error("Failed to create truck:", err); - setError(err?.message ?? String(err) ?? t("Failed to create truck")); + const errorMessage = err instanceof Error ? err.message : String(err); + setError(errorMessage || t("Failed to create truck")); } finally { setSaving(false); } @@ -322,10 +277,18 @@ const TruckLane: React.FC = () => {
- {t("TruckLance Code")} - {t("Departure Time")} - {t("Store ID")} - {t("Actions")} + + {t("TruckLance Code")} + + + {t("Departure Time")} + + + {t("Store ID")} + + + {t("Actions")} + @@ -338,40 +301,36 @@ const TruckLane: React.FC = () => { ) : ( - paginatedRows.map((truck, index) => { - const storeId = truck.storeId; - const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : "-"; - const displayStoreId = storeIdStr === "2" || storeIdStr === "2F" ? "2F" - : storeIdStr === "4" || storeIdStr === "4F" ? "4F" - : storeIdStr; - - return ( - - - {String(truck.truckLanceCode || "-")} - - - {formatDepartureTime( - Array.isArray(truck.departureTime) - ? truck.departureTime - : (truck.departureTime ? String(truck.departureTime) : null) - )} - - - {displayStoreId} - - - - - - ); - }) + paginatedRows.map((truck) => ( + + + {String(truck.truckLanceCode ?? "-")} + + + {formatDepartureTime( + Array.isArray(truck.departureTime) + ? truck.departureTime + : (truck.departureTime ? String(truck.departureTime) : null) + )} + + + {normalizeStoreId( + truck.storeId ? (typeof truck.storeId === 'string' || truck.storeId instanceof String + ? String(truck.storeId) + : String(truck.storeId)) : null + )} + + + + + + )) )}
diff --git a/src/components/Shop/TruckLaneDetail.tsx b/src/components/Shop/TruckLaneDetail.tsx index 85659f7..80398d7 100644 --- a/src/components/Shop/TruckLaneDetail.tsx +++ b/src/components/Shop/TruckLaneDetail.tsx @@ -33,34 +33,21 @@ import AddIcon from "@mui/icons-material/Add"; import { useState, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslation } from "react-i18next"; -import { findAllUniqueTruckLaneCombinationsClient, findAllShopsByTruckLanceCodeClient, deleteTruckLaneClient, updateTruckShopDetailsClient, fetchAllShopsClient, findAllUniqueShopNamesAndCodesFromTrucksClient, findAllUniqueRemarksFromTrucksClient, findAllUniqueShopCodesFromTrucksClient, findAllUniqueShopNamesFromTrucksClient, createTruckClient, findAllByTruckLanceCodeAndDeletedFalseClient } from "@/app/api/shop/client"; +import { + findAllUniqueTruckLaneCombinationsClient, + findAllShopsByTruckLanceCodeClient, + deleteTruckLaneClient, + updateTruckShopDetailsClient, + fetchAllShopsClient, + findAllUniqueShopNamesAndCodesFromTrucksClient, + findAllUniqueRemarksFromTrucksClient, + findAllUniqueShopCodesFromTrucksClient, + findAllUniqueShopNamesFromTrucksClient, + createTruckClient, + findAllByTruckLanceCodeAndDeletedFalseClient, +} from "@/app/api/shop/client"; import type { Truck, ShopAndTruck, Shop } from "@/app/api/shop/actions"; - -// Utility function to format departureTime to HH:mm format -const formatDepartureTime = (time: string | number[] | null | undefined): string => { - if (!time) return "-"; - - // Handle array format [hours, minutes] from API - if (Array.isArray(time) && time.length >= 2) { - const hours = time[0]; - const minutes = time[1]; - if (typeof hours === 'number' && typeof minutes === 'number' && - hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { - return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; - } - } - - const timeStr = String(time).trim(); - if (!timeStr || timeStr === "-") return "-"; - - // If already in HH:mm format, return as is - if (/^\d{1,2}:\d{2}$/.test(timeStr)) { - const [hours, minutes] = timeStr.split(":"); - return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`; - } - - return timeStr; -}; +import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil"; const TruckLaneDetail: React.FC = () => { const { t } = useTranslation("common"); @@ -95,59 +82,35 @@ const TruckLaneDetail: React.FC = () => { severity: "success", }); + // Fetch autocomplete data on mount useEffect(() => { - // Fetch unique shop names and codes from truck table - const fetchShopNamesFromTrucks = async () => { + const fetchAutocompleteData = async () => { try { - const shopData = await findAllUniqueShopNamesAndCodesFromTrucksClient() as Array<{ name: string; code: string }>; - + const [shopData, remarks, codes, names] = await Promise.all([ + findAllUniqueShopNamesAndCodesFromTrucksClient() as Promise>, + findAllUniqueRemarksFromTrucksClient() as Promise, + findAllUniqueShopCodesFromTrucksClient() as Promise, + findAllUniqueShopNamesFromTrucksClient() as Promise, + ]); + // Convert to Shop format (id will be 0 since we don't have shop IDs from truck table) const shopList: Shop[] = shopData.map((shop) => ({ - id: 0, // No shop ID available from truck table + id: 0, name: shop.name || "", code: shop.code || "", addr3: "", })); + setAllShops(shopList); - } catch (err: any) { - console.error("Failed to load shop names from trucks:", err); - } - }; - - // Fetch unique remarks from truck table - const fetchRemarksFromTrucks = async () => { - try { - const remarks = await findAllUniqueRemarksFromTrucksClient() as string[]; setUniqueRemarks(remarks || []); - } catch (err: any) { - console.error("Failed to load remarks from trucks:", err); - } - }; - - // Fetch unique shop codes from truck table - const fetchShopCodesFromTrucks = async () => { - try { - const codes = await findAllUniqueShopCodesFromTrucksClient() as string[]; setUniqueShopCodes(codes || []); - } catch (err: any) { - console.error("Failed to load shop codes from trucks:", err); - } - }; - - // Fetch unique shop names from truck table - const fetchShopNamesFromTrucksOnly = async () => { - try { - const names = await findAllUniqueShopNamesFromTrucksClient() as string[]; setUniqueShopNames(names || []); - } catch (err: any) { - console.error("Failed to load shop names from trucks:", err); + } catch (err) { + console.error("Failed to load autocomplete data:", err); } }; - fetchShopNamesFromTrucks(); - fetchRemarksFromTrucks(); - fetchShopCodesFromTrucks(); - fetchShopNamesFromTrucksOnly(); + fetchAutocompleteData(); }, []); useEffect(() => { @@ -409,7 +372,7 @@ const TruckLaneDetail: React.FC = () => { }; const handleBack = () => { - router.push("/settings/shop"); + router.push("/settings/shop?tab=1"); }; const handleOpenAddShopDialog = () => { @@ -515,11 +478,10 @@ const TruckLaneDetail: React.FC = () => { setError(null); try { // Get storeId from truckData - const storeId = truckData.storeId; - const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : "2F"; - const displayStoreId = storeIdStr === "2" || storeIdStr === "2F" ? "2F" - : storeIdStr === "4" || storeIdStr === "4F" ? "4F" - : storeIdStr; + const storeIdValue = truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String + ? String(truckData.storeId) + : String(truckData.storeId)) : "2F"; + const displayStoreId = normalizeStoreId(storeIdValue) || "2F"; // Get departureTime from truckData let departureTimeStr = ""; @@ -648,11 +610,11 @@ const TruckLaneDetail: React.FC = () => { ); } - const storeId = truckData.storeId; - const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : "-"; - const displayStoreId = storeIdStr === "2" || storeIdStr === "2F" ? "2F" - : storeIdStr === "4" || storeIdStr === "4F" ? "4F" - : storeIdStr; + const displayStoreId = normalizeStoreId( + truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String + ? String(truckData.storeId) + : String(truckData.storeId)) : null + ); return ( @@ -854,9 +816,10 @@ const TruckLaneDetail: React.FC = () => { {editingRowIndex === index ? ( (() => { - const storeId = truckData.storeId; - const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : ""; - const isEditable = storeIdStr === "4F" || storeIdStr === "4"; + const storeIdValue = truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String + ? String(truckData.storeId) + : String(truckData.storeId)) : null; + const isEditable = normalizeStoreId(storeIdValue) === "4F"; return ( { /> {(() => { - const storeId = truckData?.storeId; - const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : ""; - const isEditable = storeIdStr === "4F" || storeIdStr === "4"; + const storeIdValue = truckData?.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String + ? String(truckData.storeId) + : String(truckData.storeId)) : null; + const isEditable = normalizeStoreId(storeIdValue) === "4F"; return isEditable ? ( { ); }; -export default TruckLaneDetail; - - +export default TruckLaneDetail; \ No newline at end of file From a0070e025eb8a04e892603ed26ba7cd4e4b788f8 Mon Sep 17 00:00:00 2001 From: "Tommy\\2Fi-Staff" Date: Fri, 9 Jan 2026 19:48:16 +0800 Subject: [PATCH 04/12] Make Truck Deatil better --- src/components/Shop/TruckLane.tsx | 12 ++++++++++++ src/components/Shop/TruckLaneDetail.tsx | 26 ++++++++++++------------- src/i18n/en/common.json | 3 ++- src/i18n/zh/common.json | 3 ++- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/components/Shop/TruckLane.tsx b/src/components/Shop/TruckLane.tsx index c1ae0ce..efe0bc5 100644 --- a/src/components/Shop/TruckLane.tsx +++ b/src/components/Shop/TruckLane.tsx @@ -188,6 +188,18 @@ const TruckLane: React.FC = () => { return; } + // Check if truckLanceCode already exists + const trimmedCode = newTruck.truckLanceCode.trim(); + const existingTruck = truckData.find( + (truck) => String(truck.truckLanceCode || "").trim().toLowerCase() === trimmedCode.toLowerCase() + ); + + if (existingTruck) { + setSnackbarMessage(t("Truck lane code already exists. Please use a different code.")); + setSnackbarOpen(true); + return; + } + setSaving(true); setError(null); try { diff --git a/src/components/Shop/TruckLaneDetail.tsx b/src/components/Shop/TruckLaneDetail.tsx index 80398d7..21b5536 100644 --- a/src/components/Shop/TruckLaneDetail.tsx +++ b/src/components/Shop/TruckLaneDetail.tsx @@ -407,12 +407,14 @@ const TruckLaneDetail: React.FC = () => { shopCode: String(matchingShop.code || ""), }); } else { + // If no matching shop found, allow free text input for shop name setNewShop({ ...newShop, shopName: newValue, }); } - } else if (newValue === null) { + } else { + // Clear shop name when selection is cleared (but keep shop code if it exists) setNewShop({ ...newShop, shopName: "", @@ -431,14 +433,17 @@ const TruckLaneDetail: React.FC = () => { shopName: String(matchingShop.name || ""), }); } else { + // If no matching shop found, still set the code (shouldn't happen with restricted selection) setNewShop({ ...newShop, shopCode: newValue, }); } - } else if (newValue === null) { + } else { + // Clear both fields when selection is cleared setNewShop({ ...newShop, + shopName: "", shopCode: "", }); } @@ -633,7 +638,7 @@ const TruckLaneDetail: React.FC = () => { - + {t("TruckLance Code")} @@ -642,7 +647,7 @@ const TruckLaneDetail: React.FC = () => { - + {t("Departure Time")} @@ -655,7 +660,7 @@ const TruckLaneDetail: React.FC = () => { - + {t("Store ID")} @@ -943,12 +948,13 @@ const TruckLaneDetail: React.FC = () => { { handleNewShopNameChange(newValue); }} onInputChange={(event, newInputValue, reason) => { if (reason === 'input') { + // Allow free text input for shop name setNewShop({ ...newShop, shopName: newInputValue }); } }} @@ -966,17 +972,11 @@ const TruckLaneDetail: React.FC = () => { { handleNewShopCodeChange(newValue); }} - onInputChange={(event, newInputValue, reason) => { - if (reason === 'input') { - setNewShop({ ...newShop, shopCode: newInputValue }); - } - }} renderInput={(params) => ( Date: Sat, 10 Jan 2026 01:02:07 +0800 Subject: [PATCH 05/12] adding access right, daily mat forecast --- src/app/api/scheduling/index.ts | 2 + src/authorities.ts | 7 ++ src/authorties.ts | 6 -- .../NavigationContent/NavigationContent.tsx | 82 ++++++++-------- src/config/authConfig.ts | 97 ++++++++++++++----- 5 files changed, 121 insertions(+), 73 deletions(-) create mode 100644 src/authorities.ts delete mode 100644 src/authorties.ts diff --git a/src/app/api/scheduling/index.ts b/src/app/api/scheduling/index.ts index 4171c56..435bb1e 100644 --- a/src/app/api/scheduling/index.ts +++ b/src/app/api/scheduling/index.ts @@ -9,6 +9,7 @@ export type ScheduleType = "all" | "rough" | "detailed" | "manual"; export interface RoughProdScheduleResult { id: number; scheduleAt: number[]; + produceAt: number[]; schedulePeriod: number[]; schedulePeriodTo: number[]; totalEstProdCount: number; @@ -80,6 +81,7 @@ export interface RoughProdScheduleLineResultByBomByDate { // Detailed export interface DetailedProdScheduleResult { id: number; + produceAt: number[]; scheduleAt: number[]; totalEstProdCount: number; totalFGType: number; diff --git a/src/authorities.ts b/src/authorities.ts new file mode 100644 index 0000000..46e56b7 --- /dev/null +++ b/src/authorities.ts @@ -0,0 +1,7 @@ +export const [VIEW_USER, VIEW_DO, MAINTAIN_USER, VIEW_GROUP, MAINTAIN_GROUP] = [ + "VIEW_USER", + "VIEW_DO", + "MAINTAIN_USER", + "VIEW_GROUP", + "MAINTAIN_GROUP", +]; diff --git a/src/authorties.ts b/src/authorties.ts deleted file mode 100644 index 0950252..0000000 --- a/src/authorties.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const [VIEW_USER, MAINTAIN_USER, VIEW_GROUP, MAINTAIN_GROUP] = [ - "VIEW_USER", - "MAINTAIN_USER", - "VIEW_GROUP", - "MAINTAIN_GROUP", -]; diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index a614269..17d178a 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -1,3 +1,4 @@ +import { useSession } from "next-auth/react"; import Divider from "@mui/material/Divider"; import Box from "@mui/material/Box"; import React, { useEffect } from "react"; @@ -24,6 +25,15 @@ import { usePathname } from "next/navigation"; import Link from "next/link"; import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; import Logo from "../Logo"; +import { + VIEW_USER, + VIEW_DO, + MAINTAIN_USER, + VIEW_GROUP, + MAINTAIN_GROUP, + // Add more authorities as needed, e.g.: + // VIEW_PO, MAINTAIN_PO, VIEW_INVENTORY, etc. +} from "../../authorities"; interface NavigationItem { icon: React.ReactNode; @@ -31,9 +41,22 @@ interface NavigationItem { path: string; children?: NavigationItem[]; isHidden?: true | undefined; + requiredAbility?: string | string[]; } const NavigationContent: React.FC = () => { + const { data: session, status } = useSession(); + const abilities = session?.user?.abilities ?? []; + + // Helper: check if user has required permission + const hasAbility = (required?: string | string[]): boolean => { + if (!required) return true; // no requirement → always show + if (Array.isArray(required)) { + return required.some(ability => abilities.includes(ability)); + } + return abilities.includes(required); + }; + const navigationItems: NavigationItem[] = [ { icon: , @@ -108,49 +131,12 @@ const NavigationContent: React.FC = () => { }, ], }, - // { - // icon: , - // label: "Production", - // path: "", - // children: [ - // { - // icon: , - // label: "Job Order", - // path: "", - // }, - // { - // icon: , - // label: "Job Order Traceablity ", - // path: "", - // }, - // { - // icon: , - // label: "Work Order", - // path: "", - // }, - // { - // icon: , - // label: "Work Order Traceablity ", - // path: "", - // }, - // ], - // }, - // { - // icon: , - // label: "Quality Control Log", - // path: "", - // children: [ - // { - // icon: , - // label: "Quality Control Log", - // path: "", - // }, - // ], - // }, { icon: , label: "Delivery", path: "", + //requiredAbility: VIEW_DO, + requiredAbility: VIEW_USER, children: [ { icon: , @@ -248,16 +234,19 @@ const NavigationContent: React.FC = () => { icon: , label: "Settings", path: "", + requiredAbility: [VIEW_USER, VIEW_GROUP], children: [ { icon: , label: "User", path: "/settings/user", + requiredAbility: VIEW_USER, }, { icon: , label: "User Group", path: "/settings/user", + requiredAbility: VIEW_GROUP, }, // { // icon: , @@ -360,7 +349,12 @@ const NavigationContent: React.FC = () => { }; const renderNavigationItem = (item: NavigationItem) => { + if (!hasAbility(item.requiredAbility)) { + return null; + } + const isOpen = openItems.includes(item.label); + const hasVisibleChildren = item.children?.some(child => hasAbility(child.requiredAbility)); return ( { {item.icon} - {item.children && isOpen && ( + {item.children && isOpen && hasVisibleChildren && ( {item.children.map( (child) => !child.isHidden && renderNavigationItem(child), @@ -387,6 +381,10 @@ const NavigationContent: React.FC = () => { ); }; + if (status === "loading") { + return Loading...; + } + return ( @@ -397,7 +395,9 @@ const NavigationContent: React.FC = () => { - {navigationItems.map((item) => renderNavigationItem(item))} + {navigationItems + .map(renderNavigationItem) + .filter(Boolean)} {/* {navigationItems.map(({ icon, label, path }, index) => { return ( Date: Sat, 10 Jan 2026 23:28:58 +0800 Subject: [PATCH 06/12] added printer testing --- src/app/(main)/testing/page.tsx | 306 ++++++++++++++++++ src/app/api/scheduling/actions.ts | 24 ++ .../DetailedScheduleSearchView.tsx | 66 ++++ .../NavigationContent/NavigationContent.tsx | 10 +- 4 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 src/app/(main)/testing/page.tsx diff --git a/src/app/(main)/testing/page.tsx b/src/app/(main)/testing/page.tsx new file mode 100644 index 0000000..4c64f92 --- /dev/null +++ b/src/app/(main)/testing/page.tsx @@ -0,0 +1,306 @@ +"use client"; + +import React, { useState } from "react"; +import { + Box, Grid, Paper, Typography, Button, Dialog, DialogTitle, + DialogContent, DialogActions, TextField, Stack, Table, + TableBody, TableCell, TableContainer, TableHead, TableRow +} from "@mui/material"; +import { FileDownload, Print, SettingsEthernet, Lan, Router } from "@mui/icons-material"; +import dayjs from "dayjs"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; + +export default function TestingPage() { + // --- 1. TSC Section States --- + const [tscConfig, setTscConfig] = useState({ ip: '192.168.1.100', port: '9100' }); + const [tscItems, setTscItems] = useState([ + { id: 1, itemCode: 'FG-001', itemName: 'Yellow Curry Sauce', lotNo: 'LOT-TSC-01', expiryDate: '2025-12-01' }, + { id: 2, itemCode: 'FG-002', itemName: 'Red Curry Paste', lotNo: 'LOT-TSC-02', expiryDate: '2025-12-05' }, + ]); + + // --- 2. DataFlex Section States --- + const [dfConfig, setDfConfig] = useState({ ip: '192.168.1.101', port: '9100' }); + const [dfItems, setDfItems] = useState([ + { id: 1, itemCode: 'DF-101', itemName: 'Instant Noodle A', lotNo: 'LOT-DF-01', expiryDate: '2026-01-10' }, + { id: 2, itemCode: 'DF-102', itemName: 'Instant Noodle B', lotNo: 'LOT-DF-02', expiryDate: '2026-01-15' }, + ]); + + // --- 3. OnPack Section States --- + const [isPrinterModalOpen, setIsPrinterModalOpen] = useState(false); + const [printerFormData, setPrinterFormData] = useState({ + itemCode: '', + lotNo: '', + expiryDate: dayjs().format('YYYY-MM-DD'), + productName: '' + }); + + // --- 4. Laser Section States --- +const [laserConfig, setLaserConfig] = useState({ ip: '192.168.1.102', port: '8080' }); +const [laserItems, setLaserItems] = useState([ + { id: 1, templateId: 'JOB_001', lotNo: 'L-LASER-01', expiryDate: '2025-12-31', power: '50' }, +]); + + // Generic handler for inline table edits + const handleItemChange = (setter: any, id: number, field: string, value: string) => { + setter((prev: any[]) => prev.map(item => + item.id === id ? { ...item, [field]: value } : item + )); + }; + + // --- API CALLS --- + + // TSC Print (Section 1) + const handleTscPrint = async (row: any) => { + const token = localStorage.getItem("accessToken"); + const payload = { ...row, printerIp: tscConfig.ip, printerPort: tscConfig.port }; + try { + const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-tsc`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (response.ok) alert(`TSC Print Command Sent for ${row.itemCode}!`); + else alert("TSC Print Failed"); + } catch (e) { console.error("TSC Error:", e); } + }; + + // DataFlex Print (Section 2) + const handleDfPrint = async (row: any) => { + const token = localStorage.getItem("accessToken"); + const payload = { ...row, printerIp: dfConfig.ip, printerPort: dfConfig.port }; + try { + const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (response.ok) alert(`DataFlex Print Command Sent for ${row.itemCode}!`); + else alert("DataFlex Print Failed"); + } catch (e) { console.error("DataFlex Error:", e); } + }; + + // OnPack Zip Download (Section 3) + const handleDownloadPrintJob = async () => { + const token = localStorage.getItem("accessToken"); + const params = new URLSearchParams(printerFormData); + try { + const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/get-printer6?${params.toString()}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) throw new Error('Download failed'); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `${printerFormData.lotNo || 'OnPack'}.zip`); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + + setIsPrinterModalOpen(false); + } catch (e) { console.error("OnPack Error:", e); } + }; + + const handleLaserPrint = async (row: any) => { + const token = localStorage.getItem("accessToken"); + const payload = { ...row, printerIp: laserConfig.ip, printerPort: laserConfig.port }; + try { + const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (response.ok) alert(`Laser Command Sent: ${row.templateId}`); + } catch (e) { console.error(e); } + }; + + const handleLaserPreview = async (row: any) => { + const token = localStorage.getItem("accessToken"); + const payload = { ...row, printerIp: laserConfig.ip, printerPort: parseInt(laserConfig.port) }; + try { + // We'll create this endpoint in the backend next + const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (response.ok) alert("Red light preview active!"); + } catch (e) { console.error("Preview Error:", e); } + }; + + // Layout Helper + const Section = ({ title, children }: { title: string, children?: React.ReactNode }) => ( + + + + {title} + + {children || Waiting for implementation...} + + + ); + + return ( + + Printer Testing Dashboard + + + {/* 1. TSC Section */} +
+ + setTscConfig({...tscConfig, ip: e.target.value})} /> + setTscConfig({...tscConfig, port: e.target.value})} /> + + + + + + + Code + Name + Lot + Expiry + Action + + + + {tscItems.map(row => ( + + handleItemChange(setTscItems, row.id, 'itemCode', e.target.value)} /> + handleItemChange(setTscItems, row.id, 'itemName', e.target.value)} /> + handleItemChange(setTscItems, row.id, 'lotNo', e.target.value)} /> + handleItemChange(setTscItems, row.id, 'expiryDate', e.target.value)} /> + + + ))} + +
+
+
+ + {/* 2. DataFlex Section */} +
+ + setDfConfig({...dfConfig, ip: e.target.value})} /> + setDfConfig({...dfConfig, port: e.target.value})} /> + + + + + + + Code + Name + Lot + Expiry + Action + + + + {dfItems.map(row => ( + + handleItemChange(setDfItems, row.id, 'itemCode', e.target.value)} /> + handleItemChange(setDfItems, row.id, 'itemName', e.target.value)} /> + handleItemChange(setDfItems, row.id, 'lotNo', e.target.value)} /> + handleItemChange(setDfItems, row.id, 'expiryDate', e.target.value)} /> + + + ))} + +
+
+
+ + {/* 3. OnPack Section */} +
+ + + Calls /plastic/get-printer6 to generate CoLOS .job bundle. + + + +
+ + {/* 4. Laser Section (HANS600S-M) */} +
+ + setLaserConfig({...laserConfig, ip: e.target.value})} /> + setLaserConfig({...laserConfig, port: e.target.value})} /> + + + + + + + + Template + Lot + Exp + Pwr% + Action + + + + {laserItems.map(row => ( + + handleItemChange(setLaserItems, row.id, 'templateId', e.target.value)} /> + handleItemChange(setLaserItems, row.id, 'lotNo', e.target.value)} /> + handleItemChange(setLaserItems, row.id, 'expiryDate', e.target.value)} /> + handleItemChange(setLaserItems, row.id, 'power', e.target.value)} /> + + + + + + + + ))} + +
+
+ + Note: HANS Laser requires pre-saved templates on the controller. + +
+
+ + {/* Dialog for OnPack */} + setIsPrinterModalOpen(false)} fullWidth maxWidth="sm"> + OnPack Printer Job Details + + + setPrinterFormData({ ...printerFormData, itemCode: e.target.value })} /> + setPrinterFormData({ ...printerFormData, lotNo: e.target.value })} /> + setPrinterFormData({ ...printerFormData, productName: e.target.value })} /> + setPrinterFormData({ ...printerFormData, expiryDate: e.target.value })} /> + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/src/app/api/scheduling/actions.ts b/src/app/api/scheduling/actions.ts index 0ed01bb..ca5e48f 100644 --- a/src/app/api/scheduling/actions.ts +++ b/src/app/api/scheduling/actions.ts @@ -43,6 +43,13 @@ export interface ReleaseProdScheduleReq { id: number; } +export interface print6FilesReq { + itemCode: 'string', + lotNo: 'string', + expiryDate: 'string', + productName: 'string' +} + export interface ReleaseProdScheduleResponse { id: number; code: string; @@ -145,6 +152,23 @@ export const testDetailedSchedule = cache(async (date?: string) => { ); }); +export const getFile6 = cache(async ( + token: string | "", + data: print6FilesReq +) => { + const queryStr = convertObjToURLSearchParams(data) + return serverFetchJson( + `${BASE_API_URL}/plastic/get-printer6?${queryStr}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + }, + ); +}); + export const releaseProdScheduleLine = cache(async (data: ReleaseProdScheduleInputs) => { const response = serverFetchJson( `${BASE_API_URL}/productionSchedule/detail/detailed/releaseLine`, diff --git a/src/components/DetailedSchedule/DetailedScheduleSearchView.tsx b/src/components/DetailedSchedule/DetailedScheduleSearchView.tsx index 2b53dd9..7b63578 100644 --- a/src/components/DetailedSchedule/DetailedScheduleSearchView.tsx +++ b/src/components/DetailedSchedule/DetailedScheduleSearchView.tsx @@ -7,6 +7,7 @@ import { EditNote } from "@mui/icons-material"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslation } from "react-i18next"; import { ScheduleType } from "@/app/api/scheduling"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { ProdScheduleResult, SearchProdSchedule, @@ -14,6 +15,7 @@ import { fetchProdSchedules, exportProdSchedule, testDetailedSchedule, + getFile6, } from "@/app/api/scheduling/actions"; import { defaultPagingController } from "../SearchResults/SearchResults"; import { arrayToDateString, arrayToDayjs, decimalFormatter } from "@/app/utils/formatUtil"; @@ -23,6 +25,9 @@ import { Button, Stack } from "@mui/material"; import isToday from 'dayjs/plugin/isToday'; import useUploadContext from "../UploadProvider/useUploadContext"; import { FileDownload, CalendarMonth } from "@mui/icons-material"; +import { useSession } from "next-auth/react"; +import { VIEW_USER, VIEW_DO } from "@/authorities"; + dayjs.extend(isToday); // may need move to "index" or "actions" @@ -52,6 +57,10 @@ const DSOverview: React.FC = ({ type, defaultInputs }) => { const { setIsUploading } = useUploadContext(); const today = dayjs().format("YYYY-MM-DD"); + const { data: session } = useSession(); + // Extract abilities (safe fallback to empty array if not logged in / no abilities) + const abilities = session?.user?.abilities ?? []; + const router = useRouter(); // const [filterObj, setFilterObj] = useState({}); // const [tempSelectedValue, setTempSelectedValue] = useState({}); @@ -226,6 +235,48 @@ const DSOverview: React.FC = ({ type, defaultInputs }) => { refetchData(resetWithToday, "reset"); // Fetch data }, [defaultInputs, refetchData]); + const handleDownloadPrintJob = async () => { + const token = localStorage.getItem("accessToken"); + + const params = { + itemCode: 'TT173', + lotNo: 'LOT342989', + expiryDate: '2026-02-28', + productName: 'Name2342' + }; + + try { + // 1. Direct fetch call to avoid Next.js trying to parse JSON + const query = new URLSearchParams(params).toString(); + const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/get-printer6?${query}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (!response.ok) throw new Error('Network response was not ok'); + + // 2. GET THE DATA AS BLOB (This is the fix) + const blob = await response.blob(); + + // 3. Create a download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `${params.lotNo}.zip`); + document.body.appendChild(link); + link.click(); + + // Cleanup + link.parentNode?.removeChild(link); + window.URL.revokeObjectURL(url); + + } catch (error) { + console.error("Download failed", error); + } + }; + const testDetailedScheduleClick = useCallback(async () => { try { setIsUploading(true) @@ -332,6 +383,21 @@ const DSOverview: React.FC = ({ type, defaultInputs }) => { > {t("Export Schedule")} + + {false && abilities.includes(VIEW_USER) && ( + + )}
{ }, ], }, + { + icon: , + label: "Printer Testing", + path: "/testing", + isHidden: false, + }, { icon: , label: "Settings", @@ -396,6 +403,7 @@ const NavigationContent: React.FC = () => { {navigationItems + .filter(item => !item.isHidden) .map(renderNavigationItem) .filter(Boolean)} {/* {navigationItems.map(({ icon, label, path }, index) => { From 6bf874bda87ad38f3c6eb31caecdc7871e668520 Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Sat, 10 Jan 2026 23:55:09 +0800 Subject: [PATCH 07/12] adding auth --- src/authorities.ts | 11 +++++++++-- .../DetailedSchedule/DetailedScheduleSearchView.tsx | 2 +- .../NavigationContent/NavigationContent.tsx | 5 ++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/authorities.ts b/src/authorities.ts index 46e56b7..227b0a2 100644 --- a/src/authorities.ts +++ b/src/authorities.ts @@ -1,7 +1,14 @@ -export const [VIEW_USER, VIEW_DO, MAINTAIN_USER, VIEW_GROUP, MAINTAIN_GROUP] = [ +export const [VIEW_USER,MAINTAIN_USER, VIEW_GROUP, MAINTAIN_GROUP, + TESTING, PROD, PACK, ADMIN, STOCK, Driver] = [ "VIEW_USER", - "VIEW_DO", "MAINTAIN_USER", "VIEW_GROUP", "MAINTAIN_GROUP", + //below auth act as role + "TESTING", + "PROD", + "PACK", + "ADMIN", + "STOCK", + "Driver", ]; diff --git a/src/components/DetailedSchedule/DetailedScheduleSearchView.tsx b/src/components/DetailedSchedule/DetailedScheduleSearchView.tsx index 7b63578..ddccc5a 100644 --- a/src/components/DetailedSchedule/DetailedScheduleSearchView.tsx +++ b/src/components/DetailedSchedule/DetailedScheduleSearchView.tsx @@ -26,7 +26,7 @@ import isToday from 'dayjs/plugin/isToday'; import useUploadContext from "../UploadProvider/useUploadContext"; import { FileDownload, CalendarMonth } from "@mui/icons-material"; import { useSession } from "next-auth/react"; -import { VIEW_USER, VIEW_DO } from "@/authorities"; +import { VIEW_USER } from "@/authorities"; dayjs.extend(isToday); diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 6f9aa00..0abc585 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -28,12 +28,11 @@ import Logo from "../Logo"; import BugReportIcon from "@mui/icons-material/BugReport"; import { VIEW_USER, - VIEW_DO, MAINTAIN_USER, VIEW_GROUP, MAINTAIN_GROUP, // Add more authorities as needed, e.g.: - // VIEW_PO, MAINTAIN_PO, VIEW_INVENTORY, etc. + TESTING, PROD, PACK, ADMIN, STOCK, Driver } from "../../authorities"; interface NavigationItem { @@ -137,7 +136,6 @@ const NavigationContent: React.FC = () => { label: "Delivery", path: "", //requiredAbility: VIEW_DO, - requiredAbility: VIEW_USER, children: [ { icon: , @@ -235,6 +233,7 @@ const NavigationContent: React.FC = () => { icon: , label: "Printer Testing", path: "/testing", + requiredAbility: TESTING, isHidden: false, }, { From 5d836cdffcc43efdbd9133258aa41d78a69fcd4d Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Sun, 11 Jan 2026 02:15:41 +0800 Subject: [PATCH 08/12] Adding new UI for production schedule --- src/app/(main)/ps/page.tsx | 316 ++++++++++++++++++ .../NavigationContent/NavigationContent.tsx | 7 + 2 files changed, 323 insertions(+) create mode 100644 src/app/(main)/ps/page.tsx diff --git a/src/app/(main)/ps/page.tsx b/src/app/(main)/ps/page.tsx new file mode 100644 index 0000000..fc2a73e --- /dev/null +++ b/src/app/(main)/ps/page.tsx @@ -0,0 +1,316 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { + Box, Paper, Typography, Button, Dialog, DialogTitle, + DialogContent, DialogActions, TextField, Stack, Table, + TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, + CircularProgress, Tooltip +} from "@mui/material"; +import { + Search, Visibility, ListAlt, CalendarMonth, + OnlinePrediction, FileDownload, SettingsEthernet +} from "@mui/icons-material"; +import dayjs from "dayjs"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; + +export default function ProductionSchedulePage() { + // --- 1. States --- + const [searchDate, setSearchDate] = useState(dayjs().format('YYYY-MM-DD')); + const [schedules, setSchedules] = useState([]); + const [selectedLines, setSelectedLines] = useState([]); + const [isDetailOpen, setIsDetailOpen] = useState(false); + const [selectedPs, setSelectedPs] = useState(null); + const [loading, setLoading] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + + // --- 2. Auto-search on page entry --- + useEffect(() => { + handleSearch(); + }, []); + + // --- 3. Formatters & Helpers --- + + // Handles [YYYY, MM, DD] format from Kotlin/Java LocalDate + const formatBackendDate = (dateVal: any) => { + if (Array.isArray(dateVal)) { + const [year, month, day] = dateVal; + return dayjs(new Date(year, month - 1, day)).format('DD MMM (dddd)'); + } + return dayjs(dateVal).format('DD MMM (dddd)'); + }; + + // Adds commas as thousands separators + const formatNum = (num: any) => { + return new Intl.NumberFormat('en-US').format(Number(num) || 0); + }; + + // Logic to determine if the selected row's produceAt is TODAY + const isDateToday = useMemo(() => { + if (!selectedPs?.produceAt) return false; + const todayStr = dayjs().format('YYYY-MM-DD'); + let scheduleDateStr = ""; + + if (Array.isArray(selectedPs.produceAt)) { + const [y, m, d] = selectedPs.produceAt; + scheduleDateStr = dayjs(new Date(y, m - 1, d)).format('YYYY-MM-DD'); + } else { + scheduleDateStr = dayjs(selectedPs.produceAt).format('YYYY-MM-DD'); + } + + return todayStr === scheduleDateStr; + }, [selectedPs]); + + // --- 4. API Actions --- + + // Main Grid Query + const handleSearch = async () => { + const token = localStorage.getItem("accessToken"); + setLoading(true); + try { + const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await response.json(); + setSchedules(Array.isArray(data) ? data : []); + } catch (e) { + console.error("Search Error:", e); + } finally { + setLoading(false); + } + }; + + // Forecast API + const handleForecast = async () => { + const token = localStorage.getItem("accessToken"); + setLoading(true); + try { + const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/testDetailedSchedule`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (response.ok) { + await handleSearch(); // Refresh grid after successful forecast + } + } catch (e) { + console.error("Forecast Error:", e); + } finally { + setLoading(false); + } + }; + + // Export Excel API + const handleExport = async () => { + const token = localStorage.getItem("accessToken"); + try { + const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/export-prod-schedule`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!response.ok) throw new Error("Export failed"); + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `production_schedule_${dayjs().format('YYYYMMDD')}.xlsx`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (e) { + console.error("Export Error:", e); + } + }; + + // Get Detail Lines + const handleViewDetail = async (ps: any) => { + const token = localStorage.getItem("accessToken"); + setSelectedPs(ps); + try { + const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await response.json(); + setSelectedLines(data || []); + setIsDetailOpen(true); + } catch (e) { + console.error("Detail Error:", e); + } + }; + + // Auto Gen Job API (Only allowed for Today's date) + const handleAutoGenJob = async () => { + if (!isDateToday) return; + const token = localStorage.getItem("accessToken"); + setIsGenerating(true); + try { + const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/detail/detailed/release`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ id: selectedPs.id }) + }); + + if (response.ok) { + alert("Job Orders generated successfully!"); + setIsDetailOpen(false); + } else { + alert("Failed to generate jobs."); + } + } catch (e) { + console.error("Release Error:", e); + } finally { + setIsGenerating(false); + } + }; + + return ( + + + {/* Top Header Buttons */} + + + + Production Planning + + + + + + + + + {/* Query Bar */} + + setSearchDate(e.target.value)} + /> + + + + {/* Main Grid Table */} + + + + + Action + ID + Production Date + Est. Prod Count + Total FG Types + + + + {schedules.map((ps) => ( + + + handleViewDetail(ps)}> + + + + #{ps.id} + {formatBackendDate(ps.produceAt)} + {formatNum(ps.totalEstProdCount)} + {formatNum(ps.totalFGType)} + + ))} + +
+
+ + {/* Detailed Lines Dialog */} + setIsDetailOpen(false)} maxWidth="lg" fullWidth> + + + + Schedule Details: {selectedPs?.id} ({formatBackendDate(selectedPs?.produceAt)}) + + + + + + + + Job Order + Item Code + Item Name + Avg Last Month + Stock + Days Left + Batch Need + Prod Qty + Priority + + + + {selectedLines.map((line: any) => ( + + {line.joCode || '-'} + {line.itemCode} + {line.itemName} + {formatNum(line.avgQtyLastMonth)} + {formatNum(line.stockQty)} + + {line.daysLeft} + + {formatNum(line.batchNeed)} + {formatNum(line.prodQty)} + {line.itemPriority} + + ))} + +
+
+
+ + {/* Footer Actions */} + + + + + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 0abc585..7e33068 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -229,6 +229,13 @@ const NavigationContent: React.FC = () => { }, ], }, + { + icon: , + label: "PS", + path: "/ps", + requiredAbility: TESTING, + isHidden: false, + }, { icon: , label: "Printer Testing", From 40229f6d673fc08f25186aae588e94e4787c4785 Mon Sep 17 00:00:00 2001 From: "B.E.N.S.O.N" Date: Sun, 11 Jan 2026 23:29:59 +0800 Subject: [PATCH 09/12] Supporting Function: Equipment Handle and QR Code Printing --- src/app/(main)/settings/equipment/page.tsx | 3 +- src/app/api/settings/equipment/client.ts | 33 + .../api/settings/equipmentDetail/client.ts | 33 + src/app/api/settings/equipmentDetail/index.ts | 32 + src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../EquipmentSearch/EquipmentSearch.tsx | 701 ++++++++++++++++-- .../EquipmentSearchLoading.tsx | 2 + .../EquipmentSearchResults.tsx | 146 ++-- src/components/SearchBox/SearchBox.tsx | 4 +- .../qrCodeHandleEquipmentSearch.tsx | 582 +++++++++++++-- .../qrCodeHandleEquipmentSearchWrapper.tsx | 10 +- .../qrCodeHandles/qrCodeHandleTabs.tsx | 28 +- src/i18n/zh/common.json | 15 +- 13 files changed, 1390 insertions(+), 200 deletions(-) create mode 100644 src/app/api/settings/equipment/client.ts create mode 100644 src/app/api/settings/equipmentDetail/client.ts create mode 100644 src/app/api/settings/equipmentDetail/index.ts diff --git a/src/app/(main)/settings/equipment/page.tsx b/src/app/(main)/settings/equipment/page.tsx index f55631c..3ef292d 100644 --- a/src/app/(main)/settings/equipment/page.tsx +++ b/src/app/(main)/settings/equipment/page.tsx @@ -12,6 +12,7 @@ import { Suspense } from "react"; import { fetchAllEquipments } from "@/app/api/settings/equipment"; import { I18nProvider } from "@/i18n"; import EquipmentSearchWrapper from "@/components/EquipmentSearch/EquipmentSearchWrapper"; +import EquipmentSearchLoading from "@/components/EquipmentSearch/EquipmentSearchLoading"; export const metadata: Metadata = { title: "Equipment Type", @@ -33,7 +34,7 @@ const productSetting: React.FC = async () => { {t("Equipment")} - }> + }> diff --git a/src/app/api/settings/equipment/client.ts b/src/app/api/settings/equipment/client.ts new file mode 100644 index 0000000..8ab3998 --- /dev/null +++ b/src/app/api/settings/equipment/client.ts @@ -0,0 +1,33 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { EquipmentResult } from "./index"; + +export const exportEquipmentQrCode = async (equipmentIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { + + const token = localStorage.getItem("accessToken"); + + const response = await fetch(`${NEXT_PUBLIC_API_URL}/Equipment/export-qrcode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), + }, + body: JSON.stringify({ equipmentIds }), + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error("Unauthorized: Please log in again"); + } + throw new Error(`Failed to export QR code: ${response.status} ${response.statusText}`); + } + + const filename = response.headers.get("Content-Disposition")?.split("filename=")[1]?.replace(/"/g, "") || "equipment_qrcode.pdf"; + + const blob = await response.blob(); + const arrayBuffer = await blob.arrayBuffer(); + const blobValue = new Uint8Array(arrayBuffer); + + return { blobValue, filename }; +}; diff --git a/src/app/api/settings/equipmentDetail/client.ts b/src/app/api/settings/equipmentDetail/client.ts new file mode 100644 index 0000000..8627b52 --- /dev/null +++ b/src/app/api/settings/equipmentDetail/client.ts @@ -0,0 +1,33 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { EquipmentDetailResult } from "./index"; + +export const exportEquipmentQrCode = async (equipmentDetailIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { + + const token = localStorage.getItem("accessToken"); + + const response = await fetch(`${NEXT_PUBLIC_API_URL}/Equipment/export-qrcode`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), + }, + body: JSON.stringify({ equipmentDetailIds }), + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error("Unauthorized: Please log in again"); + } + throw new Error(`Failed to export QR code: ${response.status} ${response.statusText}`); + } + + const filename = response.headers.get("Content-Disposition")?.split("filename=")[1]?.replace(/"/g, "") || "equipment_qrcode.pdf"; + + const blob = await response.blob(); + const arrayBuffer = await blob.arrayBuffer(); + const blobValue = new Uint8Array(arrayBuffer); + + return { blobValue, filename }; +}; diff --git a/src/app/api/settings/equipmentDetail/index.ts b/src/app/api/settings/equipmentDetail/index.ts new file mode 100644 index 0000000..393442c --- /dev/null +++ b/src/app/api/settings/equipmentDetail/index.ts @@ -0,0 +1,32 @@ +import { cache } from "react"; +import "server-only"; +import { serverFetchJson } from "../../../utils/fetchUtil"; +import { BASE_API_URL } from "../../../../config/api"; + +export type EquipmentDetailResult = { + id: string | number; + code: string; + name: string; + description: string | undefined; + equipmentCode?: string; + equipmentTypeId?: string | number | undefined; + repairAndMaintenanceStatus?: boolean | number; + latestRepairAndMaintenanceDate?: string | Date; + lastRepairAndMaintenanceDate?: string | Date; + repairAndMaintenanceRemarks?: string; +}; + +export const fetchAllEquipmentDetails = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/EquipmentDetail`, { + next: { tags: ["equipmentDetails"] }, + }); +}); + +export const fetchEquipmentDetail = cache(async (id: number) => { + return serverFetchJson( + `${BASE_API_URL}/EquipmentDetail/details/${id}`, + { + next: { tags: ["equipmentDetails"] }, + }, + ); +}); diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 23d378d..114f98c 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -17,6 +17,7 @@ const pathToLabelMap: { [path: string]: string } = { "/settings/qrCodeHandle": "QR Code Handle", "/settings/rss": "Demand Forecast Setting", "/settings/equipment": "Equipment", + "/settings/equipment/MaintenanceEdit": "MaintenanceEdit", "/settings/shop": "ShopAndTruck", "/settings/shop/detail": "Shop Detail", "/settings/shop/truckdetail": "Truck Lane Detail", diff --git a/src/components/EquipmentSearch/EquipmentSearch.tsx b/src/components/EquipmentSearch/EquipmentSearch.tsx index 735b2a8..c850014 100644 --- a/src/components/EquipmentSearch/EquipmentSearch.tsx +++ b/src/components/EquipmentSearch/EquipmentSearch.tsx @@ -1,20 +1,35 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import SearchBox, { Criterion } from "../SearchBox"; import { EquipmentResult } from "@/app/api/settings/equipment"; import { useTranslation } from "react-i18next"; import EquipmentSearchResults, { Column } from "./EquipmentSearchResults"; import { EditNote } from "@mui/icons-material"; -import { useRouter, useSearchParams } from "next/navigation"; -import { GridDeleteIcon } from "@mui/x-data-grid"; -import { TypeEnum } from "@/app/utils/typeEnum"; -import axios from "axios"; +import { useRouter } from "next/navigation"; import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api"; import axiosInstance from "@/app/(main)/axios/axiosInstance"; import { arrayToDateTimeString } from "@/app/utils/formatUtil"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import CircularProgress from "@mui/material/CircularProgress"; +import TableRow from "@mui/material/TableRow"; +import TableCell from "@mui/material/TableCell"; +import Collapse from "@mui/material/Collapse"; +import Grid from "@mui/material/Grid"; +import DeleteIcon from "@mui/icons-material/Delete"; +import AddIcon from "@mui/icons-material/Add"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogTitle from "@mui/material/DialogTitle"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogActions from "@mui/material/DialogActions"; +import TextField from "@mui/material/TextField"; +import Autocomplete from "@mui/material/Autocomplete"; type Props = { equipments: EquipmentResult[]; @@ -28,14 +43,37 @@ const EquipmentSearch: React.FC = ({ equipments, tabIndex = 0 }) => { useState([]); const { t } = useTranslation("common"); const router = useRouter(); - const [filterObj, setFilterObj] = useState({}); - const [pagingController, setPagingController] = useState({ - pageNum: 1, - pageSize: 10, + const [filterObjByTab, setFilterObjByTab] = useState>({ + 0: {}, + 1: {}, + }); + const [pagingControllerByTab, setPagingControllerByTab] = useState>({ + 0: { pageNum: 1, pageSize: 10 }, + 1: { pageNum: 1, pageSize: 10 }, }); const [totalCount, setTotalCount] = useState(0); const [isLoading, setIsLoading] = useState(true); const [isReady, setIsReady] = useState(false); + + const filterObj = filterObjByTab[tabIndex] || {}; + const pagingController = pagingControllerByTab[tabIndex] || { pageNum: 1, pageSize: 10 }; + + const [expandedRows, setExpandedRows] = useState>(new Set()); + const [equipmentDetailsMap, setEquipmentDetailsMap] = useState>(new Map()); + const [loadingDetailsMap, setLoadingDetailsMap] = useState>(new Map()); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [itemToDelete, setItemToDelete] = useState<{ id: string | number; equipmentId: string | number } | null>(null); + const [deleting, setDeleting] = useState(false); + + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [equipmentList, setEquipmentList] = useState([]); + const [selectedDescription, setSelectedDescription] = useState(""); + const [selectedName, setSelectedName] = useState(""); + const [selectedEquipmentCode, setSelectedEquipmentCode] = useState(""); + const [isExistingCombination, setIsExistingCombination] = useState(false); + const [loadingEquipments, setLoadingEquipments] = useState(false); + const [saving, setSaving] = useState(false); useEffect(() => { const checkReady = () => { @@ -90,20 +128,12 @@ const EquipmentSearch: React.FC = ({ equipments, tabIndex = 0 }) => { }, ]; } - + return [ - { label: t("Code"), paramName: "code", type: "text" }, - { label: t("Description"), paramName: "description", type: "text" }, + { label: "設備編號", paramName: "code", type: "text" }, ]; }, [t, tabIndex]); - const onDetailClick = useCallback( - (equipment: EquipmentResult) => { - router.push(`/settings/equipment/edit?id=${equipment.id}`); - }, - [router], - ); - const onMaintenanceEditClick = useCallback( (equipment: EquipmentResult) => { router.push(`/settings/equipment/MaintenanceEdit?id=${equipment.id}`); @@ -116,34 +146,226 @@ const EquipmentSearch: React.FC = ({ equipments, tabIndex = 0 }) => { [router], ); + const fetchEquipmentDetailsByEquipmentId = useCallback(async (equipmentId: string | number) => { + setLoadingDetailsMap(prev => new Map(prev).set(equipmentId, true)); + try { + const response = await axiosInstance.get<{ + records: EquipmentResult[]; + total: number; + }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byEquipmentId/${equipmentId}`); + + if (response.status === 200) { + setEquipmentDetailsMap(prev => new Map(prev).set(equipmentId, response.data.records || [])); + } + } catch (error) { + console.error("Error fetching equipment details:", error); + setEquipmentDetailsMap(prev => new Map(prev).set(equipmentId, [])); + } finally { + setLoadingDetailsMap(prev => new Map(prev).set(equipmentId, false)); + } + }, []); + + const handleDeleteClick = useCallback((detailId: string | number, equipmentId: string | number) => { + setItemToDelete({ id: detailId, equipmentId }); + setDeleteDialogOpen(true); + }, []); + + const handleDeleteConfirm = useCallback(async () => { + if (!itemToDelete) return; + + setDeleting(true); + try { + const response = await axiosInstance.delete( + `${NEXT_PUBLIC_API_URL}/EquipmentDetail/delete/${itemToDelete.id}` + ); + + if (response.status === 200 || response.status === 204) { + setEquipmentDetailsMap(prev => { + const newMap = new Map(prev); + const currentDetails = newMap.get(itemToDelete.equipmentId) || []; + const updatedDetails = currentDetails.filter(detail => detail.id !== itemToDelete.id); + newMap.set(itemToDelete.equipmentId, updatedDetails); + return newMap; + }); + } + } catch (error) { + console.error("Error deleting equipment detail:", error); + alert("刪除失敗,請稍後再試"); + } finally { + setDeleting(false); + setDeleteDialogOpen(false); + setItemToDelete(null); + } + }, [itemToDelete]); + + const handleDeleteCancel = useCallback(() => { + setDeleteDialogOpen(false); + setItemToDelete(null); + }, []); + + const fetchEquipmentList = useCallback(async () => { + setLoadingEquipments(true); + try { + const response = await axiosInstance.get<{ + records: EquipmentResult[]; + total: number; + }>(`${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`, { + params: { + pageNum: 1, + pageSize: 1000, + }, + }); + + if (response.status === 200) { + setEquipmentList(response.data.records || []); + } + } catch (error) { + console.error("Error fetching equipment list:", error); + setEquipmentList([]); + } finally { + setLoadingEquipments(false); + } + }, []); + + const handleAddClick = useCallback(() => { + setAddDialogOpen(true); + fetchEquipmentList(); + }, [fetchEquipmentList]); + + const handleAddDialogClose = useCallback(() => { + setAddDialogOpen(false); + setSelectedDescription(""); + setSelectedName(""); + setSelectedEquipmentCode(""); + setIsExistingCombination(false); + }, []); + + const availableDescriptions = useMemo(() => { + const descriptions = equipmentList + .map((eq) => eq.description) + .filter((desc): desc is string => Boolean(desc)); + return Array.from(new Set(descriptions)); + }, [equipmentList]); + + const availableNames = useMemo(() => { + const names = equipmentList + .map((eq) => eq.name) + .filter((name): name is string => Boolean(name)); + return Array.from(new Set(names)); + }, [equipmentList]); + + useEffect(() => { + const checkAndGenerateEquipmentCode = async () => { + if (!selectedDescription || !selectedName) { + setIsExistingCombination(false); + setSelectedEquipmentCode(""); + return; + } + + const equipmentCode = `${selectedDescription}-${selectedName}`; + const existingEquipment = equipmentList.find((eq) => eq.code === equipmentCode); + + if (existingEquipment) { + setIsExistingCombination(true); + + try { + const existingDetailsResponse = await axiosInstance.get<{ + records: EquipmentResult[]; + total: number; + }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byDescriptionIncludingDeleted/${encodeURIComponent(equipmentCode)}`); + + let newEquipmentCode = ""; + + if (existingDetailsResponse.data.records && existingDetailsResponse.data.records.length > 0) { + const equipmentCodePatterns = existingDetailsResponse.data.records + .map((detail) => { + if (!detail.equipmentCode) return null; + const match = detail.equipmentCode.match(/^([A-Za-z]+)(\d+)$/); + if (match) { + const originalNumber = match[2]; + return { + prefix: match[1], + number: parseInt(match[2], 10), + paddingLength: originalNumber.length + }; + } + return null; + }) + .filter((pattern): pattern is { prefix: string; number: number; paddingLength: number } => pattern !== null); + + if (equipmentCodePatterns.length > 0) { + const prefix = equipmentCodePatterns[0].prefix; + const maxEquipmentCodeNumber = Math.max(...equipmentCodePatterns.map(p => p.number)); + const maxPaddingLength = Math.max(...equipmentCodePatterns.map(p => p.paddingLength)); + const nextNumber = maxEquipmentCodeNumber + 1; + newEquipmentCode = `${prefix}${String(nextNumber).padStart(maxPaddingLength, '0')}`; + } else { + newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; + } + } else { + newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; + } + + setSelectedEquipmentCode(newEquipmentCode); + } catch (error) { + console.error("Error checking existing equipment details:", error); + setIsExistingCombination(false); + setSelectedEquipmentCode(""); + } + } else { + setIsExistingCombination(false); + setSelectedEquipmentCode(""); + } + }; + + checkAndGenerateEquipmentCode(); + }, [selectedDescription, selectedName, equipmentList]); + + const handleToggleExpand = useCallback( + (id: string | number, code: string) => { + setExpandedRows(prev => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + if (!equipmentDetailsMap.has(id)) { + fetchEquipmentDetailsByEquipmentId(id); + } + } + return newSet; + }); + }, + [equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId] + ); + const generalDataColumns = useMemo[]>( () => [ - { - name: "id", - label: t("Details"), - onClick: onDetailClick, - buttonIcon: , - }, { name: "code", - label: t("Code"), - }, - { - name: "description", - label: t("Description"), - }, - { - name: "equipmentTypeId", - label: t("Equipment Type"), - }, - { - name: "action", - label: t(""), - buttonIcon: , - onClick: onDeleteClick, + label: "設備編號", + renderCell: (item) => ( + + { + e.stopPropagation(); + handleToggleExpand(item.id, item.code); + }} + sx={{ padding: 0.5 }} + > + {expandedRows.has(item.id) ? ( + + ) : ( + + )} + + {item.code} + + ), }, ], - [onDetailClick, onDeleteClick, t], + [t, handleToggleExpand, expandedRows], ); const repairMaintenanceColumns = useMemo[]>( @@ -250,8 +472,6 @@ const EquipmentSearch: React.FC = ({ equipments, tabIndex = 0 }) => { const transformedFilter: any = { ...filterObj }; - // For maintenance tab (tabIndex === 1), if equipmentCode is provided, - // also search by code (equipment name) with the same value if (tabIndex === 1 && transformedFilter.equipmentCode) { transformedFilter.code = transformedFilter.equipmentCode; } @@ -308,24 +528,253 @@ const EquipmentSearch: React.FC = ({ equipments, tabIndex = 0 }) => { }, [filterObj, pagingController.pageNum, pagingController.pageSize, tabIndex, isReady, refetchData]); const onReset = useCallback(() => { - setFilterObj({}); - setPagingController({ - pageNum: 1, - pageSize: pagingController.pageSize, - }); - }, [pagingController.pageSize]); + setFilterObjByTab(prev => ({ + ...prev, + [tabIndex]: {}, + })); + setPagingControllerByTab(prev => ({ + ...prev, + [tabIndex]: { + pageNum: 1, + pageSize: prev[tabIndex]?.pageSize || 10, + }, + })); + }, [tabIndex]); + + const handleSaveEquipmentDetail = useCallback(async () => { + if (!selectedName || !selectedDescription) { + return; + } + + if (!isExistingCombination && !selectedEquipmentCode) { + alert("請輸入設備編號"); + return; + } + + setSaving(true); + try { + + const equipmentCode = `${selectedDescription}-${selectedName}`; + + let equipment = equipmentList.find((eq) => eq.code === equipmentCode); + let equipmentId: string | number; + + if (!equipment) { + const equipmentResponse = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/Equipment/save`, + { + code: equipmentCode, + name: selectedName, + description: selectedDescription, + id: null, + } + ); + equipment = equipmentResponse.data; + equipmentId = equipment.id; + } else { + equipmentId = equipment.id; + } + + const existingDetailsResponse = await axiosInstance.get<{ + records: EquipmentResult[]; + total: number; + }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byDescriptionIncludingDeleted/${encodeURIComponent(equipmentCode)}`); + + let newName = "1號"; + let newEquipmentCode = ""; + + if (existingDetailsResponse.data.records && existingDetailsResponse.data.records.length > 0) { + const numbers = existingDetailsResponse.data.records + .map((detail) => { + const match = detail.name?.match(/(\d+)號/); + return match ? parseInt(match[1], 10) : 0; + }) + .filter((num) => num > 0); + + if (numbers.length > 0) { + const maxNumber = Math.max(...numbers); + newName = `${maxNumber + 1}號`; + } + + if (isExistingCombination) { + const equipmentCodePatterns = existingDetailsResponse.data.records + .map((detail) => { + if (!detail.equipmentCode) return null; + const match = detail.equipmentCode.match(/^([A-Za-z]+)(\d+)$/); + if (match) { + const originalNumber = match[2]; + return { + prefix: match[1], + number: parseInt(match[2], 10), + paddingLength: originalNumber.length + }; + } + return null; + }) + .filter((pattern): pattern is { prefix: string; number: number; paddingLength: number } => pattern !== null); + + if (equipmentCodePatterns.length > 0) { + const prefix = equipmentCodePatterns[0].prefix; + const maxEquipmentCodeNumber = Math.max(...equipmentCodePatterns.map(p => p.number)); + const maxPaddingLength = Math.max(...equipmentCodePatterns.map(p => p.paddingLength)); + const nextNumber = maxEquipmentCodeNumber + 1; + newEquipmentCode = `${prefix}${String(nextNumber).padStart(maxPaddingLength, '0')}`; + } else { + newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; + } + } else { + newEquipmentCode = selectedEquipmentCode; + } + } else { + if (isExistingCombination) { + newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; + } else { + newEquipmentCode = selectedEquipmentCode; + } + } + + const detailCode = `${equipmentCode}-${newName}`; + + await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/EquipmentDetail/save`, + { + code: detailCode, + name: newName, + description: equipmentCode, + equipmentCode: newEquipmentCode, + id: null, + equipmentTypeId: equipmentId, + repairAndMaintenanceStatus: false, + } + ); + + handleAddDialogClose(); + + if (tabIndex === 0) { + await refetchData(filterObj); + + if (equipmentDetailsMap.has(equipmentId)) { + await fetchEquipmentDetailsByEquipmentId(equipmentId); + } + } + + alert("新增成功"); + } catch (error: any) { + console.error("Error saving equipment detail:", error); + const errorMessage = error.response?.data?.message || error.message || "保存失敗,請稍後再試"; + alert(errorMessage); + } finally { + setSaving(false); + } + }, [selectedName, selectedDescription, selectedEquipmentCode, isExistingCombination, equipmentList, refetchData, filterObj, handleAddDialogClose, tabIndex, equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId]); + + const renderExpandedRow = useCallback((item: EquipmentResult): React.ReactNode => { + if (tabIndex !== 0) { + return null; + } + + const details = equipmentDetailsMap.get(item.id) || []; + const isLoading = loadingDetailsMap.get(item.id) || false; + + return ( + + + + + {isLoading ? ( + + + 載入中... + + ) : details.length === 0 ? ( + 無相關設備詳細資料 + ) : ( + + + 設備詳細資料 (設備編號: {item.code}) + + + {details.map((detail) => ( + + + + + 編號: {detail.code || "-"} + + handleDeleteClick(detail.id, item.id)} + sx={{ ml: 1 }} + > + + + + {detail.name && ( + + 名稱: {detail.name} + + )} + {detail.description && ( + + 描述: {detail.description} + + )} + {detail.equipmentCode && ( + + 設備編號: {detail.equipmentCode} + + )} + + + ))} + + + )} + + + + + ); + }, [columns.length, equipmentDetailsMap, loadingDetailsMap, expandedRows, tabIndex, handleDeleteClick]); return ( <> { - setFilterObj({ - ...query, + setFilterObjByTab(prev => { + const newState = { ...prev }; + newState[tabIndex] = query as unknown as SearchQuery; + return newState; }); }} onReset={onReset} /> + {tabIndex === 0 && ( + + + 設備編號 + + + + )} = ({ equipments, tabIndex = 0 }) => { items={filteredEquipments} columns={columns} - setPagingController={setPagingController} + setPagingController={(newController) => { + setPagingControllerByTab(prev => { + const newState = { ...prev }; + newState[tabIndex] = typeof newController === 'function' + ? newController(prev[tabIndex] || { pageNum: 1, pageSize: 10 }) + : newController; + return newState; + }); + }} pagingController={pagingController} totalCount={totalCount} isAutoPaging={false} - /> - - - ); -}; + renderExpandedRow={renderExpandedRow} + hideHeader={tabIndex === 0} + /> +
+ + {/* Delete Confirmation Dialog */} + {deleteDialogOpen && ( + + + 確認刪除 + + + + 您確定要刪除此設備詳細資料嗎?此操作無法復原。 + + + + + + + + )} -export default EquipmentSearch; \ No newline at end of file + {/* Add Equipment Detail Dialog */} + + + 新增設備詳細資料 + + + + { + setSelectedDescription(newValue || ''); + }} + onInputChange={(event, newInputValue) => { + setSelectedDescription(newInputValue); + }} + loading={loadingEquipments} + disabled={loadingEquipments || saving} + renderInput={(params) => ( + + )} + sx={{ mb: 2 }} + /> + { + setSelectedName(newValue || ''); + }} + onInputChange={(event, newInputValue) => { + setSelectedName(newInputValue); + }} + loading={loadingEquipments} + disabled={loadingEquipments || saving} + componentsProps={{ + popper: { + placement: 'bottom-start', + modifiers: [ + { + name: 'flip', + enabled: false, + }, + { + name: 'preventOverflow', + enabled: true, + }, + ], + }, + }} + renderInput={(params) => ( + + )} + /> + { + if (!isExistingCombination) { + setSelectedEquipmentCode(e.target.value); + } + }} + disabled={isExistingCombination || loadingEquipments || saving} + placeholder={isExistingCombination ? "自動生成" : "輸入設備編號"} + sx={{ mt: 2 }} + required={!isExistingCombination} + /> + + + + + + + + + ); + }; + + export default EquipmentSearch; \ No newline at end of file diff --git a/src/components/EquipmentSearch/EquipmentSearchLoading.tsx b/src/components/EquipmentSearch/EquipmentSearchLoading.tsx index 838189b..100feb0 100644 --- a/src/components/EquipmentSearch/EquipmentSearchLoading.tsx +++ b/src/components/EquipmentSearch/EquipmentSearchLoading.tsx @@ -1,3 +1,5 @@ +"use client"; + import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import Skeleton from "@mui/material/Skeleton"; diff --git a/src/components/EquipmentSearch/EquipmentSearchResults.tsx b/src/components/EquipmentSearch/EquipmentSearchResults.tsx index 7f84a41..d35b83b 100644 --- a/src/components/EquipmentSearch/EquipmentSearchResults.tsx +++ b/src/components/EquipmentSearch/EquipmentSearchResults.tsx @@ -48,6 +48,7 @@ interface BaseColumn { style?: Partial & { [propName: string]: string }; type?: ColumnType; renderCell?: (params: T) => React.ReactNode; + renderHeader?: () => React.ReactNode; } interface IconColumn extends BaseColumn { @@ -104,6 +105,8 @@ interface Props { checkboxIds?: (string | number)[]; setCheckboxIds?: Dispatch>; onRowClick?: (item: T) => void; + renderExpandedRow?: (item: T) => React.ReactNode; + hideHeader?: boolean; } function isActionColumn( @@ -197,6 +200,8 @@ function EquipmentSearchResults({ checkboxIds = [], setCheckboxIds = undefined, onRowClick = undefined, + renderExpandedRow = undefined, + hideHeader = false, }: Props) { const { t } = useTranslation("common"); const [page, setPage] = React.useState(0); @@ -303,35 +308,41 @@ function EquipmentSearchResults({ const table = ( <> - - - - {columns.map((column, idx) => ( - isCheckboxColumn(column) ? - - 0 && currItemsWithChecked.length < currItems.length} - checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length} - onChange={handleSelectAllClick} - /> - - : - {column.label.split('\n').map((line, index) => ( -
{line}
// Render each line in a div - ))} -
- ))} -
-
+
+ {!hideHeader && ( + + + {columns.map((column, idx) => ( + isCheckboxColumn(column) ? + + 0 && currItemsWithChecked.length < currItems.length} + checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length} + onChange={handleSelectAllClick} + /> + + : + {column.renderHeader ? ( + column.renderHeader() + ) : ( + column.label.split('\n').map((line, index) => ( +
{line}
// Render each line in a div + )) + )} +
+ ))} +
+
+ )} {isAutoPaging ? items @@ -339,10 +350,45 @@ function EquipmentSearchResults({ (pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage) + (pagingController?.pageSize ?? rowsPerPage)) .map((item) => { return ( - + { + setCheckboxIds + ? handleRowClick(event, item, columns) + : undefined + + if (onRowClick) { + onRowClick(item) + } + } + } + role={setCheckboxIds ? "checkbox" : undefined} + > + {columns.map((column, idx) => { + const columnName = column.name; + + return ( + + ); + })} + + {renderExpandedRow && renderExpandedRow(item)} + + ); + }) + : items.map((item) => { + return ( + + { setCheckboxIds ? handleRowClick(event, item, columns) @@ -370,38 +416,8 @@ function EquipmentSearchResults({ ); })} - ); - }) - : items.map((item) => { - return ( - { - setCheckboxIds - ? handleRowClick(event, item, columns) - : undefined - - if (onRowClick) { - onRowClick(item) - } - } - } - role={setCheckboxIds ? "checkbox" : undefined} - > - {columns.map((column, idx) => { - const columnName = column.name; - - return ( - - ); - })} - + {renderExpandedRow && renderExpandedRow(item)} + ); })} diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index f4722ae..0387d8a 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -306,7 +306,7 @@ function SearchBox({ {t("All")} {c.options.map((option) => ( diff --git a/src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx b/src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx index 87d5df1..212a28e 100644 --- a/src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx +++ b/src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx @@ -1,84 +1,102 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; import SearchBox, { Criterion } from "../SearchBox"; -import { EquipmentResult } from "@/app/api/settings/equipment"; +import { useCallback, useMemo, useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import SearchResults, { Column } from "../SearchResults"; -import { EditNote } from "@mui/icons-material"; import { useRouter } from "next/navigation"; -import { GridDeleteIcon } from "@mui/x-data-grid"; +import { successDialog } from "../Swal/CustomAlerts"; +import useUploadContext from "../UploadProvider/useUploadContext"; +import { downloadFile } from "@/app/utils/commonUtil"; +import { EquipmentDetailResult } from "@/app/api/settings/equipmentDetail"; +import { exportEquipmentQrCode } from "@/app/api/settings/equipmentDetail/client"; +import { + Checkbox, + Box, + Button, + TextField, + Stack, + Autocomplete, + Modal, + Card, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Typography +} from "@mui/material"; +import DownloadIcon from "@mui/icons-material/Download"; +import PrintIcon from "@mui/icons-material/Print"; +import CloseIcon from "@mui/icons-material/Close"; +import { PrinterCombo } from "@/app/api/settings/printer"; import axiosInstance from "@/app/(main)/axios/axiosInstance"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; -type Props = { - equipments: EquipmentResult[]; -}; +interface Props { + equipmentDetails: EquipmentDetailResult[]; + printerCombo: PrinterCombo[]; +} -type SearchQuery = Partial>; +type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; -const QrCodeHandleEquipmentSearch: React.FC = ({ equipments }) => { - const [filteredEquipments, setFilteredEquipments] = - useState([]); +const QrCodeHandleEquipmentSearch: React.FC = ({ equipmentDetails, printerCombo }) => { const { t } = useTranslation("common"); + const [filteredEquipmentDetails, setFilteredEquipmentDetails] = useState([]); const router = useRouter(); - const [filterObj, setFilterObj] = useState({}); + const { setIsUploading } = useUploadContext(); const [pagingController, setPagingController] = useState({ pageNum: 1, pageSize: 10, }); + const [filterObj, setFilterObj] = useState({}); const [totalCount, setTotalCount] = useState(0); - - const searchCriteria: Criterion[] = useMemo(() => { - const searchCriteria: Criterion[] = [ - { label: t("Code"), paramName: "code", type: "text" }, - { label: t("Description"), paramName: "description", type: "text" }, - ]; - return searchCriteria; - }, [t, equipments]); - - const onDetailClick = useCallback( - (equipment: EquipmentResult) => { - router.push(`/settings/equipment/edit?id=${equipment.id}`); - }, - [router], - ); - const onDeleteClick = useCallback( - (equipment: EquipmentResult) => {}, - [router], + const [checkboxIds, setCheckboxIds] = useState<(string | number)[]>([]); + const [selectedEquipmentDetailsMap, setSelectedEquipmentDetailsMap] = useState>(new Map()); + const [selectAll, setSelectAll] = useState(false); + const [printQty, setPrintQty] = useState(1); + const [isSearching, setIsSearching] = useState(false); + + const [previewOpen, setPreviewOpen] = useState(false); + const [previewUrl, setPreviewUrl] = useState(null); + + const [selectedEquipmentDetailsModalOpen, setSelectedEquipmentDetailsModalOpen] = useState(false); + + const filteredPrinters = useMemo(() => { + return printerCombo.filter((printer) => { + return printer.type === "A4"; + }); + }, [printerCombo]); + + const [selectedPrinter, setSelectedPrinter] = useState( + filteredPrinters.length > 0 ? filteredPrinters[0] : undefined ); - const columns = useMemo[]>( + useEffect(() => { + if (!selectedPrinter || !filteredPrinters.find(p => p.id === selectedPrinter.id)) { + setSelectedPrinter(filteredPrinters.length > 0 ? filteredPrinters[0] : undefined); + } + }, [filteredPrinters, selectedPrinter]); + + const searchCriteria: Criterion[] = useMemo( () => [ { - name: "id", - label: t("Details"), - onClick: onDetailClick, - buttonIcon: , - }, - { - name: "code", - label: t("Code"), - }, - { - name: "equipmentTypeId", - label: t("Equipment Type"), - sx: {minWidth: 180}, - }, - { - name: "description", - label: t("Description"), + label: "設備名稱", + paramName: "code", + type: "text", }, { - name: "action", - label: t(""), - buttonIcon: , - onClick: onDeleteClick, + label: "設備編號", + paramName: "equipmentCode", + type: "text", }, ], - [filteredEquipments], + [], ); interface ApiResponse { @@ -101,20 +119,19 @@ const QrCodeHandleEquipmentSearch: React.FC = ({ equipments }) => { ...filterObj, }; try { - const response = await axiosInstance.get>( - `${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`, + const response = await axiosInstance.get>( + `${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`, { params }, ); - console.log(response); if (response.status == 200) { - setFilteredEquipments(response.data.records); + setFilteredEquipmentDetails(response.data.records); setTotalCount(response.data.total); return response; } else { throw "400"; } } catch (error) { - console.error("Error fetching equipment types:", error); + console.error("Error fetching equipment details:", error); throw error; } }, @@ -125,6 +142,228 @@ const QrCodeHandleEquipmentSearch: React.FC = ({ equipments }) => { refetchData(filterObj, pagingController.pageNum, pagingController.pageSize); }, [filterObj, pagingController.pageNum, pagingController.pageSize]); + useEffect(() => { + if (filteredEquipmentDetails.length > 0) { + const allCurrentPageSelected = filteredEquipmentDetails.every(ed => checkboxIds.includes(ed.id)); + setSelectAll(allCurrentPageSelected); + } else { + setSelectAll(false); + } + }, [filteredEquipmentDetails, checkboxIds]); + + const handleSelectEquipmentDetail = useCallback((equipmentDetailId: string | number, checked: boolean) => { + if (checked) { + const equipmentDetail = filteredEquipmentDetails.find(ed => ed.id === equipmentDetailId); + if (equipmentDetail) { + setCheckboxIds(prev => [...prev, equipmentDetailId]); + setSelectedEquipmentDetailsMap(prev => { + const newMap = new Map(prev); + newMap.set(equipmentDetailId, equipmentDetail); + return newMap; + }); + } + } else { + setCheckboxIds(prev => prev.filter(id => id !== equipmentDetailId)); + setSelectedEquipmentDetailsMap(prev => { + const newMap = new Map(prev); + newMap.delete(equipmentDetailId); + return newMap; + }); + setSelectAll(false); + } + }, [filteredEquipmentDetails]); + + const fetchAllMatchingEquipmentDetails = useCallback(async (): Promise => { + const authHeader = axiosInstance.defaults.headers["Authorization"]; + if (!authHeader) { + return []; + } + + if (totalCount === 0) { + return []; + } + + const params = { + pageNum: 1, + pageSize: totalCount > 0 ? totalCount : 10000, + ...filterObj, + }; + try { + const response = await axiosInstance.get>( + `${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`, + { params }, + ); + if (response.status == 200) { + return response.data.records; + } + return []; + } catch (error) { + console.error("Error fetching all equipment details:", error); + return []; + } + }, [filterObj, totalCount]); + + const handleSelectAll = useCallback(async (checked: boolean) => { + if (checked) { + try { + const allEquipmentDetails = await fetchAllMatchingEquipmentDetails(); + const allIds = allEquipmentDetails.map(equipmentDetail => equipmentDetail.id); + + setCheckboxIds(allIds); + setSelectedEquipmentDetailsMap(prev => { + const newMap = new Map(prev); + allEquipmentDetails.forEach(equipmentDetail => { + newMap.set(equipmentDetail.id, equipmentDetail); + }); + return newMap; + }); + setSelectAll(true); + } catch (error) { + console.error("Error selecting all equipment:", error); + } + } else { + setCheckboxIds([]); + setSelectedEquipmentDetailsMap(new Map()); + setSelectAll(false); + } + }, [fetchAllMatchingEquipmentDetails]); + + const showPdfPreview = useCallback(async (equipmentDetailIds: (string | number)[]) => { + if (equipmentDetailIds.length === 0) { + return; + } + try { + setIsUploading(true); + const numericIds = equipmentDetailIds.map(id => typeof id === 'string' ? parseInt(id) : id); + const response = await exportEquipmentQrCode(numericIds); + + const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + + setPreviewUrl(`${url}#toolbar=0`); + setPreviewOpen(true); + } catch (error) { + console.error("Error exporting QR code:", error); + } finally { + setIsUploading(false); + } + }, [setIsUploading]); + + const handleClosePreview = useCallback(() => { + setPreviewOpen(false); + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + setPreviewUrl(null); + } + }, [previewUrl]); + + const handleDownloadQrCode = useCallback(async (equipmentDetailIds: (string | number)[]) => { + if (equipmentDetailIds.length === 0) { + return; + } + try { + setIsUploading(true); + const numericIds = equipmentDetailIds.map(id => typeof id === 'string' ? parseInt(id) : id); + const response = await exportEquipmentQrCode(numericIds); + downloadFile(response.blobValue, response.filename); + setSelectedEquipmentDetailsModalOpen(false); + successDialog("二維碼已下載", t); + } catch (error) { + console.error("Error exporting QR code:", error); + } finally { + setIsUploading(false); + } + }, [setIsUploading, t]); + + const handlePrint = useCallback(async () => { + if (checkboxIds.length === 0) { + return; + } + try { + setIsUploading(true); + const numericIds = checkboxIds.map(id => typeof id === 'string' ? parseInt(id) : id); + const response = await exportEquipmentQrCode(numericIds); + + const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + + const printWindow = window.open(url, '_blank'); + if (printWindow) { + printWindow.onload = () => { + for (let i = 0; i < printQty; i++) { + setTimeout(() => { + printWindow.print(); + }, i * 500); + } + }; + } + + setTimeout(() => { + URL.revokeObjectURL(url); + }, 1000); + setSelectedEquipmentDetailsModalOpen(false); + successDialog("二維碼已列印", t); + } catch (error) { + console.error("Error printing QR code:", error); + } finally { + setIsUploading(false); + } + }, [checkboxIds, printQty, setIsUploading, t]); + + const handleViewSelectedQrCodes = useCallback(() => { + if (checkboxIds.length === 0) { + return; + } + setSelectedEquipmentDetailsModalOpen(true); + }, [checkboxIds]); + + const selectedEquipmentDetails = useMemo(() => { + return Array.from(selectedEquipmentDetailsMap.values()); + }, [selectedEquipmentDetailsMap]); + + const handleCloseSelectedEquipmentDetailsModal = useCallback(() => { + setSelectedEquipmentDetailsModalOpen(false); + }, []); + + const columns = useMemo[]>( + () => [ + { + name: "id", + label: "", + sx: { width: "50px", minWidth: "50px" }, + renderCell: (params) => ( + handleSelectEquipmentDetail(params.id, e.target.checked)} + onClick={(e) => e.stopPropagation()} + /> + ), + }, + { + name: "code", + label: "設備名稱", + align: "left", + headerAlign: "left", + sx: { width: "150px", minWidth: "150px" }, + }, + { + name: "description", + label: "設備描述", + align: "left", + headerAlign: "left", + sx: { width: "200px", minWidth: "200px" }, + }, + { + name: "equipmentCode", + label: "設備編號", + align: "left", + headerAlign: "left", + sx: { width: "150px", minWidth: "150px" }, + }, + ], + [t, checkboxIds, handleSelectEquipmentDetail], + ); + const onReset = useCallback(() => { setFilterObj({}); setPagingController({ pageNum: 1, pageSize: 10 }); @@ -138,19 +377,238 @@ const QrCodeHandleEquipmentSearch: React.FC = ({ equipments }) => { setFilterObj({ ...query, }); + setPagingController({ pageNum: 1, pageSize: 10 }); }} onReset={onReset} /> - - items={filteredEquipments} + + items={filteredEquipmentDetails} columns={columns} - setPagingController={setPagingController} pagingController={pagingController} + setPagingController={setPagingController} totalCount={totalCount} isAutoPaging={false} /> + + + + + + + + + + 已選擇設備 ({selectedEquipmentDetails.length}) + + + + + + + + +
+ + + + 設備名稱 + + + 設備描述 + + + 設備編號 + + + + + {selectedEquipmentDetails.length === 0 ? ( + + + 沒有選擇的設備 + + + ) : ( + selectedEquipmentDetails.map((equipmentDetail) => ( + + {equipmentDetail.code || '-'} + {equipmentDetail.description || '-'} + {equipmentDetail.equipmentCode || '-'} + + )) + )} + +
+
+
+ + + + + options={filteredPrinters} + value={selectedPrinter ?? null} + onChange={(event, value) => { + setSelectedPrinter(value ?? undefined); + }} + getOptionLabel={(option) => option.name || option.label || option.code || String(option.id)} + renderInput={(params) => ( + + )} + /> + { + const value = parseInt(e.target.value) || 1; + setPrintQty(Math.max(1, value)); + }} + inputProps={{ min: 1 }} + sx={{ width: 120 }} + /> + + + + + + + + + + + + + + + + + {previewUrl && ( +