"use client"; import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { ArrowLeft, Save, Loader2, Plus, Settings, Trash2, Edit2 } from "lucide-react"; 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, CheckCircle, XCircle } from "lucide-react"; import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, ResizableDialogDescription } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useToast } from "@/hooks/use-toast"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, loading: () => (
), }); // 나중에 구현할 데이터 바인딩 패널 const YardElementConfigPanel = dynamic(() => import("./YardElementConfigPanel"), { ssr: false, loading: () =>
로딩 중...
, }); interface YardEditorProps { layout: YardLayout; onBack: () => void; } export default function YardEditor({ layout, onBack }: YardEditorProps) { const { toast } = useToast(); const [placements, setPlacements] = useState([]); const [originalPlacements, setOriginalPlacements] = useState([]); // 원본 데이터 보관 const [selectedPlacement, setSelectedPlacement] = useState(null); const [focusPlacementId, setFocusPlacementId] = useState(null); // 카메라 포커스할 요소 ID 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: "" }); const [deleteConfirmDialog, setDeleteConfirmDialog] = useState<{ open: boolean; placementId: number | null; }>({ open: false, placementId: null }); const [editLayoutDialog, setEditLayoutDialog] = useState<{ open: boolean; name: string; }>({ open: false, name: "" }); // 배치 목록 로드 useEffect(() => { const loadPlacements = async () => { try { setIsLoading(true); const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id); if (response.success) { const loadedData = (response.data as YardPlacement[]).map((p) => ({ ...p, // 문자열로 저장된 숫자 필드를 숫자로 변환 position_x: Number(p.position_x), position_y: Number(p.position_y), position_z: Number(p.position_z), size_x: Number(p.size_x), size_y: Number(p.size_y), size_z: Number(p.size_z), quantity: p.quantity !== null && p.quantity !== undefined ? Number(p.quantity) : null, })); setPlacements(loadedData); setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); // 깊은 복사 } } catch (error) { console.error("배치 목록 로드 실패:", error); setError("배치 목록을 불러올 수 없습니다."); } finally { setIsLoading(false); } }; loadPlacements(); }, [layout.id]); // 빈 공간 찾기 (그리드 기반) const findEmptyGridPosition = (gridSize = 5) => { // 이미 사용 중인 좌표 Set const occupiedPositions = new Set( placements.map((p) => { const x = Math.round(p.position_x / gridSize) * gridSize; const z = Math.round(p.position_z / gridSize) * gridSize; return `${x},${z}`; }), ); // 나선형으로 빈 공간 찾기 let x = 0; let z = 0; let direction = 0; // 0: 우, 1: 하, 2: 좌, 3: 상 let steps = 1; let stepsTaken = 0; let stepsInDirection = 0; for (let i = 0; i < 1000; i++) { const key = `${x},${z}`; if (!occupiedPositions.has(key)) { return { x, z }; } // 다음 위치로 이동 stepsInDirection++; if (direction === 0) x += gridSize; // 우 else if (direction === 1) z += gridSize; // 하 else if (direction === 2) x -= gridSize; // 좌 else z -= gridSize; // 상 if (stepsInDirection >= steps) { stepsInDirection = 0; direction = (direction + 1) % 4; stepsTaken++; if (stepsTaken === 2) { stepsTaken = 0; steps++; } } } return { x: 0, z: 0 }; }; // 특정 XZ 위치에 배치할 때 적절한 Y 위치 계산 (마인크래프트 쌓기) const calculateYPosition = (x: number, z: number, existingPlacements: YardPlacement[]) => { const gridSize = 5; const halfSize = gridSize / 2; let maxY = halfSize; // 기본 바닥 높이 (2.5) for (const p of existingPlacements) { // XZ가 겹치는지 확인 const isXZOverlapping = Math.abs(x - p.position_x) < gridSize && Math.abs(z - p.position_z) < gridSize; if (isXZOverlapping) { // 이 요소의 윗면 높이 const topY = p.position_y + (p.size_y || gridSize) / 2; // 새 요소의 Y 위치 (윗면 + 새 요소 높이/2) const newY = topY + gridSize / 2; if (newY > maxY) { maxY = newY; } } } return maxY; }; // 빈 요소 추가 (로컬 상태에만 추가, 저장 시 서버에 반영) const handleAddElement = () => { const gridSize = 5; const emptyPos = findEmptyGridPosition(gridSize); const centerX = emptyPos.x + gridSize / 2; const centerZ = emptyPos.z + gridSize / 2; // 해당 위치에 적절한 Y 계산 (쌓기) const appropriateY = calculateYPosition(centerX, centerZ, placements); const newPlacement: YardPlacement = { id: nextPlacementId, // 임시 음수 ID yard_layout_id: layout.id, material_code: null, material_name: null, quantity: null, unit: null, // 그리드 칸의 중심에 배치 (Three.js Box position은 중심점) position_x: centerX, // 칸 중심: 0→2.5, 5→7.5, 10→12.5... position_y: appropriateY, // 쌓기 고려한 Y 위치 position_z: centerZ, // 칸 중심: 0→2.5, 5→7.5, 10→12.5... size_x: gridSize, size_y: gridSize, size_z: gridSize, color: "#9ca3af", data_source_type: null, data_source_config: null, data_binding: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; setPlacements((prev) => [...prev, newPlacement]); setSelectedPlacement(newPlacement); setShowConfigPanel(true); setHasUnsavedChanges(true); setNextPlacementId((prev) => prev - 1); // 다음 임시 ID }; // 요소 선택 (3D 캔버스 또는 목록에서) const handleSelectPlacement = (placement: YardPlacement | null) => { console.log("📍 handleSelectPlacement called with:", placement); if (!placement) { // 빈 공간 클릭 시 선택 해제 console.log(" → Deselecting (null placement)"); setSelectedPlacement(null); setShowConfigPanel(false); setFocusPlacementId(null); return; } console.log(" → Selecting placement:", placement.id, placement.material_name); setSelectedPlacement(placement); setShowConfigPanel(false); // 선택 시에는 설정 패널 닫기 console.log(" → Setting focusPlacementId to:", placement.id); setFocusPlacementId(placement.id); // 카메라 포커스 // 카메라 애니메이션 완료 후 focusPlacementId 초기화 (재클릭 시 다시 포커스 가능) setTimeout(() => { console.log(" → Clearing focusPlacementId"); setFocusPlacementId(null); }, 1100); // 애니메이션 시간(1000ms)보다 약간 길게 }; // 설정 버튼 클릭 const handleConfigClick = (placement: YardPlacement) => { setSelectedPlacement(placement); setShowConfigPanel(true); }; // 요소 삭제 확인 Dialog 열기 const handleDeletePlacement = (placementId: number) => { setDeleteConfirmDialog({ open: true, placementId }); }; // 중력 적용: 삭제된 요소 위에 있던 요소들을 아래로 내림 const applyGravity = (deletedPlacement: YardPlacement, remainingPlacements: YardPlacement[]) => { const gridSize = 5; const halfSize = gridSize / 2; return remainingPlacements.map((p) => { // 삭제된 요소와 XZ가 겹치는지 확인 const isXZOverlapping = Math.abs(p.position_x - deletedPlacement.position_x) < gridSize && Math.abs(p.position_z - deletedPlacement.position_z) < gridSize; // 삭제된 요소보다 위에 있는지 확인 const isAbove = p.position_y > deletedPlacement.position_y; if (isXZOverlapping && isAbove) { // 아래로 내림: 삭제된 요소의 크기만큼 const fallDistance = deletedPlacement.size_y || gridSize; const newY = Math.max(halfSize, p.position_y - fallDistance); // 바닥(2.5) 아래로는 안 내려감 return { ...p, position_y: newY, }; } return p; }); }; // 요소 삭제 확정 (로컬 상태에서만 삭제, 저장 시 서버에 반영) const confirmDeletePlacement = () => { const { placementId } = deleteConfirmDialog; if (placementId === null) return; setPlacements((prev) => { const deletedPlacement = prev.find((p) => p.id === placementId); if (!deletedPlacement) return prev; // 삭제 후 남은 요소들 const remaining = prev.filter((p) => p.id !== placementId); // 중력 적용 (재귀적으로 계속 적용) let result = remaining; let hasChanges = true; // 모든 요소가 안정될 때까지 반복 while (hasChanges) { const before = JSON.stringify(result.map((p) => p.position_y)); result = applyGravity(deletedPlacement, result); const after = JSON.stringify(result.map((p) => p.position_y)); hasChanges = before !== after; } return result; }); if (selectedPlacement?.id === placementId) { setSelectedPlacement(null); setShowConfigPanel(false); } setHasUnsavedChanges(true); setDeleteConfirmDialog({ open: false, placementId: null }); }; // 자재 드래그 (3D 캔버스에서, 로컬 상태에만 반영) const handlePlacementDrag = (id: number, position: { x: number; y: number; z: number }) => { const updatedPosition = { position_x: Math.round(position.x * 2) / 2, position_y: position.y, position_z: Math.round(position.z * 2) / 2, }; setPlacements((prev) => prev.map((p) => (p.id === id ? { ...p, ...updatedPosition } : p))); if (selectedPlacement?.id === id) { setSelectedPlacement((prev) => (prev ? { ...prev, ...updatedPosition } : null)); } setHasUnsavedChanges(true); }; // 전체 저장 (신규/수정/삭제 모두 처리) const handleSave = async () => { setIsSaving(true); try { // 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) { 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); setSaveResultDialog({ open: true, success: false, message: `저장에 실패했습니다: ${error instanceof Error ? error.message : "알 수 없는 오류"}`, }); } finally { setIsSaving(false); } }; // 설정 패널에서 데이터 업데이트 (로컬 상태에만 반영, 서버 저장 안함) const handleSaveConfig = (updatedData: Partial) => { if (!selectedPlacement) return; // 로컬 상태만 업데이트 setPlacements((prev) => prev.map((p) => { if (p.id === selectedPlacement.id) { return { ...p, ...updatedData }; } return p; }), ); // 선택된 요소도 업데이트 setSelectedPlacement((prev) => (prev ? { ...prev, ...updatedData } : null)); setShowConfigPanel(false); setHasUnsavedChanges(true); }; // 요소가 설정되었는지 확인 const isConfigured = (placement: YardPlacement): boolean => { return !!(placement.material_name && placement.quantity && placement.unit); }; // 레이아웃 편집 Dialog 열기 const handleEditLayout = () => { setEditLayoutDialog({ open: true, name: layout.name, }); }; // 레이아웃 정보 저장 const handleSaveLayoutInfo = async () => { try { const response = await yardLayoutApi.updateLayout(layout.id, { name: editLayoutDialog.name, }); if (response.success) { // 레이아웃 정보 업데이트 layout.name = editLayoutDialog.name; setEditLayoutDialog({ open: false, name: "" }); } } catch (error) { console.error("레이아웃 정보 수정 실패:", error); setError("레이아웃 정보 수정에 실패했습니다."); } }; return (
{/* 상단 툴바 */}

{layout.name}

{layout.description &&

{layout.description}

}
{hasUnsavedChanges && 미저장 변경사항 있음}
{/* 에러 메시지 */} {error && ( {error} )} {/* 메인 컨텐츠 영역 */}
{/* 좌측: 3D 캔버스 */}
{isLoading ? (
) : ( handleSelectPlacement(placement as YardPlacement | null)} onPlacementDrag={handlePlacementDrag} focusOnPlacementId={focusPlacementId} onCollisionDetected={() => { toast({ title: "배치 불가", description: "해당 위치에 이미 다른 요소가 있습니다.", variant: "destructive", }); }} /> )}
{/* 우측: 요소 목록 또는 설정 패널 */}
{showConfigPanel && selectedPlacement ? ( // 설정 패널 setShowConfigPanel(false)} /> ) : ( // 요소 목록

요소 목록

총 {placements.length}개

{placements.length === 0 ? (
요소가 없습니다.
{'위의 "요소 추가" 버튼을 클릭하세요.'}
) : (
{placements.map((placement) => { const configured = isConfigured(placement); const isSelected = selectedPlacement?.id === placement.id; return (
handleSelectPlacement(placement)} >
{configured ? ( <>
{placement.material_name}
수량: {placement.quantity} {placement.unit}
) : ( <>
요소 #{placement.id}
데이터 바인딩 설정 필요
)}
); })}
)}
)}
{/* 저장 결과 Dialog */} setSaveResultDialog((prev) => ({ ...prev, open }))}> e.stopPropagation()}> {saveResultDialog.success ? ( <> 저장 완료 ) : ( <> 저장 실패 )} {saveResultDialog.message}
{/* 삭제 확인 Dialog */} !open && setDeleteConfirmDialog({ open: false, placementId: null })} > e.stopPropagation()}> 요소 삭제 확인 이 요소를 삭제하시겠습니까?
저장 버튼을 눌러야 최종적으로 삭제됩니다.
{/* 레이아웃 편집 Dialog */} !open && setEditLayoutDialog({ open: false, name: "" })} > e.stopPropagation()}> 야드 레이아웃 정보 수정
setEditLayoutDialog((prev) => ({ ...prev, name: e.target.value }))} placeholder="레이아웃 이름을 입력하세요" />
); }