diff --git a/backend-node/src/services/YardLayoutService.ts b/backend-node/src/services/YardLayoutService.ts index 64572739..609e3d1c 100644 --- a/backend-node/src/services/YardLayoutService.ts +++ b/backend-node/src/services/YardLayoutService.ts @@ -153,18 +153,35 @@ export class YardLayoutService { `; const pool = getPool(); + + // NaN 방지를 위한 안전한 변환 함수 + const safeParseInt = ( + value: any, + defaultValue: number | null = null + ): number | null => { + if (!value && value !== 0) return defaultValue; + const parsed = parseInt(String(value), 10); + return isNaN(parsed) ? defaultValue : parsed; + }; + + const safeParseFloat = (value: any, defaultValue: number): number => { + if (!value && value !== 0) return defaultValue; + const parsed = parseFloat(String(value)); + return isNaN(parsed) ? defaultValue : parsed; + }; + const result = await pool.query(query, [ layoutId, data.material_code || null, data.material_name || null, - data.quantity || null, + safeParseInt(data.quantity, null), data.unit || null, - data.position_x || 0, - data.position_y || 0, - data.position_z || 0, - data.size_x || 5, - data.size_y || 5, - data.size_z || 5, + safeParseFloat(data.position_x, 0), + safeParseFloat(data.position_y, 0), + safeParseFloat(data.position_z, 0), + safeParseFloat(data.size_x, 5), + safeParseFloat(data.size_y, 5), + safeParseFloat(data.size_z, 5), data.color || "#9ca3af", // 미설정 시 회색 data.data_source_type || null, data.data_source_config ? JSON.stringify(data.data_source_config) : null, @@ -201,17 +218,31 @@ export class YardLayoutService { `; const pool = getPool(); + + // NaN 방지를 위한 안전한 변환 함수 + const safeParseInt = (value: any): number | null => { + if (value === null || value === undefined) return null; + const parsed = parseInt(String(value), 10); + return isNaN(parsed) ? null : parsed; + }; + + const safeParseFloat = (value: any): number | null => { + if (value === null || value === undefined) return null; + const parsed = parseFloat(String(value)); + return isNaN(parsed) ? null : parsed; + }; + const result = await pool.query(query, [ data.material_code !== undefined ? data.material_code : null, data.material_name !== undefined ? data.material_name : null, - data.quantity !== undefined ? data.quantity : null, + data.quantity !== undefined ? safeParseInt(data.quantity) : null, data.unit !== undefined ? data.unit : null, - data.position_x !== undefined ? data.position_x : null, - data.position_y !== undefined ? data.position_y : null, - data.position_z !== undefined ? data.position_z : null, - data.size_x !== undefined ? data.size_x : null, - data.size_y !== undefined ? data.size_y : null, - data.size_z !== undefined ? data.size_z : null, + data.position_x !== undefined ? safeParseFloat(data.position_x) : null, + data.position_y !== undefined ? safeParseFloat(data.position_y) : null, + data.position_z !== undefined ? safeParseFloat(data.position_z) : null, + data.size_x !== undefined ? safeParseFloat(data.size_x) : null, + data.size_y !== undefined ? safeParseFloat(data.size_y) : null, + data.size_z !== undefined ? safeParseFloat(data.size_z) : null, data.color !== undefined ? data.color : null, data.data_source_type !== undefined ? data.data_source_type : null, data.data_source_config !== undefined diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 27115ee1..e0b5778f 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -6,6 +6,7 @@ import { DashboardCanvas } from "./DashboardCanvas"; import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigModal } from "./ElementConfigModal"; import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; +import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal"; import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; @@ -495,6 +496,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D onClose={closeConfigModal} onSave={saveListWidgetConfig} /> + ) : configModalElement.type === "widget" && configModalElement.subtype === "yard-management-3d" ? ( + ) : ( void; + onSave: (updates: Partial) => void; +} + +export function YardWidgetConfigModal({ element, isOpen, onClose, onSave }: YardWidgetConfigModalProps) { + const [customTitle, setCustomTitle] = useState(element.customTitle || ""); + const [showHeader, setShowHeader] = useState(element.showHeader !== false); + + useEffect(() => { + if (isOpen) { + setCustomTitle(element.customTitle || ""); + setShowHeader(element.showHeader !== false); + } + }, [isOpen, element]); + + const handleSave = () => { + onSave({ + customTitle, + showHeader, + }); + onClose(); + }; + + return ( + + e.stopPropagation()} className="sm:max-w-[500px]"> + + 야드 관리 위젯 설정 + + + + {/* 위젯 제목 */} + + 위젯 제목 + setCustomTitle(e.target.value)} + placeholder="제목을 입력하세요 (비워두면 기본 제목 사용)" + /> + 기본 제목: 야드 관리 3D + + + {/* 헤더 표시 여부 */} + + setShowHeader(checked === true)} + /> + + 헤더 표시 + + + + + + + 취소 + + 저장 + + + + ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 2841c17d..346d59f5 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -7,7 +7,8 @@ import { yardLayoutApi } from "@/lib/api/yardLayoutApi"; import dynamic from "next/dynamic"; import { YardLayout, YardPlacement } from "./types"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, CheckCircle } from "lucide-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, @@ -31,11 +32,19 @@ interface YardEditorProps { export default function YardEditor({ layout, onBack }: YardEditorProps) { const [placements, setPlacements] = useState([]); + const [originalPlacements, setOriginalPlacements] = useState([]); // 원본 데이터 보관 const [selectedPlacement, setSelectedPlacement] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [showConfigPanel, setShowConfigPanel] = useState(false); const [error, setError] = useState(null); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); // 미저장 변경사항 추적 + const [nextPlacementId, setNextPlacementId] = useState(-1); // 임시 ID (음수 사용) + const [saveResultDialog, setSaveResultDialog] = useState<{ + open: boolean; + success: boolean; + message: string; + }>({ open: false, success: false, message: "" }); // 배치 목록 로드 useEffect(() => { @@ -44,7 +53,9 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setIsLoading(true); const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id); if (response.success) { - setPlacements(response.data as YardPlacement[]); + const loadedData = response.data as YardPlacement[]; + setPlacements(loadedData); + setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); // 깊은 복사 } } catch (error) { console.error("배치 목록 로드 실패:", error); @@ -57,36 +68,34 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { loadPlacements(); }, [layout.id]); - // 빈 요소 추가 - const handleAddElement = async () => { - try { - const newPlacementData = { - position_x: 0, - position_y: 0, - position_z: 0, - size_x: 5, - size_y: 5, - size_z: 5, - color: "#9ca3af", // 회색 (미설정 상태) - }; + // 빈 요소 추가 (로컬 상태에만 추가, 저장 시 서버에 반영) + const handleAddElement = () => { + const newPlacement: YardPlacement = { + id: nextPlacementId, // 임시 음수 ID + yard_layout_id: layout.id, + material_code: null, + material_name: null, + quantity: null, + unit: null, + position_x: 0, + position_y: 2.5, + position_z: 0, + size_x: 5, + size_y: 5, + size_z: 5, + color: "#9ca3af", + data_source_type: null, + data_source_config: null, + data_binding: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; - console.log("요소 추가 요청:", { layoutId: layout.id, data: newPlacementData }); - const response = await yardLayoutApi.addMaterialPlacement(layout.id, newPlacementData); - console.log("요소 추가 응답:", response); - - if (response.success) { - const newPlacement = response.data as YardPlacement; - setPlacements((prev) => [...prev, newPlacement]); - setSelectedPlacement(newPlacement); - setShowConfigPanel(true); // 자동으로 설정 패널 표시 - } else { - console.error("요소 추가 실패 (응답):", response); - setError(response.message || "요소 추가에 실패했습니다."); - } - } catch (error) { - console.error("요소 추가 실패 (예외):", error); - setError(`요소 추가에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`); - } + setPlacements((prev) => [...prev, newPlacement]); + setSelectedPlacement(newPlacement); + setShowConfigPanel(true); + setHasUnsavedChanges(true); + setNextPlacementId((prev) => prev - 1); // 다음 임시 ID }; // 요소 선택 (3D 캔버스 또는 목록에서) @@ -101,28 +110,21 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setShowConfigPanel(true); }; - // 요소 삭제 - const handleDeletePlacement = async (placementId: number) => { + // 요소 삭제 (로컬 상태에서만 삭제, 저장 시 서버에 반영) + const handleDeletePlacement = (placementId: number) => { if (!confirm("이 요소를 삭제하시겠습니까?")) { return; } - try { - const response = await yardLayoutApi.removePlacement(placementId); - if (response.success) { - setPlacements((prev) => prev.filter((p) => p.id !== placementId)); - if (selectedPlacement?.id === placementId) { - setSelectedPlacement(null); - setShowConfigPanel(false); - } - } - } catch (error) { - console.error("요소 삭제 실패:", error); - setError("요소 삭제에 실패했습니다."); + setPlacements((prev) => prev.filter((p) => p.id !== placementId)); + if (selectedPlacement?.id === placementId) { + setSelectedPlacement(null); + setShowConfigPanel(false); } + setHasUnsavedChanges(true); }; - // 자재 드래그 (3D 캔버스에서) + // 자재 드래그 (3D 캔버스에서, 로컬 상태에만 반영) const handlePlacementDrag = (id: number, position: { x: number; y: number; z: number }) => { const updatedPosition = { position_x: Math.round(position.x * 2) / 2, @@ -135,76 +137,119 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { if (selectedPlacement?.id === id) { setSelectedPlacement((prev) => (prev ? { ...prev, ...updatedPosition } : null)); } + + setHasUnsavedChanges(true); }; - // 저장 + // 전체 저장 (신규/수정/삭제 모두 처리) const handleSave = async () => { setIsSaving(true); try { - const response = await yardLayoutApi.batchUpdatePlacements( - layout.id, - placements.map((p) => ({ - id: p.id, - position_x: p.position_x, - position_y: p.position_y, - position_z: p.position_z, - size_x: p.size_x, - size_y: p.size_y, - size_z: p.size_z, - color: p.color, - })), - ); + // 1. 삭제된 요소 처리 (원본에는 있지만 현재 state에 없는 경우) + const deletedIds = originalPlacements + .filter((orig) => !placements.find((p) => p.id === orig.id)) + .map((p) => p.id); + for (const id of deletedIds) { + await yardLayoutApi.removePlacement(id); + } + + // 2. 신규 추가 요소 처리 (ID가 음수인 경우) + const newPlacements = placements.filter((p) => p.id < 0); + const addedPlacements: YardPlacement[] = []; + + for (const newPlacement of newPlacements) { + const response = await yardLayoutApi.addMaterialPlacement(layout.id, { + material_code: newPlacement.material_code, + material_name: newPlacement.material_name, + quantity: newPlacement.quantity, + unit: newPlacement.unit, + position_x: newPlacement.position_x, + position_y: newPlacement.position_y, + position_z: newPlacement.position_z, + size_x: newPlacement.size_x, + size_y: newPlacement.size_y, + size_z: newPlacement.size_z, + color: newPlacement.color, + data_source_type: newPlacement.data_source_type, + data_source_config: newPlacement.data_source_config, + data_binding: newPlacement.data_binding, + memo: newPlacement.memo, + }); + + if (response.success) { + addedPlacements.push(response.data as YardPlacement); + } + } + + // 3. 기존 요소 수정 처리 (ID가 양수인 경우) + const existingPlacements = placements.filter((p) => p.id > 0); + + for (const placement of existingPlacements) { + await yardLayoutApi.updatePlacement(placement.id, { + material_code: placement.material_code, + material_name: placement.material_name, + quantity: placement.quantity, + unit: placement.unit, + position_x: placement.position_x, + position_y: placement.position_y, + position_z: placement.position_z, + size_x: placement.size_x, + size_y: placement.size_y, + size_z: placement.size_z, + color: placement.color, + data_source_type: placement.data_source_type, + data_source_config: placement.data_source_config, + data_binding: placement.data_binding, + memo: placement.memo, + }); + } + + // 4. 저장 성공 후 데이터 다시 로드 + const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id); if (response.success) { - alert("저장되었습니다"); + const loadedData = response.data as YardPlacement[]; + setPlacements(loadedData); + setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); + setHasUnsavedChanges(false); + setSelectedPlacement(null); + setShowConfigPanel(false); + setSaveResultDialog({ + open: true, + success: true, + message: "모든 변경사항이 성공적으로 저장되었습니다.", + }); } } catch (error) { console.error("저장 실패:", error); - alert("저장에 실패했습니다"); + setSaveResultDialog({ + open: true, + success: false, + message: `저장에 실패했습니다: ${error instanceof Error ? error.message : "알 수 없는 오류"}`, + }); } finally { setIsSaving(false); } }; - // 설정 패널에서 저장 - const handleSaveConfig = async (updatedData: Partial) => { + // 설정 패널에서 데이터 업데이트 (로컬 상태에만 반영, 서버 저장 안함) + const handleSaveConfig = (updatedData: Partial) => { if (!selectedPlacement) return; - try { - const response = await yardLayoutApi.updatePlacement(selectedPlacement.id, updatedData); - if (response.success) { - const updated = response.data as YardPlacement; + // 로컬 상태만 업데이트 + setPlacements((prev) => + prev.map((p) => { + if (p.id === selectedPlacement.id) { + return { ...p, ...updatedData }; + } + return p; + }), + ); - // 현재 위치 정보를 유지하면서 업데이트 - setPlacements((prev) => - prev.map((p) => { - if (p.id === updated.id) { - // 현재 프론트엔드 상태의 위치를 유지 - return { - ...updated, - position_x: p.position_x, - position_y: p.position_y, - position_z: p.position_z, - }; - } - return p; - }), - ); - - // 선택된 요소도 동일하게 업데이트 - setSelectedPlacement({ - ...updated, - position_x: selectedPlacement.position_x, - position_y: selectedPlacement.position_y, - position_z: selectedPlacement.position_z, - }); - - setShowConfigPanel(false); - } - } catch (error) { - console.error("설정 저장 실패:", error); - setError("설정 저장에 실패했습니다."); - } + // 선택된 요소도 업데이트 + setSelectedPlacement((prev) => (prev ? { ...prev, ...updatedData } : null)); + setShowConfigPanel(false); + setHasUnsavedChanges(true); }; // 요소가 설정되었는지 확인 @@ -227,19 +272,22 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { - - {isSaving ? ( - <> - - 저장 중... - > - ) : ( - <> - - 저장 - > - )} - + + {hasUnsavedChanges && 미저장 변경사항 있음} + + {isSaving ? ( + <> + + 저장 중... + > + ) : ( + <> + + 저장 + > + )} + + {/* 에러 메시지 */} @@ -369,6 +417,31 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { )} + + {/* 저장 결과 Dialog */} + setSaveResultDialog((prev) => ({ ...prev, open }))}> + e.stopPropagation()}> + + + {saveResultDialog.success ? ( + <> + + 저장 완료 + > + ) : ( + <> + + 저장 실패 + > + )} + + {saveResultDialog.message} + + + setSaveResultDialog((prev) => ({ ...prev, open: false }))}>확인 + + + ); } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx index 27c0860f..efd86e6c 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx @@ -18,7 +18,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; interface YardElementConfigPanelProps { placement: YardPlacement; - onSave: (updatedData: Partial) => Promise; + onSave: (updatedData: Partial) => void; // Promise 제거 (즉시 로컬 상태 업데이트) onCancel: () => void; } @@ -52,9 +52,8 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }: const [sizeY, setSizeY] = useState(placement.size_y); const [sizeZ, setSizeZ] = useState(placement.size_z); - // 에러 및 로딩 + // 에러 const [error, setError] = useState(null); - const [isSaving, setIsSaving] = useState(false); // 외부 DB 커넥션 목록 로드 useEffect(() => { @@ -180,8 +179,8 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }: } }; - // 저장 - const handleSave = async () => { + // 적용 (로컬 상태만 업데이트, 서버 저장은 나중에 일괄 처리) + const handleApply = () => { // 검증 if (!queryResult) { setError("먼저 데이터를 조회해주세요."); @@ -203,49 +202,40 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }: return; } - setIsSaving(true); + const selectedRow = queryResult.rows[selectedRowIndex]; + const materialName = selectedRow[materialNameField]; + const quantity = selectedRow[quantityField]; - try { - const selectedRow = queryResult.rows[selectedRowIndex]; - const materialName = selectedRow[materialNameField]; - const quantity = selectedRow[quantityField]; + const dataSourceConfig: YardDataSourceConfig = { + type: dataSourceType, + query: dataSourceType !== "rest_api" ? query : undefined, + connectionId: dataSourceType === "external_db" ? parseInt(externalConnectionId) : undefined, + url: dataSourceType === "rest_api" ? apiUrl : undefined, + method: dataSourceType === "rest_api" ? apiMethod : undefined, + dataPath: dataSourceType === "rest_api" && apiDataPath ? apiDataPath : undefined, + }; - const dataSourceConfig: YardDataSourceConfig = { - type: dataSourceType, - query: dataSourceType !== "rest_api" ? query : undefined, - connectionId: dataSourceType === "external_db" ? parseInt(externalConnectionId) : undefined, - url: dataSourceType === "rest_api" ? apiUrl : undefined, - method: dataSourceType === "rest_api" ? apiMethod : undefined, - dataPath: dataSourceType === "rest_api" && apiDataPath ? apiDataPath : undefined, - }; + const dataBinding: YardDataBinding = { + selectedRowIndex, + materialNameField, + quantityField, + unit: unit.trim(), + }; - const dataBinding: YardDataBinding = { - selectedRowIndex, - materialNameField, - quantityField, - unit: unit.trim(), - }; + const updatedData: Partial = { + material_name: String(materialName), + quantity: Number(quantity), + unit: unit.trim(), + color, + size_x: sizeX, + size_y: sizeY, + size_z: sizeZ, + data_source_type: dataSourceType, + data_source_config: dataSourceConfig, + data_binding: dataBinding, + }; - const updatedData: Partial = { - material_name: String(materialName), - quantity: Number(quantity), - unit: unit.trim(), - color, - size_x: sizeX, - size_y: sizeY, - size_z: sizeZ, - data_source_type: dataSourceType, - data_source_config: dataSourceConfig, - data_binding: dataBinding, - }; - - await onSave(updatedData); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : "저장 중 오류가 발생했습니다."; - setError(errorMessage); - } finally { - setIsSaving(false); - } + onSave(updatedData); // 동기적으로 즉시 로컬 상태 업데이트 }; return ( @@ -537,15 +527,8 @@ export default function YardElementConfigPanel({ placement, onSave, onCancel }: 취소 - - {isSaving ? ( - <> - - 저장 중... - > - ) : ( - "저장" - )} + + 적용
기본 제목: 야드 관리 3D