diff --git a/YARD_MANAGEMENT_3D_DATA_BINDING_REDESIGN.md b/YARD_MANAGEMENT_3D_DATA_BINDING_REDESIGN.md new file mode 100644 index 00000000..0b131635 --- /dev/null +++ b/YARD_MANAGEMENT_3D_DATA_BINDING_REDESIGN.md @@ -0,0 +1,426 @@ +# 야드 관리 3D - 데이터 바인딩 시스템 재설계 + +## 1. 개요 + +### 현재 방식의 문제점 + +- 고정된 임시 자재 마스터(`temp_material_master`) 테이블에 의존 +- 실제 외부 시스템의 자재 데이터와 연동 불가 +- 자재 목록이 제한적이고 유연성 부족 +- 사용자가 직접 데이터를 선택하거나 입력할 수 없음 + +### 새로운 방식의 목표 + +- 차트/리스트 위젯과 동일한 데이터 소스 선택 방식 적용 +- DB 커넥션 또는 REST API를 통해 실제 자재 데이터 연동 +- 사용자가 자재명, 수량 등을 직접 매핑 및 입력 가능 +- 설정되지 않은 요소는 뷰어에서 명확히 표시 + +--- + +## 2. 핵심 변경사항 + +### 2.1 요소(Element) 개념 도입 + +- 기존: 자재 목록에서 클릭 → 즉시 배치 +- 변경: [+ 요소 추가] 버튼 클릭 → 3D 캔버스에 즉시 빈 요소 배치 → 우측 패널이 데이터 바인딩 설정 화면으로 전환 + +### 2.2 데이터 소스 선택 + +- 현재 DB (내부 PostgreSQL) +- 외부 DB 커넥션 +- REST API + +### 2.3 데이터 매핑 + +- 자재명 필드 선택 (데이터 소스에서) +- 수량 필드 선택 (데이터 소스에서) +- 단위 직접 입력 (예: EA, BOX, KG 등) +- 색상 선택 + +--- + +## 3. 데이터베이스 스키마 변경 + +### 3.1 기존 테이블 수정: `yard_material_placement` + +```sql +-- 기존 컬럼 변경 +ALTER TABLE yard_material_placement + -- 기존 컬럼 제거 (외부 자재 ID 관련) + DROP COLUMN IF EXISTS external_material_id, + + -- 데이터 소스 정보 추가 + ADD COLUMN data_source_type VARCHAR(20), -- 'database', 'external_db', 'rest_api' + ADD COLUMN data_source_config JSONB, -- 데이터 소스 설정 + + -- 데이터 바인딩 정보 추가 + ADD COLUMN data_binding JSONB, -- 필드 매핑 정보 + + -- 자재 정보를 NULL 허용으로 변경 (설정 전에는 NULL) + ALTER COLUMN material_code DROP NOT NULL, + ALTER COLUMN material_name DROP NOT NULL, + ALTER COLUMN quantity DROP NOT NULL; +``` + +### 3.2 data_source_config 구조 + +```typescript +interface DataSourceConfig { + type: "database" | "external_db" | "rest_api"; + + // type === 'database' (현재 DB) + query?: string; + + // type === 'external_db' (외부 DB) + connectionId?: number; + query?: string; + + // type === 'rest_api' + url?: string; + method?: "GET" | "POST"; + headers?: Record; + queryParams?: Record; + body?: string; + dataPath?: string; // 응답에서 데이터 배열 경로 (예: "data.items") +} +``` + +### 3.3 data_binding 구조 + +```typescript +interface DataBinding { + // 데이터 소스의 특정 행 선택 + selectedRowIndex?: number; + + // 필드 매핑 (데이터 소스에서 선택) + materialNameField?: string; // 자재명이 들어있는 컬럼명 + quantityField?: string; // 수량이 들어있는 컬럼명 + + // 단위는 사용자가 직접 입력 + unit: string; // 예: "EA", "BOX", "KG", "M" 등 +} +``` + +--- + +## 4. UI/UX 설계 + +### 4.1 편집 모드 (YardEditor) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [← 목록으로] 야드명: A구역 [저장] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────┐ ┌──────────────────────────┐│ +│ │ │ │ ││ +│ │ │ │ [+ 요소 추가] ││ +│ │ │ │ ││ +│ │ 3D 캔버스 │ │ ┌────────────────────┐ ││ +│ │ │ │ │ □ 요소 1 │ ││ +│ │ │ │ │ 자재: 철판 A │ ││ +│ │ │ │ │ 수량: 50 EA │ ││ +│ │ │ │ │ [편집] [삭제] │ ││ +│ │ │ │ └────────────────────┘ ││ +│ │ │ │ ││ +│ │ │ │ ┌────────────────────┐ ││ +│ │ │ │ │ □ 요소 2 (미설정) │ ││ +│ │ │ │ │ 데이터 바인딩 │ ││ +│ │ │ │ │ 설정 필요 │ ││ +│ │ │ │ │ [설정] [삭제] │ ││ +│ │ │ │ └────────────────────┘ ││ +│ │ │ │ ││ +│ └───────────────────────────┘ └──────────────────────────┘│ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 4.1.1 요소 목록 (우측 패널) + +- **[+ 요소 추가]** 버튼: 새 요소 생성 +- **요소 카드**: + - 설정 완료: 자재명, 수량 표시 + [편집] [삭제] 버튼 + - 미설정: "데이터 바인딩 설정 필요" + [설정] [삭제] 버튼 + +#### 4.1.2 요소 추가 흐름 + +``` +1. [+ 요소 추가] 클릭 + ↓ +2. 3D 캔버스의 기본 위치(0,0,0)에 회색 반투명 박스로 빈 요소 즉시 배치 + ↓ +3. 요소가 자동 선택됨 + ↓ +4. 우측 패널이 "데이터 바인딩 설정" 화면으로 자동 전환 + (요소 목록에서 [설정] 버튼을 클릭해도 동일한 화면) +``` + +### 4.2 데이터 바인딩 설정 패널 (우측) + +**[+ 요소 추가] 버튼 클릭 시 또는 [설정] 버튼 클릭 시 우측 패널이 아래와 같이 변경됩니다:** + +``` +┌──────────────────────────────────────┐ +│ 데이터 바인딩 설정 [← 목록]│ +├──────────────────────────────────────┤ +│ │ +│ ┌─ 1단계: 데이터 소스 선택 ─────────────────────────┐ │ +│ │ │ │ +│ │ ○ 현재 DB ○ 외부 DB ○ REST API │ │ +│ │ │ │ +│ │ [현재 DB 선택 시] │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ +│ │ │ SELECT material_name, quantity, unit │ │ │ +│ │ │ FROM inventory │ │ │ +│ │ │ WHERE status = 'available' │ │ │ +│ │ └────────────────────────────────────────────┘ │ │ +│ │ [실행] 버튼 │ │ +│ │ │ │ +│ │ [외부 DB 선택 시] │ │ +│ │ - 외부 커넥션 선택 드롭다운 │ │ +│ │ - SQL 쿼리 입력 │ │ +│ │ - [실행] 버튼 │ │ +│ │ │ │ +│ │ [REST API 선택 시] │ │ +│ │ - URL 입력 │ │ +│ │ - Method 선택 (GET/POST) │ │ +│ │ - Headers, Query Params 설정 │ │ +│ │ - [실행] 버튼 │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 2단계: 쿼리 결과 및 필드 매핑 ──────────────────────┐ │ +│ │ │ │ +│ │ 쿼리 결과 (5행): │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ +│ │ │ material_name │ quantity │ status │ │ │ +│ │ │ 철판 A │ 50 │ available │ ○ │ │ +│ │ │ 강관 파이프 │ 100 │ available │ ○ │ │ +│ │ │ 볼트 세트 │ 500 │ in_stock │ ○ │ │ +│ │ └────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ 필드 매핑: │ │ +│ │ 자재명: [material_name ▼] │ │ +│ │ 수량: [quantity ▼] │ │ +│ │ │ │ +│ │ 단위 입력: │ │ +│ │ 단위: [EA_____________] │ │ +│ │ (예: EA, BOX, KG, M, L 등) │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 3단계: 배치 설정 ──────────────────────────────────┐ │ +│ │ │ │ +│ │ 색상: [🎨 #3b82f6] │ │ +│ │ │ │ +│ │ 크기: │ │ +│ │ 너비: [5] 높이: [5] 깊이: [5] │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ [← 목록으로] [저장] │ +└──────────────────────────────────────┘ +``` + +**참고:** + +- [← 목록으로] 버튼: 요소 목록 화면으로 돌아갑니다 +- [저장] 버튼: 데이터 바인딩 설정을 저장하고 요소 목록 화면으로 돌아갑니다 +- 저장하지 않고 나가면 요소는 "미설정" 상태로 남습니다 + +### 4.3 뷰어 모드 (Yard3DViewer) + +#### 4.3.1 설정된 요소 + +- 정상적으로 3D 박스 렌더링 +- 클릭 시 자재명, 수량 정보 표시 + +#### 4.3.2 미설정 요소 + +``` +┌─────────────────────┐ +│ │ +│ ⚠️ │ +│ │ +│ 설정되지 않은 │ +│ 요소입니다 │ +│ │ +└─────────────────────┘ +``` + +- 반투명 회색 박스로 표시 +- 클릭 시 "데이터 바인딩이 설정되지 않았습니다" 메시지 + +--- + +## 5. 구현 단계 + +### Phase 1: 데이터베이스 스키마 변경 + +- [ ] `yard_material_placement` 테이블 수정 +- [ ] 마이그레이션 스크립트 작성 +- [ ] 기존 데이터 호환성 처리 + +### Phase 2: 백엔드 API 수정 + +- [ ] `YardLayoutService.ts` 수정 + - `addMaterialPlacement`: 데이터 소스/바인딩 정보 저장 + - `updatePlacement`: 데이터 바인딩 업데이트 + - `getPlacementsByLayoutId`: 새 필드 포함하여 조회 +- [ ] 데이터 소스 실행 로직 추가 + - DB 쿼리 실행 + - 외부 DB 쿼리 실행 + - REST API 호출 + +### Phase 3: 프론트엔드 타입 정의 + +- [ ] `types.ts`에 새로운 인터페이스 추가 + - `YardElementDataSource` + - `YardElementDataBinding` + - `YardPlacement` 업데이트 + +### Phase 4: 요소 추가 및 관리 + +- [ ] `YardEditor.tsx` 수정 + - [+ 요소 추가] 버튼 구현 + - 빈 요소 생성 로직 (즉시 3D 캔버스에 배치) + - 요소 추가 시 자동으로 해당 요소 선택 + - 우측 패널 상태 관리 (요소 목록 ↔ 데이터 바인딩 설정) + - 요소 목록 UI + - 설정/미설정 상태 구분 표시 + +### Phase 5: 데이터 바인딩 패널 + +- [ ] `YardElementConfigPanel.tsx` 생성 (우측 패널 컴포넌트) + - [← 목록으로] 버튼으로 요소 목록으로 복귀 + - 1단계: 데이터 소스 선택 (DatabaseConfig, ExternalDbConfig, RestApiConfig 재사용) + - 2단계: 쿼리 결과 테이블 + 행 선택 + 필드 매핑 + - 자재명 필드 선택 (드롭다운) + - 수량 필드 선택 (드롭다운) + - 단위 직접 입력 (Input) + - 3단계: 배치 설정 (색상, 크기) + - [저장] 버튼으로 설정 저장 및 목록으로 복귀 + +### Phase 6: 3D 캔버스 렌더링 수정 + +- [ ] `Yard3DCanvas.tsx` 수정 + - 설정된 요소: 기존 렌더링 + - 미설정 요소: 회색 반투명 박스 + 경고 아이콘 + +### Phase 7: 뷰어 모드 수정 + +- [ ] `Yard3DViewer.tsx` 수정 + - 미설정 요소 감지 + - 미설정 요소 클릭 시 안내 메시지 + +### Phase 8: 임시 테이블 제거 + +- [ ] `temp_material_master` 테이블 삭제 +- [ ] 관련 API 및 UI 코드 정리 + +--- + +## 6. 데이터 구조 예시 + +### 6.1 데이터 소스 + 필드 매핑 사용 + +```json +{ + "id": 1, + "yard_layout_id": 1, + "material_code": null, + "material_name": "철판 A타입", + "quantity": 50, + "unit": "EA", + "data_source_type": "database", + "data_source_config": { + "type": "database", + "query": "SELECT material_name, quantity FROM inventory WHERE material_id = 'MAT-001'" + }, + "data_binding": { + "selectedRowIndex": 0, + "materialNameField": "material_name", + "quantityField": "quantity", + "unit": "EA" + }, + "position_x": 10, + "position_y": 0, + "position_z": 10, + "size_x": 5, + "size_y": 5, + "size_z": 5, + "color": "#ef4444" +} +``` + +### 6.2 미설정 요소 + +```json +{ + "id": 3, + "yard_layout_id": 1, + "material_code": null, + "material_name": null, + "quantity": null, + "unit": null, + "data_source_type": null, + "data_source_config": null, + "data_binding": null, + "position_x": 30, + "position_y": 0, + "position_z": 30, + "size_x": 5, + "size_y": 5, + "size_z": 5, + "color": "#9ca3af" +} +``` + +--- + +## 7. 장점 + +1. **유연성**: 다양한 데이터 소스 지원 (내부 DB, 외부 DB, REST API) +2. **실시간성**: 실제 시스템의 자재 데이터와 연동 가능 +3. **일관성**: 차트/리스트 위젯과 동일한 데이터 소스 선택 방식 +4. **사용자 경험**: 데이터 매핑 방식 선택 가능 (자동/수동) +5. **확장성**: 새로운 데이터 소스 타입 추가 용이 +6. **명확성**: 미설정 요소를 시각적으로 구분 + +--- + +## 8. 마이그레이션 전략 + +### 8.1 기존 데이터 처리 + +- 기존 `temp_material_master` 기반 배치 데이터를 수동 입력 모드로 전환 +- `external_material_id` → `data_binding.mode = 'manual'`로 변환 + +### 8.2 단계적 전환 + +1. 새 스키마 적용 (기존 컬럼 유지) +2. 새 UI/로직 구현 및 테스트 +3. 기존 데이터 마이그레이션 +4. 임시 테이블 및 구 코드 제거 + +--- + +## 9. 기술 스택 + +- **백엔드**: PostgreSQL JSONB, Node.js/TypeScript +- **프론트엔드**: React, TypeScript, Shadcn UI +- **3D 렌더링**: React Three Fiber, Three.js +- **데이터 소스**: 기존 `DatabaseConfig`, `ExternalDbConfig`, `RestApiConfig` 컴포넌트 재사용 + +--- + +## 10. 예상 개발 기간 + +- Phase 1-2 (DB/백엔드): 1일 +- Phase 3-4 (프론트엔드 구조): 1일 +- Phase 5 (데이터 바인딩 모달): 2일 +- Phase 6-7 (3D 렌더링/뷰어): 1일 +- Phase 8 (정리 및 테스트): 0.5일 + +**총 예상 기간: 약 5.5일** diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 37965d00..0e41697f 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -56,7 +56,6 @@ 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"; // 임시 주석 @@ -207,7 +206,6 @@ 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 deleted file mode 100644 index bcac72d4..00000000 --- a/backend-node/src/controllers/MaterialController.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 index 652f74a2..c6a58759 100644 --- a/backend-node/src/controllers/YardLayoutController.ts +++ b/backend-node/src/controllers/YardLayoutController.ts @@ -146,18 +146,14 @@ export class YardLayoutController { } } - // 야드에 자재 배치 추가 + // 야드에 자재 배치 추가 (빈 요소 또는 설정된 요소) 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: "자재 정보가 필요합니다.", - }); - } + // 데이터 바인딩 재설계 후 material_code와 external_material_id는 선택사항 + // 빈 요소를 추가할 수 있어야 함 const placement = await YardLayoutService.addMaterialPlacement( parseInt(id), diff --git a/backend-node/src/routes/materialRoutes.ts b/backend-node/src/routes/materialRoutes.ts deleted file mode 100644 index a85e10f6..00000000 --- a/backend-node/src/routes/materialRoutes.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/services/MaterialService.ts b/backend-node/src/services/MaterialService.ts deleted file mode 100644 index 0f316cdc..00000000 --- a/backend-node/src/services/MaterialService.ts +++ /dev/null @@ -1,111 +0,0 @@ -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 index 6d077915..64572739 100644 --- a/backend-node/src/services/YardLayoutService.ts +++ b/backend-node/src/services/YardLayoutService.ts @@ -101,7 +101,6 @@ export class YardLayoutService { SELECT id, yard_layout_id, - external_material_id, material_code, material_name, quantity, @@ -113,6 +112,9 @@ export class YardLayoutService { size_y, size_z, color, + data_source_type, + data_source_config, + data_binding, memo, created_at, updated_at @@ -126,12 +128,11 @@ export class YardLayoutService { 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, @@ -143,60 +144,83 @@ export class YardLayoutService { size_y, size_z, color, + data_source_type, + data_source_config, + data_binding, memo - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) 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.material_code || null, + data.material_name || null, + 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, - data.color || "#3b82f6", + data.color || "#9ca3af", // 미설정 시 회색 + data.data_source_type || null, + data.data_source_config ? JSON.stringify(data.data_source_config) : null, + data.data_binding ? JSON.stringify(data.data_binding) : null, 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), + material_code = COALESCE($1, material_code), + material_name = COALESCE($2, material_name), + quantity = COALESCE($3, quantity), + unit = COALESCE($4, unit), + position_x = COALESCE($5, position_x), + position_y = COALESCE($6, position_y), + position_z = COALESCE($7, position_z), + size_x = COALESCE($8, size_x), + size_y = COALESCE($9, size_y), + size_z = COALESCE($10, size_z), + color = COALESCE($11, color), + data_source_type = COALESCE($12, data_source_type), + data_source_config = COALESCE($13, data_source_config), + data_binding = COALESCE($14, data_binding), + memo = COALESCE($15, memo), updated_at = CURRENT_TIMESTAMP - WHERE id = $9 + WHERE id = $16 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, + data.material_code !== undefined ? data.material_code : null, + data.material_name !== undefined ? data.material_name : null, + data.quantity !== undefined ? 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.color !== undefined ? data.color : null, + data.data_source_type !== undefined ? data.data_source_type : null, + data.data_source_config !== undefined + ? JSON.stringify(data.data_source_config) + : null, + data.data_binding !== undefined + ? JSON.stringify(data.data_binding) + : null, + data.memo !== undefined ? data.memo : null, placementId, ]); @@ -230,8 +254,9 @@ export class YardLayoutService { size_x = $4, size_y = $5, size_z = $6, + color = $7, updated_at = CURRENT_TIMESTAMP - WHERE id = $7 AND yard_layout_id = $8 + WHERE id = $8 AND yard_layout_id = $9 RETURNING * `; @@ -242,6 +267,7 @@ export class YardLayoutService { placement.size_x, placement.size_y, placement.size_z, + placement.color, placement.id, layoutId, ]); @@ -299,14 +325,14 @@ export class YardLayoutService { await client.query( ` INSERT INTO yard_material_placement ( - yard_layout_id, external_material_id, material_code, material_name, + yard_layout_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) + size_x, size_y, size_z, color, + data_source_type, data_source_config, data_binding, memo + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) `, [ newLayout.id, - placement.external_material_id, placement.material_code, placement.material_name, placement.quantity, @@ -318,6 +344,9 @@ export class YardLayoutService { placement.size_y, placement.size_z, placement.color, + placement.data_source_type, + placement.data_source_config, + placement.data_binding, placement.memo, ] ); diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 6b54daae..f725497c 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -163,7 +163,7 @@ export function CanvasElement({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { // 모달이나 다이얼로그가 열려있으면 드래그 무시 - if (document.querySelector('[role="dialog"]')) { + if (document.querySelector('[role="dialog"]') || document.querySelector('[role="alertdialog"]')) { return; } @@ -198,7 +198,7 @@ export function CanvasElement({ const handleResizeMouseDown = useCallback( (e: React.MouseEvent, handle: string) => { // 모달이나 다이얼로그가 열려있으면 리사이즈 무시 - if (document.querySelector('[role="dialog"]')) { + if (document.querySelector('[role="dialog"]') || document.querySelector('[role="alertdialog"]')) { return; } diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx index 09c08dac..c09e7df6 100644 --- a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx @@ -3,8 +3,7 @@ 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 { Plus, Check, Trash2 } from "lucide-react"; import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal"; import YardEditor from "./yard-3d/YardEditor"; import Yard3DViewer from "./yard-3d/Yard3DViewer"; @@ -16,6 +15,7 @@ interface YardLayout { name: string; description: string; placement_count: number; + created_at: string; updated_at: string; } @@ -34,6 +34,7 @@ export default function YardManagement3DWidget({ const [isLoading, setIsLoading] = useState(true); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [editingLayout, setEditingLayout] = useState(null); + const [deleteLayoutId, setDeleteLayoutId] = useState(null); // 레이아웃 목록 로드 const loadLayouts = async () => { @@ -41,7 +42,7 @@ export default function YardManagement3DWidget({ setIsLoading(true); const response = await yardLayoutApi.getAllLayouts(); if (response.success) { - setLayouts(response.data); + setLayouts(response.data as YardLayout[]); } } catch (error) { console.error("야드 레이아웃 목록 조회 실패:", error); @@ -73,7 +74,7 @@ export default function YardManagement3DWidget({ if (response.success) { await loadLayouts(); setIsCreateModalOpen(false); - setEditingLayout(response.data); + setEditingLayout(response.data as YardLayout); } } catch (error) { console.error("야드 레이아웃 생성 실패:", error); @@ -93,6 +94,26 @@ export default function YardManagement3DWidget({ loadLayouts(); }; + // 레이아웃 삭제 + const handleDeleteLayout = async () => { + if (!deleteLayoutId) return; + + try { + const response = await yardLayoutApi.deleteLayout(deleteLayoutId); + if (response.success) { + // 삭제된 레이아웃이 현재 선택된 레이아웃이면 설정 초기화 + if (config?.layoutId === deleteLayoutId && onConfigChange) { + onConfigChange({ layoutId: 0, layoutName: "" }); + } + await loadLayouts(); + } + } catch (error) { + console.error("레이아웃 삭제 실패:", error); + } finally { + setDeleteLayoutId(null); + } + }; + // 편집 모드: 편집 중인 경우 YardEditor 표시 if (isEditMode && editingLayout) { return ( @@ -149,16 +170,29 @@ export default function YardManagement3DWidget({ {layout.description &&

{layout.description}

}
배치된 자재: {layout.placement_count}개
- +
+ + +
))} @@ -172,6 +206,37 @@ export default function YardManagement3DWidget({ onClose={() => setIsCreateModalOpen(false)} onCreate={handleCreateLayout} /> + + {/* 삭제 확인 모달 */} + { + if (!open) setDeleteLayoutId(null); + }} + > + e.stopPropagation()} className="sm:max-w-[425px]"> + + 야드 레이아웃 삭제 + +
+

+ 이 야드 레이아웃을 삭제하시겠습니까? +
+ 레이아웃 내의 모든 배치 정보도 함께 삭제됩니다. +
+ 이 작업은 되돌릴 수 없습니다. +

+
+ + +
+
+
+
); } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index b9995e26..d55e8ad3 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -7,11 +7,10 @@ import * as THREE from "three"; interface YardPlacement { id: number; - external_material_id: string; - material_code: string; - material_name: string; - quantity: number; - unit: string; + material_code?: string | null; + material_name?: string | null; + quantity?: number | null; + unit?: string | null; position_x: number; position_y: number; position_z: number; @@ -19,6 +18,9 @@ interface YardPlacement { size_y: number; size_z: number; color: string; + data_source_type?: string | null; + data_source_config?: any; + data_binding?: any; } interface Yard3DCanvasProps { @@ -159,6 +161,9 @@ function MaterialBox({ } }; + // 요소가 설정되었는지 확인 + const isConfigured = !!(placement.material_name && placement.quantity && placement.unit); + return ( ); diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx index 2c6f1bf4..ead548f1 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx @@ -8,11 +8,10 @@ import { Loader2 } from "lucide-react"; interface YardPlacement { id: number; yard_layout_id: number; - external_material_id: string; - material_code: string; - material_name: string; - quantity: number; - unit: string; + material_code?: string | null; + material_name?: string | null; + quantity?: number | null; + unit?: string | null; position_x: number; position_y: number; position_z: number; @@ -20,6 +19,9 @@ interface YardPlacement { size_y: number; size_z: number; color: string; + data_source_type?: string | null; + data_source_config?: any; + data_binding?: any; status?: string; memo?: string; } @@ -130,7 +132,9 @@ export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) { {selectedPlacement && (
-

자재 정보

+

+ {selectedPlacement.material_name ? "자재 정보" : "미설정 요소"} +

-
-
- -
{selectedPlacement.material_name}
-
+ {selectedPlacement.material_name && selectedPlacement.quantity && selectedPlacement.unit ? ( +
+
+ +
{selectedPlacement.material_name}
+
-
- -
- {selectedPlacement.quantity} {selectedPlacement.unit} +
+ +
+ {selectedPlacement.quantity} {selectedPlacement.unit} +
-
+ ) : ( +
+
⚠️
+
데이터 바인딩이
+
설정되지 않았습니다
+
편집 모드에서 설정해주세요
+
+ )}
)}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 8dd82e5d..2841c17d 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -2,11 +2,12 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { ArrowLeft, Save, Loader2, X } from "lucide-react"; -import { yardLayoutApi, materialApi } from "@/lib/api/yardLayoutApi"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { ArrowLeft, Save, Loader2, Plus, Settings, Trash2 } 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 } from "lucide-react"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, @@ -17,41 +18,11 @@ const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ), }); -interface TempMaterial { - id: number; - material_code: string; - material_name: string; - category: string; - unit: string; - default_color: string; - description: string; -} - -interface YardLayout { - id: number; - name: string; - description: string; - placement_count?: number; - updated_at: string; -} - -interface YardPlacement { - id: number; - yard_layout_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; -} +// 나중에 구현할 데이터 바인딩 패널 +const YardElementConfigPanel = dynamic(() => import("./YardElementConfigPanel"), { + ssr: false, + loading: () =>
로딩 중...
, +}); interface YardEditorProps { layout: YardLayout; @@ -60,90 +31,94 @@ interface YardEditorProps { export default function YardEditor({ layout, onBack }: YardEditorProps) { const [placements, setPlacements] = useState([]); - const [materials, setMaterials] = useState([]); const [selectedPlacement, setSelectedPlacement] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const [editValues, setEditValues] = useState({ - size_x: 5, - size_y: 5, - size_z: 5, - color: "#3b82f6", - }); + const [showConfigPanel, setShowConfigPanel] = useState(false); + const [error, setError] = useState(null); - // 배치 목록 & 자재 목록 로드 + // 배치 목록 로드 useEffect(() => { - const loadData = async () => { + const loadPlacements = async () => { try { setIsLoading(true); - const [placementsRes, materialsRes] = await Promise.all([ - yardLayoutApi.getPlacementsByLayoutId(layout.id), - materialApi.getTempMaterials({ limit: 100 }), - ]); - - if (placementsRes.success) { - setPlacements(placementsRes.data as YardPlacement[]); - } - if (materialsRes.success) { - setMaterials(materialsRes.data as TempMaterial[]); + const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id); + if (response.success) { + setPlacements(response.data as YardPlacement[]); } } catch (error) { - console.error("데이터 로드 실패:", error); + console.error("배치 목록 로드 실패:", error); + setError("배치 목록을 불러올 수 없습니다."); } finally { setIsLoading(false); } }; - loadData(); + loadPlacements(); }, [layout.id]); - // selectedPlacement 변경 시 editValues 업데이트 - useEffect(() => { - if (selectedPlacement) { - setEditValues({ - size_x: selectedPlacement.size_x, - size_y: selectedPlacement.size_y, - size_z: selectedPlacement.size_z, - color: selectedPlacement.color, - }); - } - }, [selectedPlacement]); + // 빈 요소 추가 + 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 handleMaterialClick = async (material: TempMaterial) => { - // 이미 배치되었는지 확인 - const alreadyPlaced = placements.find((p) => p.material_code === material.material_code); - if (alreadyPlaced) { - alert("이미 배치된 자재입니다."); + 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)}`); + } + }; + + // 요소 선택 (3D 캔버스 또는 목록에서) + const handleSelectPlacement = (placement: YardPlacement) => { + setSelectedPlacement(placement); + setShowConfigPanel(false); // 선택 시에는 설정 패널 닫기 + }; + + // 설정 버튼 클릭 + const handleConfigClick = (placement: YardPlacement) => { + setSelectedPlacement(placement); + setShowConfigPanel(true); + }; + + // 요소 삭제 + const handleDeletePlacement = async (placementId: number) => { + if (!confirm("이 요소를 삭제하시겠습니까?")) { return; } - // 기본 위치에 배치 - const placementData = { - external_material_id: `TEMP-${material.id}`, - material_code: material.material_code, - material_name: material.material_name, - quantity: 1, - unit: material.unit, - position_x: 0, - position_y: 0, - position_z: 0, - size_x: 5, - size_y: 5, - size_z: 5, - color: material.default_color, - }; - try { - const response = await yardLayoutApi.addMaterialPlacement(layout.id, placementData); + const response = await yardLayoutApi.removePlacement(placementId); if (response.success) { - setPlacements((prev) => [...prev, response.data as YardPlacement]); - setSelectedPlacement(response.data as YardPlacement); + setPlacements((prev) => prev.filter((p) => p.id !== placementId)); + if (selectedPlacement?.id === placementId) { + setSelectedPlacement(null); + setShowConfigPanel(false); + } } } catch (error) { - console.error("자재 배치 실패:", error); - alert("자재 배치에 실패했습니다."); + console.error("요소 삭제 실패:", error); + setError("요소 삭제에 실패했습니다."); } }; @@ -155,49 +130,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { position_z: Math.round(position.z * 2) / 2, }; - setPlacements((prev) => - prev.map((p) => - p.id === id - ? { - ...p, - ...updatedPosition, - } - : p, - ), - ); + setPlacements((prev) => prev.map((p) => (p.id === id ? { ...p, ...updatedPosition } : p))); - // 선택된 자재도 업데이트 if (selectedPlacement?.id === id) { - setSelectedPlacement((prev) => - prev - ? { - ...prev, - ...updatedPosition, - } - : null, - ); + setSelectedPlacement((prev) => (prev ? { ...prev, ...updatedPosition } : null)); } }; - // 자재 배치 해제 - const handlePlacementRemove = async (id: number): Promise => { - try { - const response = await yardLayoutApi.removePlacement(id); - if (response.success) { - setPlacements((prev) => prev.filter((p) => p.id !== id)); - setSelectedPlacement(null); - } - } catch (error) { - console.error("배치 해제 실패:", error); - alert("배치 해제에 실패했습니다."); - } - }; - - // 위치/크기/색상 업데이트 - const handlePlacementUpdate = (id: number, updates: Partial) => { - setPlacements((prev) => prev.map((p) => (p.id === id ? { ...p, ...updates } : p))); - }; - // 저장 const handleSave = async () => { setIsSaving(true); @@ -227,12 +166,51 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { } }; - // 필터링된 자재 목록 - const filteredMaterials = materials.filter( - (m) => - m.material_name.toLowerCase().includes(searchTerm.toLowerCase()) || - m.material_code.toLowerCase().includes(searchTerm.toLowerCase()), - ); + // 설정 패널에서 저장 + const handleSaveConfig = async (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 === 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("설정 저장에 실패했습니다."); + } + }; + + // 요소가 설정되었는지 확인 + const isConfigured = (placement: YardPlacement): boolean => { + return !!(placement.material_name && placement.quantity && placement.unit); + }; return (
@@ -264,6 +242,14 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
+ {/* 에러 메시지 */} + {error && ( + + + {error} + + )} + {/* 메인 컨텐츠 영역 */}
{/* 좌측: 3D 캔버스 */} @@ -276,152 +262,104 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setSelectedPlacement(placement as YardPlacement)} + onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement)} onPlacementDrag={handlePlacementDrag} /> )}
- {/* 우측: 자재 목록 또는 편집 패널 */} + {/* 우측: 요소 목록 또는 설정 패널 */}
- {selectedPlacement ? ( - // 선택된 자재 편집 패널 -
-
-

자재 정보

- -
- -
-
- {/* 읽기 전용 정보 */} -
- -
{selectedPlacement.material_code}
-
- -
- -
{selectedPlacement.material_name}
-
- -
- -
- {selectedPlacement.quantity} {selectedPlacement.unit} -
-
- - {/* 편집 가능 정보 */} -
- - -
-
- - { - const newValue = parseFloat(e.target.value) || 1; - setEditValues((prev) => ({ ...prev, size_x: newValue })); - handlePlacementUpdate(selectedPlacement.id, { size_x: newValue }); - }} - /> -
-
- - { - const newValue = parseFloat(e.target.value) || 1; - setEditValues((prev) => ({ ...prev, size_y: newValue })); - handlePlacementUpdate(selectedPlacement.id, { size_y: newValue }); - }} - /> -
-
- - { - const newValue = parseFloat(e.target.value) || 1; - setEditValues((prev) => ({ ...prev, size_z: newValue })); - handlePlacementUpdate(selectedPlacement.id, { size_z: newValue }); - }} - /> -
-
- -
- - { - setEditValues((prev) => ({ ...prev, color: e.target.value })); - handlePlacementUpdate(selectedPlacement.id, { color: e.target.value }); - }} - /> -
-
- - -
-
-
+ {showConfigPanel && selectedPlacement ? ( + // 설정 패널 + setShowConfigPanel(false)} + /> ) : ( - // 자재 목록 + // 요소 목록
-

자재 목록

- setSearchTerm(e.target.value)} - className="text-sm" - /> +
+

요소 목록

+ +
+

총 {placements.length}개

-
- {filteredMaterials.length === 0 ? ( +
+ {placements.length === 0 ? (
- 검색 결과가 없습니다 + 요소가 없습니다. +
+ {'위의 "요소 추가" 버튼을 클릭하세요.'}
) : ( -
- {filteredMaterials.map((material) => { - const isPlaced = placements.some((p) => p.material_code === material.material_code); +
+ {placements.map((placement) => { + const configured = isConfigured(placement); + const isSelected = selectedPlacement?.id === placement.id; + return ( - +
+
+ {configured ? ( + <> +
{placement.material_name}
+
+ 수량: {placement.quantity} {placement.unit} +
+ + ) : ( + <> +
요소 #{placement.id}
+
데이터 바인딩 설정 필요
+ + )} +
+
+ +
+ + +
+
); })}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx new file mode 100644 index 00000000..27c0860f --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardElementConfigPanel.tsx @@ -0,0 +1,554 @@ +"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 { ArrowLeft, Loader2, Play } from "lucide-react"; +import { YardPlacement, YardDataSourceConfig, YardDataBinding, QueryResult } from "./types"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Card } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; +import { dashboardApi } from "@/lib/api/dashboard"; +import { ExternalDbConnectionAPI, type ExternalDbConnection } from "@/lib/api/externalDbConnection"; +import { Textarea } from "@/components/ui/textarea"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; + +interface YardElementConfigPanelProps { + placement: YardPlacement; + onSave: (updatedData: Partial) => Promise; + onCancel: () => void; +} + +export default function YardElementConfigPanel({ placement, onSave, onCancel }: YardElementConfigPanelProps) { + // 데이터 소스 설정 + const [dataSourceType, setDataSourceType] = useState<"database" | "external_db" | "rest_api">( + (placement.data_source_config?.type as "database" | "external_db" | "rest_api") || "database", + ); + const [query, setQuery] = useState(placement.data_source_config?.query || ""); + const [externalConnectionId, setExternalConnectionId] = useState( + placement.data_source_config?.connectionId?.toString() || "", + ); + const [externalConnections, setExternalConnections] = useState([]); + + // REST API 설정 + const [apiUrl, setApiUrl] = useState(placement.data_source_config?.url || ""); + const [apiMethod, setApiMethod] = useState<"GET" | "POST">(placement.data_source_config?.method || "GET"); + const [apiDataPath, setApiDataPath] = useState(placement.data_source_config?.dataPath || ""); + + // 쿼리 결과 및 매핑 + const [queryResult, setQueryResult] = useState(null); + const [isExecuting, setIsExecuting] = useState(false); + const [selectedRowIndex, setSelectedRowIndex] = useState(placement.data_binding?.selectedRowIndex ?? 0); + const [materialNameField, setMaterialNameField] = useState(placement.data_binding?.materialNameField || ""); + const [quantityField, setQuantityField] = useState(placement.data_binding?.quantityField || ""); + const [unit, setUnit] = useState(placement.data_binding?.unit || "EA"); + + // 배치 설정 + const [color, setColor] = useState(placement.color || "#3b82f6"); + const [sizeX, setSizeX] = useState(placement.size_x); + 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(() => { + const loadConnections = async () => { + try { + const connections = await ExternalDbConnectionAPI.getConnections(); + setExternalConnections(connections || []); + } catch (err) { + console.error("외부 DB 커넥션 로드 실패:", err); + } + }; + + if (dataSourceType === "external_db") { + loadConnections(); + } + }, [dataSourceType]); + + // 쿼리 실행 + const executeQuery = async () => { + if (!query.trim()) { + setError("쿼리를 입력해주세요."); + return; + } + + if (dataSourceType === "external_db" && !externalConnectionId) { + setError("외부 DB 커넥션을 선택해주세요."); + return; + } + + setIsExecuting(true); + setError(null); + + try { + let apiResult: { columns: string[]; rows: Record[]; rowCount: number }; + + if (dataSourceType === "external_db" && externalConnectionId) { + const result = await ExternalDbConnectionAPI.executeQuery(parseInt(externalConnectionId), query.trim()); + + if (!result.success) { + throw new Error(result.message || "외부 DB 쿼리 실행에 실패했습니다."); + } + + apiResult = { + columns: result.data?.[0] ? Object.keys(result.data[0]) : [], + rows: result.data || [], + rowCount: result.data?.length || 0, + }; + } else { + apiResult = await dashboardApi.executeQuery(query.trim()); + } + + setQueryResult({ + columns: apiResult.columns, + rows: apiResult.rows, + totalRows: apiResult.rowCount, + }); + + // 자동으로 첫 번째 필드 선택 + if (apiResult.columns.length > 0) { + if (!materialNameField) setMaterialNameField(apiResult.columns[0]); + if (!quantityField && apiResult.columns.length > 1) setQuantityField(apiResult.columns[1]); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "쿼리 실행 중 오류가 발생했습니다."; + setError(errorMessage); + } finally { + setIsExecuting(false); + } + }; + + // REST API 호출 + const executeRestApi = async () => { + if (!apiUrl.trim()) { + setError("API URL을 입력해주세요."); + return; + } + + setIsExecuting(true); + setError(null); + + try { + const response = await fetch(apiUrl, { + method: apiMethod, + }); + + if (!response.ok) { + throw new Error(`API 호출 실패: ${response.status}`); + } + + let data = await response.json(); + + // dataPath가 있으면 해당 경로의 데이터 추출 + if (apiDataPath) { + const pathParts = apiDataPath.split("."); + for (const part of pathParts) { + data = data[part]; + } + } + + // 배열이 아니면 배열로 변환 + if (!Array.isArray(data)) { + data = [data]; + } + + const columns = data.length > 0 ? Object.keys(data[0]) : []; + + setQueryResult({ + columns, + rows: data, + totalRows: data.length, + }); + + // 자동으로 첫 번째 필드 선택 + if (columns.length > 0) { + if (!materialNameField) setMaterialNameField(columns[0]); + if (!quantityField && columns.length > 1) setQuantityField(columns[1]); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "API 호출 중 오류가 발생했습니다."; + setError(errorMessage); + } finally { + setIsExecuting(false); + } + }; + + // 저장 + const handleSave = async () => { + // 검증 + if (!queryResult) { + setError("먼저 데이터를 조회해주세요."); + return; + } + + if (!materialNameField || !quantityField) { + setError("자재명과 수량 필드를 선택해주세요."); + return; + } + + if (!unit.trim()) { + setError("단위를 입력해주세요."); + return; + } + + if (selectedRowIndex >= queryResult.rows.length) { + setError("선택한 행이 유효하지 않습니다."); + return; + } + + setIsSaving(true); + + 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 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, + }; + + await onSave(updatedData); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "저장 중 오류가 발생했습니다."; + setError(errorMessage); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {/* 헤더 */} +
+

데이터 바인딩 설정

+ +
+ + {/* 에러 메시지 */} + {error && ( + + + {error} + + )} + + {/* 컨텐츠 */} +
+
+ {/* 1단계: 데이터 소스 선택 */} + +

1단계: 데이터 소스 선택

+ + setDataSourceType(value as "database" | "external_db" | "rest_api")} + > +
+ + +
+
+ + +
+
+ + +
+
+ +
+ {/* 현재 DB 또는 외부 DB */} + {dataSourceType !== "rest_api" && ( + <> + {dataSourceType === "external_db" && ( +
+ + +
+ )} + +
+ +