diff --git a/YARD_MANAGEMENT_3D_PLAN.md b/YARD_MANAGEMENT_3D_PLAN.md index 49938406..fe11a2cc 100644 --- a/YARD_MANAGEMENT_3D_PLAN.md +++ b/YARD_MANAGEMENT_3D_PLAN.md @@ -8,10 +8,19 @@ ### 주요 특징 -- 각 대시보드는 하나의 야드(창고 내부 상태)를 나타냄 -- 3D 공간에서 자재를 직접 배치 및 이동 가능 -- 배치된 자재 클릭 시 상세 정보 표시 -- 여러 야드 레이아웃을 저장하고 선택 가능 +- **대시보드 위젯**: 대시보드의 위젯 형태로 추가되는 기능 +- **야드 레이아웃 관리**: 여러 야드 레이아웃을 생성, 선택, 수정, 삭제 가능 +- **3D 시각화**: Three.js + React Three Fiber를 사용한 3D 렌더링 +- **자재 배치**: 3D 공간에서 자재를 직접 배치 및 이동 가능 +- **자재 정보**: 배치된 자재 클릭 시 상세 정보 표시 (읽기 전용 자재 정보 + 편집 가능한 배치 정보) + +### 위젯 통합 + +- **위젯 타입**: `yard-management-3d` +- **위치**: 대시보드 관리 > 데이터 위젯 > 야드 관리 3D +- **표시 모드**: + - 편집 모드: 플레이스홀더 표시 + - 뷰 모드: 실제 야드 관리 기능 실행 --- @@ -158,48 +167,85 @@ Response: [ **경로**: `backend-node/src/services/YardLayoutService.ts` -```typescript -- getAllLayouts(): 모든 야드 레이아웃 목록 조회 -- getLayoutById(id): 특정 야드 레이아웃 상세 조회 -- createLayout(data): 새 야드 레이아웃 생성 -- updateLayout(id, data): 야드 레이아웃 수정 (이름, 설명) -- deleteLayout(id): 야드 레이아웃 삭제 -- getPlacementsByLayoutId(layoutId): 특정 야드의 모든 배치 자재 조회 -- addMaterialPlacement(layoutId, placementData): 야드에 자재 배치 추가 -- updatePlacement(placementId, data): 배치 정보 수정 (위치, 크기, 색상, 메모만) -- removePlacement(placementId): 배치 해제 (자재는 삭제되지 않음) -- batchUpdatePlacements(layoutId, placements[]): 여러 배치 일괄 업데이트 -``` +**구현 완료** -**중요**: 자재 마스터 데이터(코드, 이름, 수량, 단위)는 읽기 전용 +주요 메서드: + +- `getAllLayouts()`: 모든 야드 레이아웃 목록 조회 (배치 자재 개수 포함) +- `getLayoutById(id)`: 특정 야드 레이아웃 상세 조회 +- `createLayout(data)`: 새 야드 레이아웃 생성 +- `updateLayout(id, data)`: 야드 레이아웃 수정 (이름, 설명만) +- `deleteLayout(id)`: 야드 레이아웃 삭제 (CASCADE로 배치 자재도 함께 삭제) +- `duplicateLayout(id, newName)`: 야드 레이아웃 복제 (배치 자재 포함) +- `getPlacementsByLayoutId(layoutId)`: 특정 야드의 모든 배치 자재 조회 +- `addMaterialPlacement(layoutId, data)`: 야드에 자재 배치 추가 +- `updatePlacement(placementId, data)`: 배치 정보 수정 (위치, 크기, 색상, 메모만) +- `removePlacement(placementId)`: 배치 해제 +- `batchUpdatePlacements(layoutId, placements)`: 여러 배치 일괄 업데이트 (트랜잭션 처리) + +**중요**: 자재 마스터 데이터(코드, 이름, 수량, 단위)는 읽기 전용. 배치 정보만 수정 가능. ### 3.2. YardLayoutController **경로**: `backend-node/src/controllers/YardLayoutController.ts` -- GET `/api/yard-layouts`: 모든 레이아웃 목록 -- GET `/api/yard-layouts/:id`: 특정 레이아웃 상세 -- POST `/api/yard-layouts`: 새 레이아웃 생성 -- PUT `/api/yard-layouts/:id`: 레이아웃 수정 (이름, 설명만) -- DELETE `/api/yard-layouts/:id`: 레이아웃 삭제 -- GET `/api/yard-layouts/:id/placements`: 레이아웃의 배치 자재 목록 -- POST `/api/yard-layouts/:id/placements`: 자재 배치 추가 -- PUT `/api/yard-placements/:id`: 배치 정보 수정 (위치, 크기, 색상, 메모만) -- DELETE `/api/yard-placements/:id`: 배치 해제 -- PUT `/api/yard-layouts/:id/placements/batch`: 배치 일괄 업데이트 +**구현 완료** -### 3.3. MaterialController (임시 자재 조회용) +엔드포인트: + +- `GET /api/yard-layouts`: 모든 레이아웃 목록 (배치 개수 포함) +- `GET /api/yard-layouts/:id`: 특정 레이아웃 상세 +- `POST /api/yard-layouts`: 새 레이아웃 생성 (name, description) +- `PUT /api/yard-layouts/:id`: 레이아웃 수정 (이름, 설명만) +- `DELETE /api/yard-layouts/:id`: 레이아웃 삭제 (CASCADE) +- `POST /api/yard-layouts/:id/duplicate`: 레이아웃 복제 (name) +- `GET /api/yard-layouts/:id/placements`: 레이아웃의 배치 자재 목록 +- `POST /api/yard-layouts/:id/placements`: 자재 배치 추가 +- `PUT /api/yard-layouts/placements/:id`: 배치 정보 수정 +- `DELETE /api/yard-layouts/placements/:id`: 배치 해제 +- `PUT /api/yard-layouts/:id/placements/batch`: 배치 일괄 업데이트 + +모든 엔드포인트는 `authMiddleware`로 인증 보호됨 + +### 3.3. MaterialService + +**경로**: `backend-node/src/services/MaterialService.ts` + +**구현 완료** + +주요 메서드: + +- `getTempMaterials(params)`: 임시 자재 목록 조회 (검색, 카테고리 필터, 페이징) +- `getTempMaterialByCode(code)`: 특정 자재 상세 조회 +- `getCategories()`: 카테고리 목록 조회 + +### 3.4. MaterialController **경로**: `backend-node/src/controllers/MaterialController.ts` -- GET `/api/materials/temp`: 임시 자재 마스터 목록 조회 (검색, 필터링) -- GET `/api/materials/temp/:code`: 특정 자재 상세 조회 +**구현 완료** + +엔드포인트: + +- `GET /api/materials/temp`: 임시 자재 마스터 목록 (검색, 필터링, 페이징) +- `GET /api/materials/temp/categories`: 카테고리 목록 +- `GET /api/materials/temp/:code`: 특정 자재 상세 **향후**: 외부 API 프록시로 변경 예정 -### 3.4. Routes +### 3.5. Routes -**경로**: `backend-node/src/routes/yardLayoutRoutes.ts` + `materialRoutes.ts` +**경로**: + +- `backend-node/src/routes/yardLayoutRoutes.ts` +- `backend-node/src/routes/materialRoutes.ts` + +**구현 완료** + +`app.ts`에 등록: + +- `app.use("/api/yard-layouts", yardLayoutRoutes)` +- `app.use("/api/materials", materialRoutes)` --- @@ -209,43 +255,87 @@ Response: [ **경로**: `frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx` +**구현 완료** + **주요 기능**: -1. 야드 레이아웃 선택/생성 모드 전환 -2. 3D 캔버스 렌더링 -3. 자재 배치 및 이동 인터랙션 -4. 선택된 자재 정보 패널 표시 +1. 레이아웃 선택/생성 모드와 3D 편집 모드 전환 +2. 편집 모드와 뷰 모드 구분 (isEditMode props) +3. API 연동 (yardLayoutApi) **상태 관리**: ```typescript -- mode: 'select' | 'edit' | 'create' // 현재 모드 -- selectedLayoutId: number | null // 선택된 레이아웃 ID -- layoutData: YardLayout | null // 현재 레이아웃 데이터 -- materials: YardMaterial[] // 배치된 자재 목록 -- selectedMaterialId: string | null // 선택된 자재 ID -- isDragging: boolean // 드래그 중 여부 +- layouts: YardLayout[] // 전체 레이아웃 목록 +- selectedLayout: YardLayout | null // 선택된 레이아웃 +- isCreateModalOpen: boolean // 생성 모달 표시 여부 +- isLoading: boolean // 로딩 상태 ``` -### 4.2. YardLayoutSelector (레이아웃 선택) +**하위 컴포넌트**: -**경로**: `frontend/components/admin/dashboard/widgets/YardLayoutSelector.tsx` +- `YardLayoutList`: 레이아웃 목록 표시 +- `YardLayoutCreateModal`: 새 레이아웃 생성 모달 +- `YardEditor`: 3D 편집 화면 -- 저장된 야드 레이아웃 목록 표시 -- 레이아웃 선택 기능 -- 새 레이아웃 생성 버튼 +### 4.2. API 클라이언트 -### 4.3. YardLayoutCreator (레이아웃 생성) +**경로**: `frontend/lib/api/yardLayoutApi.ts` -**경로**: `frontend/components/admin/dashboard/widgets/YardLayoutCreator.tsx` +**구현 완료** + +**yardLayoutApi**: + +- `getAllLayouts()`: 모든 레이아웃 목록 +- `getLayoutById(id)`: 레이아웃 상세 +- `createLayout(data)`: 레이아웃 생성 +- `updateLayout(id, data)`: 레이아웃 수정 +- `deleteLayout(id)`: 레이아웃 삭제 +- `duplicateLayout(id, name)`: 레이아웃 복제 +- `getPlacementsByLayoutId(layoutId)`: 배치 목록 +- `addMaterialPlacement(layoutId, data)`: 배치 추가 +- `updatePlacement(placementId, data)`: 배치 수정 +- `removePlacement(placementId)`: 배치 삭제 +- `batchUpdatePlacements(layoutId, placements)`: 일괄 업데이트 + +**materialApi**: + +- `getTempMaterials(params)`: 임시 자재 목록 +- `getTempMaterialByCode(code)`: 자재 상세 +- `getCategories()`: 카테고리 목록 + +### 4.3. YardLayoutList (레이아웃 목록) + +**경로**: `frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutList.tsx` + +**구현 예정** + +- 테이블 형식으로 레이아웃 목록 표시 +- 검색 및 정렬 기능 +- 행 클릭 시 레이아웃 선택 (편집 모드 진입) +- 작업 메뉴 (편집, 복제, 삭제) + +### 4.4. YardLayoutCreateModal (레이아웃 생성 모달) + +**경로**: `frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx` + +**구현 예정** - 야드 이름, 설명 입력 -- 야드 크기 설정 (width, depth, height) -- 그리드 크기 설정 +- Shadcn UI Dialog 사용 +- 생성 완료 시 자동으로 편집 모드 진입 -### 4.4. Yard3DCanvas (3D 캔버스) +### 4.5. YardEditor (3D 편집 화면) -**경로**: `frontend/components/admin/dashboard/widgets/Yard3DCanvas.tsx` +**경로**: `frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx` + +**구현 예정** + +**주요 구성**: + +- 상단 툴바 (뒤로가기, 저장, 자재 추가 등) +- 좌측: 3D 캔버스 +- 우측: 자재 정보 패널 (선택 시 표시) **기술 스택**: @@ -261,9 +351,11 @@ Response: [ 4. 자재 드래그 앤 드롭 (위치 이동) 5. 카메라 컨트롤 (회전, 줌) -### 4.5. MaterialInfoPanel (자재 정보 패널) +### 4.6. MaterialInfoPanel (자재 정보 패널) -**경로**: `frontend/components/admin/dashboard/widgets/MaterialInfoPanel.tsx` +**경로**: `frontend/components/admin/dashboard/widgets/yard-3d/MaterialInfoPanel.tsx` + +**구현 예정** **읽기 전용 정보** (외부 자재 데이터): @@ -543,42 +635,53 @@ Response: [ ## 6. 구현 단계 -### Phase 1: 데이터베이스 및 백엔드 API +### Phase 1: 데이터베이스 및 백엔드 API ✅ **완료** -1. 테이블 생성 스크립트 작성 -2. 마이그레이션 실행 -3. Service, Controller, Routes 구현 -4. API 테스트 +1. ✅ 테이블 생성 스크립트 작성 (`create_yard_management_tables.sql`) +2. ✅ 마이그레이션 실행 +3. ✅ Service, Controller, Routes 구현 +4. ✅ API 클라이언트 구현 (yardLayoutApi, materialApi) -### Phase 2: 야드 레이아웃 선택/생성 +### Phase 2: 메인 위젯 및 레이아웃 관리 🔄 **진행 중** -1. YardLayoutSelector 컴포넌트 구현 -2. YardLayoutCreator 컴포넌트 구현 -3. API 연동 -4. 레이아웃 CRUD 기능 테스트 +1. ✅ types.ts에 위젯 타입 추가 +2. ✅ DashboardTopMenu에 위젯 추가 +3. ✅ DashboardDesigner에 위젯 타이틀/컨텐츠 추가 +4. ✅ YardManagement3DWidget 메인 컴포넌트 구현 +5. ⏳ YardLayoutList 컴포넌트 구현 +6. ⏳ YardLayoutCreateModal 컴포넌트 구현 -### Phase 3: 3D 캔버스 기본 구조 +### Phase 3: 3D 편집 화면 ⏳ **대기 중** -1. Yard3DCanvas 컴포넌트 기본 구조 -2. 야드 바닥 그리드 렌더링 -3. 카메라 컨트롤 (OrbitControls) -4. 자재 3D 박스 렌더링 +1. ⏳ YardEditor 메인 컴포넌트 +2. ⏳ 상단 툴바 (뒤로가기, 저장, 자재 추가) +3. ⏳ 레이아웃 구성 (좌측 캔버스 + 우측 패널) -### Phase 4: 자재 배치 및 인터랙션 +### Phase 4: 3D 캔버스 기본 구조 ⏳ **대기 중** -1. MaterialLibrary 컴포넌트 구현 -2. 자재 드래그 앤 드롭 배치 -3. 자재 클릭 선택 -4. 자재 위치 이동 (드래그) +1. ⏳ Yard3DCanvas 컴포넌트 기본 구조 +2. ⏳ React Three Fiber 설정 +3. ⏳ 야드 바닥 그리드 렌더링 +4. ⏳ 카메라 컨트롤 (OrbitControls) +5. ⏳ 자재 3D 박스 렌더링 -### Phase 5: 자재 정보 패널 및 편집 +### Phase 5: 자재 배치 및 인터랙션 ⏳ **대기 중** -1. MaterialInfoPanel 컴포넌트 구현 -2. 자재 정보 표시 -3. 자재 정보 수정 (수량, 위치, 크기 등) -4. 자재 삭제 +1. ⏳ MaterialLibrary 컴포넌트 구현 +2. ⏳ 자재 선택 및 추가 +3. ⏳ 자재 드래그 앤 드롭 배치 +4. ⏳ 자재 클릭 선택 +5. ⏳ 자재 위치 이동 (드래그) -### Phase 6: 통합 및 최적화 +### Phase 6: 자재 정보 패널 및 편집 ⏳ **대기 중** + +1. ⏳ MaterialInfoPanel 컴포넌트 구현 +2. ⏳ 자재 정보 표시 (읽기 전용 + 편집 가능) +3. ⏳ 자재 배치 정보 수정 +4. ⏳ 배치 해제 기능 +5. ⏳ 변경사항 저장 + +### Phase 7: 통합 및 최적화 ⏳ **대기 중** 1. YardManagement3DWidget 통합 2. 상태 관리 최적화 diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 4ba9405d..37965d00 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -55,6 +55,8 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 +import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D +import materialRoutes from "./routes/materialRoutes"; // 자재 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -204,6 +206,8 @@ app.use("/api/risk-alerts", riskAlertRoutes); // 리스크/알림 관리 app.use("/api/todos", todoRoutes); // To-Do 관리 app.use("/api/bookings", bookingRoutes); // 예약 요청 관리 app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회 +app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D +app.use("/api/materials", materialRoutes); // 자재 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/MaterialController.ts b/backend-node/src/controllers/MaterialController.ts new file mode 100644 index 00000000..bcac72d4 --- /dev/null +++ b/backend-node/src/controllers/MaterialController.ts @@ -0,0 +1,68 @@ +import { Request, Response } from "express"; +import MaterialService from "../services/MaterialService"; + +export class MaterialController { + // 임시 자재 마스터 목록 조회 + async getTempMaterials(req: Request, res: Response) { + try { + const { search, category, page, limit } = req.query; + + const result = await MaterialService.getTempMaterials({ + search: search as string, + category: category as string, + page: page ? parseInt(page as string) : 1, + limit: limit ? parseInt(limit as string) : 20, + }); + + return res.json({ success: true, ...result }); + } catch (error: any) { + console.error("Error fetching temp materials:", error); + return res.status(500).json({ + success: false, + message: "자재 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 특정 자재 상세 조회 + async getTempMaterialByCode(req: Request, res: Response) { + try { + const { code } = req.params; + const material = await MaterialService.getTempMaterialByCode(code); + + if (!material) { + return res.status(404).json({ + success: false, + message: "자재를 찾을 수 없습니다.", + }); + } + + return res.json({ success: true, data: material }); + } catch (error: any) { + console.error("Error fetching temp material:", error); + return res.status(500).json({ + success: false, + message: "자재 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 카테고리 목록 조회 + async getCategories(req: Request, res: Response) { + try { + const categories = await MaterialService.getCategories(); + return res.json({ success: true, data: categories }); + } catch (error: any) { + console.error("Error fetching categories:", error); + return res.status(500).json({ + success: false, + message: "카테고리 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +} + +export default new MaterialController(); diff --git a/backend-node/src/controllers/YardLayoutController.ts b/backend-node/src/controllers/YardLayoutController.ts new file mode 100644 index 00000000..652f74a2 --- /dev/null +++ b/backend-node/src/controllers/YardLayoutController.ts @@ -0,0 +1,299 @@ +import { Request, Response } from "express"; +import YardLayoutService from "../services/YardLayoutService"; + +export class YardLayoutController { + // 모든 야드 레이아웃 목록 조회 + async getAllLayouts(req: Request, res: Response) { + try { + const layouts = await YardLayoutService.getAllLayouts(); + res.json({ success: true, data: layouts }); + } catch (error: any) { + console.error("Error fetching yard layouts:", error); + res.status(500).json({ + success: false, + message: "야드 레이아웃 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 특정 야드 레이아웃 상세 조회 + async getLayoutById(req: Request, res: Response) { + try { + const { id } = req.params; + const layout = await YardLayoutService.getLayoutById(parseInt(id)); + + if (!layout) { + return res.status(404).json({ + success: false, + message: "야드 레이아웃을 찾을 수 없습니다.", + }); + } + + return res.json({ success: true, data: layout }); + } catch (error: any) { + console.error("Error fetching yard layout:", error); + return res.status(500).json({ + success: false, + message: "야드 레이아웃 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 새 야드 레이아웃 생성 + async createLayout(req: Request, res: Response) { + try { + const { name, description } = req.body; + + if (!name) { + return res.status(400).json({ + success: false, + message: "야드 이름은 필수입니다.", + }); + } + + const created_by = (req as any).user?.userId || "system"; + const layout = await YardLayoutService.createLayout({ + name, + description, + created_by, + }); + + return res.status(201).json({ success: true, data: layout }); + } catch (error: any) { + console.error("Error creating yard layout:", error); + return res.status(500).json({ + success: false, + message: "야드 레이아웃 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 야드 레이아웃 수정 + async updateLayout(req: Request, res: Response) { + try { + const { id } = req.params; + const { name, description } = req.body; + + const layout = await YardLayoutService.updateLayout(parseInt(id), { + name, + description, + }); + + if (!layout) { + return res.status(404).json({ + success: false, + message: "야드 레이아웃을 찾을 수 없습니다.", + }); + } + + return res.json({ success: true, data: layout }); + } catch (error: any) { + console.error("Error updating yard layout:", error); + return res.status(500).json({ + success: false, + message: "야드 레이아웃 수정 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 야드 레이아웃 삭제 + async deleteLayout(req: Request, res: Response) { + try { + const { id } = req.params; + const layout = await YardLayoutService.deleteLayout(parseInt(id)); + + if (!layout) { + return res.status(404).json({ + success: false, + message: "야드 레이아웃을 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + message: "야드 레이아웃이 삭제되었습니다.", + }); + } catch (error: any) { + console.error("Error deleting yard layout:", error); + return res.status(500).json({ + success: false, + message: "야드 레이아웃 삭제 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 특정 야드의 모든 배치 자재 조회 + async getPlacementsByLayoutId(req: Request, res: Response) { + try { + const { id } = req.params; + const placements = await YardLayoutService.getPlacementsByLayoutId( + parseInt(id) + ); + + res.json({ success: true, data: placements }); + } catch (error: any) { + console.error("Error fetching placements:", error); + res.status(500).json({ + success: false, + message: "배치 자재 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 야드에 자재 배치 추가 + async addMaterialPlacement(req: Request, res: Response) { + try { + const { id } = req.params; + const placementData = req.body; + + if (!placementData.external_material_id || !placementData.material_code) { + return res.status(400).json({ + success: false, + message: "자재 정보가 필요합니다.", + }); + } + + const placement = await YardLayoutService.addMaterialPlacement( + parseInt(id), + placementData + ); + + return res.status(201).json({ success: true, data: placement }); + } catch (error: any) { + console.error("Error adding material placement:", error); + + if (error.code === "23505") { + // 유니크 제약 조건 위반 + return res.status(409).json({ + success: false, + message: "이미 배치된 자재입니다.", + }); + } + + return res.status(500).json({ + success: false, + message: "자재 배치 추가 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 배치 정보 수정 + async updatePlacement(req: Request, res: Response) { + try { + const { id } = req.params; + const placementData = req.body; + + const placement = await YardLayoutService.updatePlacement( + parseInt(id), + placementData + ); + + if (!placement) { + return res.status(404).json({ + success: false, + message: "배치 정보를 찾을 수 없습니다.", + }); + } + + return res.json({ success: true, data: placement }); + } catch (error: any) { + console.error("Error updating placement:", error); + return res.status(500).json({ + success: false, + message: "배치 정보 수정 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 배치 해제 + async removePlacement(req: Request, res: Response) { + try { + const { id } = req.params; + const placement = await YardLayoutService.removePlacement(parseInt(id)); + + if (!placement) { + return res.status(404).json({ + success: false, + message: "배치 정보를 찾을 수 없습니다.", + }); + } + + return res.json({ success: true, message: "배치가 해제되었습니다." }); + } catch (error: any) { + console.error("Error removing placement:", error); + return res.status(500).json({ + success: false, + message: "배치 해제 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 여러 배치 일괄 업데이트 + async batchUpdatePlacements(req: Request, res: Response) { + try { + const { id } = req.params; + const { placements } = req.body; + + if (!Array.isArray(placements) || placements.length === 0) { + return res.status(400).json({ + success: false, + message: "배치 목록이 필요합니다.", + }); + } + + const updatedPlacements = await YardLayoutService.batchUpdatePlacements( + parseInt(id), + placements + ); + + return res.json({ success: true, data: updatedPlacements }); + } catch (error: any) { + console.error("Error batch updating placements:", error); + return res.status(500).json({ + success: false, + message: "배치 일괄 업데이트 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 야드 레이아웃 복제 + async duplicateLayout(req: Request, res: Response) { + try { + const { id } = req.params; + const { name } = req.body; + + if (!name) { + return res.status(400).json({ + success: false, + message: "새 야드 이름은 필수입니다.", + }); + } + + const layout = await YardLayoutService.duplicateLayout( + parseInt(id), + name + ); + + return res.status(201).json({ success: true, data: layout }); + } catch (error: any) { + console.error("Error duplicating yard layout:", error); + return res.status(500).json({ + success: false, + message: "야드 레이아웃 복제 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +} + +export default new YardLayoutController(); diff --git a/backend-node/src/routes/materialRoutes.ts b/backend-node/src/routes/materialRoutes.ts new file mode 100644 index 00000000..a85e10f6 --- /dev/null +++ b/backend-node/src/routes/materialRoutes.ts @@ -0,0 +1,15 @@ +import express from "express"; +import MaterialController from "../controllers/MaterialController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 임시 자재 마스터 관리 +router.get("/temp", MaterialController.getTempMaterials); +router.get("/temp/categories", MaterialController.getCategories); +router.get("/temp/:code", MaterialController.getTempMaterialByCode); + +export default router; diff --git a/backend-node/src/routes/yardLayoutRoutes.ts b/backend-node/src/routes/yardLayoutRoutes.ts new file mode 100644 index 00000000..bd1c3590 --- /dev/null +++ b/backend-node/src/routes/yardLayoutRoutes.ts @@ -0,0 +1,27 @@ +import express from "express"; +import YardLayoutController from "../controllers/YardLayoutController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 야드 레이아웃 관리 +router.get("/", YardLayoutController.getAllLayouts); +router.get("/:id", YardLayoutController.getLayoutById); +router.post("/", YardLayoutController.createLayout); +router.put("/:id", YardLayoutController.updateLayout); +router.delete("/:id", YardLayoutController.deleteLayout); +router.post("/:id/duplicate", YardLayoutController.duplicateLayout); + +// 자재 배치 관리 +router.get("/:id/placements", YardLayoutController.getPlacementsByLayoutId); +router.post("/:id/placements", YardLayoutController.addMaterialPlacement); +router.put("/:id/placements/batch", YardLayoutController.batchUpdatePlacements); + +// 개별 배치 관리 (별도 경로) +router.put("/placements/:id", YardLayoutController.updatePlacement); +router.delete("/placements/:id", YardLayoutController.removePlacement); + +export default router; diff --git a/backend-node/src/services/MaterialService.ts b/backend-node/src/services/MaterialService.ts new file mode 100644 index 00000000..0f316cdc --- /dev/null +++ b/backend-node/src/services/MaterialService.ts @@ -0,0 +1,111 @@ +import { getPool } from "../database/db"; + +export class MaterialService { + // 임시 자재 마스터 목록 조회 + async getTempMaterials(params: { + search?: string; + category?: string; + page?: number; + limit?: number; + }) { + const { search, category, page = 1, limit = 20 } = params; + const offset = (page - 1) * limit; + + let whereConditions: string[] = ["is_active = true"]; + const queryParams: any[] = []; + let paramIndex = 1; + + if (search) { + whereConditions.push( + `(material_code ILIKE $${paramIndex} OR material_name ILIKE $${paramIndex})` + ); + queryParams.push(`%${search}%`); + paramIndex++; + } + + if (category) { + whereConditions.push(`category = $${paramIndex}`); + queryParams.push(category); + paramIndex++; + } + + const whereClause = + whereConditions.length > 0 + ? `WHERE ${whereConditions.join(" AND ")}` + : ""; + + const pool = getPool(); + + // 전체 개수 조회 + const countQuery = `SELECT COUNT(*) as total FROM temp_material_master ${whereClause}`; + const countResult = await pool.query(countQuery, queryParams); + const total = parseInt(countResult.rows[0].total); + + // 데이터 조회 + const dataQuery = ` + SELECT + id, + material_code, + material_name, + category, + unit, + default_color, + description, + created_at + FROM temp_material_master + ${whereClause} + ORDER BY material_code ASC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `; + + queryParams.push(limit, offset); + const dataResult = await pool.query(dataQuery, queryParams); + + return { + data: dataResult.rows, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + // 특정 자재 상세 조회 + async getTempMaterialByCode(materialCode: string) { + const query = ` + SELECT + id, + material_code, + material_name, + category, + unit, + default_color, + description, + created_at + FROM temp_material_master + WHERE material_code = $1 AND is_active = true + `; + + const pool = getPool(); + const result = await pool.query(query, [materialCode]); + return result.rows[0] || null; + } + + // 카테고리 목록 조회 + async getCategories() { + const query = ` + SELECT DISTINCT category + FROM temp_material_master + WHERE is_active = true AND category IS NOT NULL + ORDER BY category ASC + `; + + const pool = getPool(); + const result = await pool.query(query); + return result.rows.map((row) => row.category); + } +} + +export default new MaterialService(); diff --git a/backend-node/src/services/YardLayoutService.ts b/backend-node/src/services/YardLayoutService.ts new file mode 100644 index 00000000..6d077915 --- /dev/null +++ b/backend-node/src/services/YardLayoutService.ts @@ -0,0 +1,337 @@ +import { getPool } from "../database/db"; + +export class YardLayoutService { + // 모든 야드 레이아웃 목록 조회 + async getAllLayouts() { + const query = ` + SELECT + yl.id, + yl.name, + yl.description, + yl.created_by, + yl.created_at, + yl.updated_at, + COUNT(ymp.id) as placement_count + FROM yard_layout yl + LEFT JOIN yard_material_placement ymp ON yl.id = ymp.yard_layout_id + GROUP BY yl.id + ORDER BY yl.updated_at DESC + `; + + const pool = getPool(); + const result = await pool.query(query); + return result.rows; + } + + // 특정 야드 레이아웃 상세 조회 + async getLayoutById(id: number) { + const query = ` + SELECT + id, + name, + description, + created_by, + created_at, + updated_at + FROM yard_layout + WHERE id = $1 + `; + + const pool = getPool(); + const result = await pool.query(query, [id]); + return result.rows[0] || null; + } + + // 새 야드 레이아웃 생성 + async createLayout(data: { + name: string; + description?: string; + created_by?: string; + }) { + const query = ` + INSERT INTO yard_layout (name, description, created_by) + VALUES ($1, $2, $3) + RETURNING * + `; + + const pool = getPool(); + const result = await pool.query(query, [ + data.name, + data.description || null, + data.created_by || null, + ]); + return result.rows[0]; + } + + // 야드 레이아웃 수정 (이름, 설명만) + async updateLayout( + id: number, + data: { name?: string; description?: string } + ) { + const query = ` + UPDATE yard_layout + SET + name = COALESCE($1, name), + description = COALESCE($2, description), + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 + RETURNING * + `; + + const pool = getPool(); + const result = await pool.query(query, [ + data.name || null, + data.description || null, + id, + ]); + return result.rows[0] || null; + } + + // 야드 레이아웃 삭제 + async deleteLayout(id: number) { + const query = `DELETE FROM yard_layout WHERE id = $1 RETURNING *`; + const pool = getPool(); + const result = await pool.query(query, [id]); + return result.rows[0] || null; + } + + // 특정 야드의 모든 배치 자재 조회 + async getPlacementsByLayoutId(layoutId: number) { + const query = ` + SELECT + id, + yard_layout_id, + external_material_id, + material_code, + material_name, + quantity, + unit, + position_x, + position_y, + position_z, + size_x, + size_y, + size_z, + color, + memo, + created_at, + updated_at + FROM yard_material_placement + WHERE yard_layout_id = $1 + ORDER BY created_at ASC + `; + + const pool = getPool(); + const result = await pool.query(query, [layoutId]); + return result.rows; + } + + // 야드에 자재 배치 추가 + async addMaterialPlacement(layoutId: number, data: any) { + const query = ` + INSERT INTO yard_material_placement ( + yard_layout_id, + external_material_id, + material_code, + material_name, + quantity, + unit, + position_x, + position_y, + position_z, + size_x, + size_y, + size_z, + color, + memo + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING * + `; + + const pool = getPool(); + const result = await pool.query(query, [ + layoutId, + data.external_material_id, + data.material_code, + data.material_name, + data.quantity, + data.unit, + data.position_x || 0, + data.position_y || 0, + data.position_z || 0, + data.size_x || 5, + data.size_y || 5, + data.size_z || 5, + data.color || "#3b82f6", + data.memo || null, + ]); + + return result.rows[0]; + } + + // 배치 정보 수정 (위치, 크기, 색상, 메모만) + async updatePlacement(placementId: number, data: any) { + const query = ` + UPDATE yard_material_placement + SET + position_x = COALESCE($1, position_x), + position_y = COALESCE($2, position_y), + position_z = COALESCE($3, position_z), + size_x = COALESCE($4, size_x), + size_y = COALESCE($5, size_y), + size_z = COALESCE($6, size_z), + color = COALESCE($7, color), + memo = COALESCE($8, memo), + updated_at = CURRENT_TIMESTAMP + WHERE id = $9 + RETURNING * + `; + + const pool = getPool(); + const result = await pool.query(query, [ + data.position_x, + data.position_y, + data.position_z, + data.size_x, + data.size_y, + data.size_z, + data.color, + data.memo, + placementId, + ]); + + return result.rows[0] || null; + } + + // 배치 해제 (자재는 삭제되지 않음) + async removePlacement(placementId: number) { + const query = `DELETE FROM yard_material_placement WHERE id = $1 RETURNING *`; + const pool = getPool(); + const result = await pool.query(query, [placementId]); + return result.rows[0] || null; + } + + // 여러 배치 일괄 업데이트 + async batchUpdatePlacements(layoutId: number, placements: any[]) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const results = []; + for (const placement of placements) { + const query = ` + UPDATE yard_material_placement + SET + position_x = $1, + position_y = $2, + position_z = $3, + size_x = $4, + size_y = $5, + size_z = $6, + updated_at = CURRENT_TIMESTAMP + WHERE id = $7 AND yard_layout_id = $8 + RETURNING * + `; + + const result = await client.query(query, [ + placement.position_x, + placement.position_y, + placement.position_z, + placement.size_x, + placement.size_y, + placement.size_z, + placement.id, + layoutId, + ]); + + if (result.rows[0]) { + results.push(result.rows[0]); + } + } + + await client.query("COMMIT"); + return results; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + // 야드 레이아웃 복제 + async duplicateLayout(id: number, newName: string) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 원본 레이아웃 조회 + const layoutQuery = `SELECT * FROM yard_layout WHERE id = $1`; + const layoutResult = await client.query(layoutQuery, [id]); + const originalLayout = layoutResult.rows[0]; + + if (!originalLayout) { + throw new Error("Layout not found"); + } + + // 새 레이아웃 생성 + const newLayoutQuery = ` + INSERT INTO yard_layout (name, description, created_by) + VALUES ($1, $2, $3) + RETURNING * + `; + const newLayoutResult = await client.query(newLayoutQuery, [ + newName, + originalLayout.description, + originalLayout.created_by, + ]); + const newLayout = newLayoutResult.rows[0]; + + // 배치 자재 복사 + const placementsQuery = `SELECT * FROM yard_material_placement WHERE yard_layout_id = $1`; + const placementsResult = await client.query(placementsQuery, [id]); + + for (const placement of placementsResult.rows) { + await client.query( + ` + INSERT INTO yard_material_placement ( + yard_layout_id, external_material_id, material_code, material_name, + quantity, unit, position_x, position_y, position_z, + size_x, size_y, size_z, color, memo + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + `, + [ + newLayout.id, + placement.external_material_id, + placement.material_code, + placement.material_name, + placement.quantity, + placement.unit, + placement.position_x, + placement.position_y, + placement.position_z, + placement.size_x, + placement.size_y, + placement.size_z, + placement.color, + placement.memo, + ] + ); + } + + await client.query("COMMIT"); + return newLayout; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } +} + +export default new YardLayoutService(); diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 3be76d41..d06e8f98 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -106,6 +106,12 @@ import { CalendarWidget } from "./widgets/CalendarWidget"; import { DriverManagementWidget } from "./widgets/DriverManagementWidget"; import { ListWidget } from "./widgets/ListWidget"; +// 야드 관리 3D 위젯 +const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + interface CanvasElementProps { element: DashboardElement; isSelected: boolean; @@ -707,6 +713,17 @@ export function CanvasElement({ }} /> + ) : element.type === "widget" && element.subtype === "yard-management-3d" ? ( + // 야드 관리 3D 위젯 렌더링 +
+ { + onUpdate(element.id, { yardConfig: newConfig }); + }} + /> +
) : element.type === "widget" && element.subtype === "todo" ? ( // To-Do 위젯 렌더링
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 6d8ef8fc..6f127191 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -612,13 +612,15 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { case "vehicle-map": return "🚚 차량 위치 지도"; case "calendar": - return "📅 달력 위젯"; + return "달력 위젯"; case "driver-management": - return "🚚 기사 관리 위젯"; + return "기사 관리 위젯"; case "list": - return "📋 리스트 위젯"; + return "리스트 위젯"; + case "yard-management-3d": + return "야드 관리 3D"; default: - return "🔧 위젯"; + return "위젯"; } } return "요소"; @@ -657,6 +659,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string { return "driver-management"; case "list": return "list-widget"; + case "yard-management-3d": + return "yard-3d"; default: return "위젯 내용이 여기에 표시됩니다"; } diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 03464aee..35062400 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -181,6 +181,7 @@ export function DashboardTopMenu({ 데이터 위젯 리스트 위젯 + 야드 관리 3D {/* 지도 */} 커스텀 지도 카드 {/* 커스텀 목록 카드 */} diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 05a656f3..08308cc4 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -35,7 +35,8 @@ export type ElementSubtype = | "booking-alert" | "maintenance" | "document" - | "list"; // 위젯 타입 + | "list" + | "yard-management-3d"; // 야드 관리 3D 위젯 export interface Position { x: number; @@ -63,6 +64,7 @@ export interface DashboardElement { calendarConfig?: CalendarConfig; // 달력 설정 driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정 listConfig?: ListWidgetConfig; // 리스트 위젯 설정 + yardConfig?: YardManagementConfig; // 야드 관리 3D 설정 } export interface DragData { @@ -271,3 +273,9 @@ export interface ListColumn { align?: "left" | "center" | "right"; // 정렬 visible?: boolean; // 표시 여부 (기본: true) } + +// 야드 관리 3D 설정 +export interface YardManagementConfig { + layoutId: number; // 선택된 야드 레이아웃 ID + layoutName?: string; // 레이아웃 이름 (표시용) +} diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx new file mode 100644 index 00000000..2ba2e697 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Plus, Check } from "lucide-react"; +import YardLayoutList from "./yard-3d/YardLayoutList"; +import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal"; +import YardEditor from "./yard-3d/YardEditor"; +import Yard3DViewer from "./yard-3d/Yard3DViewer"; +import { yardLayoutApi } from "@/lib/api/yardLayoutApi"; +import type { YardManagementConfig } from "../types"; + +interface YardLayout { + id: number; + name: string; + description: string; + placement_count: number; + updated_at: string; +} + +interface YardManagement3DWidgetProps { + isEditMode?: boolean; + config?: YardManagementConfig; + onConfigChange?: (config: YardManagementConfig) => void; +} + +export default function YardManagement3DWidget({ + isEditMode = false, + config, + onConfigChange, +}: YardManagement3DWidgetProps) { + const [layouts, setLayouts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editingLayout, setEditingLayout] = useState(null); + + // 레이아웃 목록 로드 + const loadLayouts = async () => { + try { + setIsLoading(true); + const response = await yardLayoutApi.getAllLayouts(); + if (response.success) { + setLayouts(response.data); + } + } catch (error) { + console.error("야드 레이아웃 목록 조회 실패:", error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (isEditMode) { + loadLayouts(); + } + }, [isEditMode]); + + // 레이아웃 선택 (편집 모드에서만) + const handleSelectLayout = (layout: YardLayout) => { + if (onConfigChange) { + onConfigChange({ + layoutId: layout.id, + layoutName: layout.name, + }); + } + }; + + // 새 레이아웃 생성 + const handleCreateLayout = async (name: string, description: string) => { + try { + const response = await yardLayoutApi.createLayout({ name, description }); + if (response.success) { + await loadLayouts(); + setIsCreateModalOpen(false); + setEditingLayout(response.data); + } + } catch (error) { + console.error("야드 레이아웃 생성 실패:", error); + throw error; + } + }; + + // 편집 완료 + const handleEditComplete = () => { + if (editingLayout && onConfigChange) { + onConfigChange({ + layoutId: editingLayout.id, + layoutName: editingLayout.name, + }); + } + setEditingLayout(null); + loadLayouts(); + }; + + // 편집 모드: 편집 중인 경우 YardEditor 표시 + if (isEditMode && editingLayout) { + return ( +
+ +
+ ); + } + + // 편집 모드: 레이아웃 선택 UI + if (isEditMode) { + return ( +
+
+
+

야드 레이아웃 선택

+

+ {config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"} +

+
+ +
+ +
+ {isLoading ? ( +
+
로딩 중...
+
+ ) : layouts.length === 0 ? ( +
+
+
🏗️
+
생성된 야드 레이아웃이 없습니다
+
먼저 야드 레이아웃을 생성하세요
+
+
+ ) : ( +
+ {layouts.map((layout) => ( +
+
+ + +
+
+ ))} +
+ )} +
+ + {/* 생성 모달 */} + setIsCreateModalOpen(false)} + onCreate={handleCreateLayout} + /> +
+ ); + } + + // 뷰 모드: 선택된 레이아웃의 3D 뷰어 표시 + if (!config?.layoutId) { + return ( +
+
+
🏗️
+
야드 레이아웃이 설정되지 않았습니다
+
대시보드 편집에서 레이아웃을 선택하세요
+
+
+ ); + } + + // 선택된 레이아웃의 3D 뷰어 표시 + return ( +
+ +
+ ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx new file mode 100644 index 00000000..2d813744 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; + +interface TempMaterial { + id: number; + material_code: string; + material_name: string; + category: string; + unit: string; + default_color: string; + description: string; +} + +interface MaterialAddModalProps { + isOpen: boolean; + material: TempMaterial | null; + onClose: () => void; + onAdd: (placementData: any) => Promise; +} + +export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: MaterialAddModalProps) { + const [quantity, setQuantity] = useState("1"); + const [positionX, setPositionX] = useState("0"); + const [positionY, setPositionY] = useState("0"); + const [positionZ, setPositionZ] = useState("0"); + const [sizeX, setSizeX] = useState("5"); + const [sizeY, setSizeY] = useState("5"); + const [sizeZ, setSizeZ] = useState("5"); + const [color, setColor] = useState(""); + const [isAdding, setIsAdding] = useState(false); + + // 모달이 열릴 때 기본값 설정 + const handleOpen = (open: boolean) => { + if (open && material) { + setColor(material.default_color); + setQuantity("1"); + setPositionX("0"); + setPositionY("0"); + setPositionZ("0"); + setSizeX("5"); + setSizeY("5"); + setSizeZ("5"); + } + }; + + // 자재 추가 + const handleAdd = async () => { + if (!material) return; + + setIsAdding(true); + try { + await onAdd({ + external_material_id: `TEMP-${Date.now()}`, + material_code: material.material_code, + material_name: material.material_name, + quantity: parseInt(quantity) || 1, + unit: material.unit, + position_x: parseFloat(positionX) || 0, + position_y: parseFloat(positionY) || 0, + position_z: parseFloat(positionZ) || 0, + size_x: parseFloat(sizeX) || 5, + size_y: parseFloat(sizeY) || 5, + size_z: parseFloat(sizeZ) || 5, + color: color || material.default_color, + }); + onClose(); + } catch (error) { + console.error("자재 추가 실패:", error); + } finally { + setIsAdding(false); + } + }; + + if (!material) return null; + + return ( + { + handleOpen(open); + if (!open) onClose(); + }} + > + + + 자재 배치 설정 + + +
+ {/* 자재 정보 */} +
+
선택한 자재
+
+
+
+
{material.material_name}
+
{material.material_code}
+
+
+
+ + {/* 수량 */} +
+ +
+ setQuantity(e.target.value)} + min="1" + className="flex-1" + /> + {material.unit} +
+
+ + {/* 3D 위치 */} +
+ +
+
+ + setPositionX(e.target.value)} + step="0.5" + /> +
+
+ + setPositionY(e.target.value)} + step="0.5" + /> +
+
+ + setPositionZ(e.target.value)} + step="0.5" + /> +
+
+
+ + {/* 3D 크기 */} +
+ +
+
+ + setSizeX(e.target.value)} + min="1" + step="0.5" + /> +
+
+ + setSizeY(e.target.value)} + min="1" + step="0.5" + /> +
+
+ + setSizeZ(e.target.value)} + min="1" + step="0.5" + /> +
+
+
+ + {/* 색상 */} +
+ +
+ setColor(e.target.value)} + className="h-10 w-20 cursor-pointer rounded border" + /> + setColor(e.target.value)} className="flex-1" /> +
+
+
+ + + + + + +
+ ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialEditPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialEditPanel.tsx new file mode 100644 index 00000000..d2388711 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialEditPanel.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Trash2 } from "lucide-react"; + +interface YardPlacement { + id: number; + external_material_id: string; + material_code: string; + material_name: string; + quantity: number; + unit: string; + position_x: number; + position_y: number; + position_z: number; + size_x: number; + size_y: number; + size_z: number; + color: string; + memo?: string; +} + +interface MaterialEditPanelProps { + placement: YardPlacement | null; + onClose: () => void; + onUpdate: (id: number, updates: Partial) => void; + onRemove: (id: number) => void; +} + +export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemove }: MaterialEditPanelProps) { + const [editData, setEditData] = useState>({}); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + // placement 변경 시 editData 초기화 + useEffect(() => { + if (placement) { + setEditData({ + 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, + memo: placement.memo, + }); + } + }, [placement]); + + if (!placement) return null; + + // 변경사항 적용 + const handleApply = () => { + onUpdate(placement.id, editData); + }; + + // 배치 해제 + const handleRemove = () => { + onRemove(placement.id); + setIsDeleteDialogOpen(false); + }; + + return ( +
+
+

자재 정보

+ +
+ +
+ {/* 읽기 전용 정보 */} +
+
자재 정보 (읽기 전용)
+
+
자재 코드
+
{placement.material_code}
+
+
+
자재 이름
+
{placement.material_name}
+
+
+
수량
+
+ {placement.quantity} {placement.unit} +
+
+
+ + {/* 배치 정보 (편집 가능) */} +
+
배치 정보 (편집 가능)
+ + {/* 3D 위치 */} +
+ +
+
+ + setEditData({ ...editData, position_x: parseFloat(e.target.value) || 0 })} + step="0.5" + className="h-8 text-xs" + /> +
+
+ + setEditData({ ...editData, position_y: parseFloat(e.target.value) || 0 })} + step="0.5" + className="h-8 text-xs" + /> +
+
+ + setEditData({ ...editData, position_z: parseFloat(e.target.value) || 0 })} + step="0.5" + className="h-8 text-xs" + /> +
+
+
+ + {/* 3D 크기 */} +
+ +
+
+ + setEditData({ ...editData, size_x: parseFloat(e.target.value) || 1 })} + min="1" + step="0.5" + className="h-8 text-xs" + /> +
+
+ + setEditData({ ...editData, size_y: parseFloat(e.target.value) || 1 })} + min="1" + step="0.5" + className="h-8 text-xs" + /> +
+
+ + setEditData({ ...editData, size_z: parseFloat(e.target.value) || 1 })} + min="1" + step="0.5" + className="h-8 text-xs" + /> +
+
+
+ + {/* 색상 */} +
+ +
+ setEditData({ ...editData, color: e.target.value })} + className="h-8 w-16 cursor-pointer rounded border" + /> + setEditData({ ...editData, color: e.target.value })} + className="h-8 flex-1 text-xs" + /> +
+
+ + {/* 메모 */} +
+ +