diff --git a/YARD_MANAGEMENT_3D_PLAN.md b/YARD_MANAGEMENT_3D_PLAN.md new file mode 100644 index 00000000..fe11a2cc --- /dev/null +++ b/YARD_MANAGEMENT_3D_PLAN.md @@ -0,0 +1,1028 @@ +# 야드 관리 3D 기능 구현 계획서 + +## 1. 기능 개요 + +### 목적 + +대시보드에서 야드(Yard)의 자재 배치 상태를 3D로 시각화하고 관리하는 위젯 + +### 주요 특징 + +- **대시보드 위젯**: 대시보드의 위젯 형태로 추가되는 기능 +- **야드 레이아웃 관리**: 여러 야드 레이아웃을 생성, 선택, 수정, 삭제 가능 +- **3D 시각화**: Three.js + React Three Fiber를 사용한 3D 렌더링 +- **자재 배치**: 3D 공간에서 자재를 직접 배치 및 이동 가능 +- **자재 정보**: 배치된 자재 클릭 시 상세 정보 표시 (읽기 전용 자재 정보 + 편집 가능한 배치 정보) + +### 위젯 통합 + +- **위젯 타입**: `yard-management-3d` +- **위치**: 대시보드 관리 > 데이터 위젯 > 야드 관리 3D +- **표시 모드**: + - 편집 모드: 플레이스홀더 표시 + - 뷰 모드: 실제 야드 관리 기능 실행 + +--- + +## 2. 데이터베이스 설계 + +### 2.1. yard_layout 테이블 + +야드 레이아웃 정보 저장 + +```sql +CREATE TABLE yard_layout ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, -- 야드 이름 (예: "A구역", "1번 야드") + description TEXT, -- 설명 + created_by VARCHAR(50), -- 생성자 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 2.2. yard_material_placement 테이블 + +야드 내 자재 배치 정보 (외부 자재 데이터 참조) + +```sql +CREATE TABLE yard_material_placement ( + id SERIAL PRIMARY KEY, + yard_layout_id INTEGER REFERENCES yard_layout(id) ON DELETE CASCADE, + + -- 외부 자재 참조 (API로 받아올 데이터) + external_material_id VARCHAR(100) NOT NULL, -- 외부 시스템 자재 ID + material_code VARCHAR(50) NOT NULL, -- 자재 코드 (캐시) + material_name VARCHAR(100) NOT NULL, -- 자재 이름 (캐시) + quantity INTEGER NOT NULL DEFAULT 1, -- 수량 (캐시) + unit VARCHAR(20) DEFAULT 'EA', -- 단위 (캐시) + + -- 3D 위치 정보 + position_x NUMERIC(10, 2) NOT NULL DEFAULT 0, -- X 좌표 + position_y NUMERIC(10, 2) NOT NULL DEFAULT 0, -- Y 좌표 (높이) + position_z NUMERIC(10, 2) NOT NULL DEFAULT 0, -- Z 좌표 + + -- 3D 크기 정보 + size_x NUMERIC(10, 2) NOT NULL DEFAULT 5, -- 너비 + size_y NUMERIC(10, 2) NOT NULL DEFAULT 5, -- 높이 + size_z NUMERIC(10, 2) NOT NULL DEFAULT 5, -- 깊이 + + -- 외관 정보 + color VARCHAR(7) DEFAULT '#3b82f6', -- 색상 (HEX) + + -- 추가 정보 + status VARCHAR(20) DEFAULT 'normal', -- 상태 (normal, alert, warning) + memo TEXT, -- 메모 + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 외부 자재 ID와 야드 레이아웃의 조합은 유니크해야 함 (중복 배치 방지) +CREATE UNIQUE INDEX idx_yard_material_unique + ON yard_material_placement(yard_layout_id, external_material_id); +``` + +### 2.3. temp_material_master 테이블 (임시 자재 마스터) + +외부 API를 받기 전까지 사용할 임시 자재 데이터 + +```sql +CREATE TABLE temp_material_master ( + id SERIAL PRIMARY KEY, + material_code VARCHAR(50) UNIQUE NOT NULL, -- 자재 코드 + material_name VARCHAR(100) NOT NULL, -- 자재 이름 + category VARCHAR(50), -- 카테고리 + unit VARCHAR(20) DEFAULT 'EA', -- 기본 단위 + default_color VARCHAR(7) DEFAULT '#3b82f6', -- 기본 색상 + description TEXT, -- 설명 + is_active BOOLEAN DEFAULT true, -- 사용 여부 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 임시 자재 마스터 샘플 데이터 +INSERT INTO temp_material_master (material_code, material_name, category, unit, default_color, description) VALUES +('MAT-STEEL-001', '철판 A타입 (1200x2400)', '철강', 'EA', '#ef4444', '두께 10mm 철판'), +('MAT-STEEL-002', '철판 B타입 (1000x2000)', '철강', 'EA', '#dc2626', '두께 8mm 철판'), +('MAT-PIPE-001', '강관 파이프 (Φ100)', '파이프', 'EA', '#10b981', '길이 6m'), +('MAT-PIPE-002', '강관 파이프 (Φ150)', '파이프', 'EA', '#059669', '길이 6m'), +('MAT-BOLT-001', '볼트 세트 M12', '부품', 'BOX', '#f59e0b', '100개/박스'), +('MAT-BOLT-002', '볼트 세트 M16', '부품', 'BOX', '#d97706', '100개/박스'), +('MAT-ANGLE-001', '앵글 (75x75x6)', '형강', 'EA', '#8b5cf6', '길이 6m'), +('MAT-ANGLE-002', '앵글 (100x100x10)', '형강', 'EA', '#7c3aed', '길이 6m'), +('MAT-CHANNEL-001', '채널 (100x50x5)', '형강', 'EA', '#06b6d4', '길이 6m'), +('MAT-WIRE-001', '와이어 로프 (Φ12)', '케이블', 'M', '#ec4899', '롤 단위'); +``` + +### 2.4. 초기 데이터 마이그레이션 스크립트 + +```sql +-- 샘플 야드 레이아웃 +INSERT INTO yard_layout (name, description, created_by) VALUES +('A구역', '메인 야드 A구역', 'admin'), +('B구역', '메인 야드 B구역', 'admin'), +('C구역', '보조 야드 C구역', 'admin'); + +-- 샘플 자재 배치 (A구역) - 임시 자재 마스터 참조 +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, status) VALUES +(1, 'TEMP-1', 'MAT-STEEL-001', '철판 A타입 (1200x2400)', 50, 'EA', 10, 0, 10, 8, 4, 8, '#ef4444', 'normal'), +(1, 'TEMP-2', 'MAT-STEEL-002', '철판 B타입 (1000x2000)', 30, 'EA', 25, 0, 10, 8, 4, 8, '#dc2626', 'normal'), +(1, 'TEMP-3', 'MAT-PIPE-001', '강관 파이프 (Φ100)', 100, 'EA', 40, 0, 10, 6, 6, 6, '#10b981', 'normal'), +(1, 'TEMP-4', 'MAT-BOLT-001', '볼트 세트 M12', 500, 'BOX', 55, 0, 10, 4, 4, 4, '#f59e0b', 'warning'); +``` + +### 2.5. 외부 자재 API 연동 구조 + +**현재 (Phase 1)**: 임시 자재 마스터 사용 + +```typescript +// temp_material_master 테이블에서 조회 +GET / api / materials / temp; +``` + +**향후 (Phase 2)**: 외부 API 연동 + +```typescript +// 외부 시스템 자재 API +GET /api/external/materials +Response: [ + { + id: "EXT-12345", + code: "MAT-STEEL-001", + name: "철판 A타입", + quantity: 150, + unit: "EA", + location: "창고A-1", + ... + } +] +``` + +--- + +## 3. 백엔드 API 설계 + +### 3.1. YardLayoutService + +**경로**: `backend-node/src/services/YardLayoutService.ts` + +**구현 완료** + +주요 메서드: + +- `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`: 새 레이아웃 생성 (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/categories`: 카테고리 목록 +- `GET /api/materials/temp/:code`: 특정 자재 상세 + +**향후**: 외부 API 프록시로 변경 예정 + +### 3.5. Routes + +**경로**: + +- `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)` + +--- + +## 4. 프론트엔드 컴포넌트 설계 + +### 4.1. YardManagement3DWidget (메인 위젯) + +**경로**: `frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx` + +**구현 완료** + +**주요 기능**: + +1. 레이아웃 선택/생성 모드와 3D 편집 모드 전환 +2. 편집 모드와 뷰 모드 구분 (isEditMode props) +3. API 연동 (yardLayoutApi) + +**상태 관리**: + +```typescript +- layouts: YardLayout[] // 전체 레이아웃 목록 +- selectedLayout: YardLayout | null // 선택된 레이아웃 +- isCreateModalOpen: boolean // 생성 모달 표시 여부 +- isLoading: boolean // 로딩 상태 +``` + +**하위 컴포넌트**: + +- `YardLayoutList`: 레이아웃 목록 표시 +- `YardLayoutCreateModal`: 새 레이아웃 생성 모달 +- `YardEditor`: 3D 편집 화면 + +### 4.2. API 클라이언트 + +**경로**: `frontend/lib/api/yardLayoutApi.ts` + +**구현 완료** + +**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` + +**구현 예정** + +- 야드 이름, 설명 입력 +- Shadcn UI Dialog 사용 +- 생성 완료 시 자동으로 편집 모드 진입 + +### 4.5. YardEditor (3D 편집 화면) + +**경로**: `frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx` + +**구현 예정** + +**주요 구성**: + +- 상단 툴바 (뒤로가기, 저장, 자재 추가 등) +- 좌측: 3D 캔버스 +- 우측: 자재 정보 패널 (선택 시 표시) + +**기술 스택**: + +- React Three Fiber +- @react-three/drei (OrbitControls, Grid, Box) +- Three.js + +**주요 기능**: + +1. 야드 바닥 그리드 표시 +2. 자재 3D 박스 렌더링 +3. 자재 클릭 이벤트 처리 +4. 자재 드래그 앤 드롭 (위치 이동) +5. 카메라 컨트롤 (회전, 줌) + +### 4.6. MaterialInfoPanel (자재 정보 패널) + +**경로**: `frontend/components/admin/dashboard/widgets/yard-3d/MaterialInfoPanel.tsx` + +**구현 예정** + +**읽기 전용 정보** (외부 자재 데이터): + +- 자재 코드 +- 자재 이름 +- 수량 +- 단위 +- 카테고리 + +**수정 가능 정보** (배치 데이터): + +- 3D 위치 (X, Y, Z) +- 3D 크기 (너비, 높이, 깊이) +- 색상 +- 메모 + +**기능**: + +- 배치 정보 수정 +- 배치 해제 (야드에서 자재 제거) + +### 4.6. MaterialLibrary (자재 라이브러리) + +**경로**: `frontend/components/admin/dashboard/widgets/MaterialLibrary.tsx` + +- 사용 가능한 자재 목록 표시 +- 자재 검색 기능 +- 자재를 3D 캔버스로 드래그하여 배치 +- 자재 마스터 데이터 조회 (기존 테이블 활용 가능) + +--- + +## 5. UI/UX 설계 + +### 5.1. 초기 화면 (선택/생성 모드) + +야드 레이아웃 목록을 테이블 형식으로 표시 + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ 야드 관리 3D [+ 새 야드 생성] │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ [검색: ________________] [정렬: 최근순 ▼] │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ 야드명 │ 설명 │ 배치 자재 │ 최종 수정 │ 작업 │ │ +│ ├────────────────────────────────────────────────────────────────────┤ │ +│ │ A구역 │ 메인 야드 A구역 │ 15개 │ 2025-01-15 14:30 │ ⋮ │ │ +│ │ │ │ │ │ │ │ +│ ├────────────────────────────────────────────────────────────────────┤ │ +│ │ B구역 │ 메인 야드 B구역 │ 8개 │ 2025-01-14 10:20 │ ⋮ │ │ +│ │ │ │ │ │ │ │ +│ ├────────────────────────────────────────────────────────────────────┤ │ +│ │ C구역 │ 보조 야드 C구역 │ 3개 │ 2025-01-10 09:15 │ ⋮ │ │ +│ │ │ │ │ │ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ 총 3개 [1] 2 3 4 5 > │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ + +작업 메뉴 (⋮ 클릭 시): +┌─────────────┐ +│ 편집 │ +│ 복제 │ +│ 삭제 │ +└─────────────┘ +``` + +### 5.2. 레이아웃 생성 모달 + +새 야드 레이아웃을 생성할 때 표시되는 모달 + +``` +┌─────────────────────────────────────────────────┐ +│ 새 야드 레이아웃 생성 [X] │ +├─────────────────────────────────────────────────┤ +│ │ +│ 야드 이름 * │ +│ [____________________________________] │ +│ │ +│ 설명 │ +│ [____________________________________] │ +│ [____________________________________] │ +│ │ +│ [취소] [생성] │ +└─────────────────────────────────────────────────┘ +``` + +### 5.3. 편집 모드 - 전체 레이아웃 + +야드 편집 화면의 전체 구성 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ A구역 [저장] [미리보기] [취소] │ +├───────────────────────────────────────┬─────────────────────────────────────┤ +│ │ 도구 패널 [최소화] │ +│ ├─────────────────────────────────────┤ +│ │ │ +│ │ 자재 라이브러리 │ +│ │ ───────────────────── │ +│ │ [검색: ____________] [카테고리 ▼] │ +│ │ │ +│ 3D 캔버스 │ ┌───────────────────────┐ │ +│ │ │ MAT-STEEL-001 │ │ +│ (야드 그리드 + 자재 배치) │ │ 철판 A타입 │ │ +│ │ │ 50 EA 재고 있음 │ │ +│ - 마우스 드래그: 카메라 회전 │ │ [배치] │ │ +│ - 휠: 줌 인/아웃 │ └───────────────────────┘ │ +│ - 자재 클릭: 선택 │ │ +│ - 자재 드래그: 이동 │ ┌───────────────────────┐ │ +│ │ │ MAT-STEEL-002 │ │ +│ │ │ 철판 B타입 │ │ +│ │ │ 30 EA 재고 있음 │ │ +│ │ │ [배치] │ │ +│ │ └───────────────────────┘ │ +│ │ │ +│ │ ┌───────────────────────┐ │ +│ │ │ MAT-PIPE-001 │ │ +│ │ │ 강관 파이프 │ │ +│ │ │ 100 EA 재고 있음 │ │ +│ │ │ [배치] │ │ +│ │ └───────────────────────┘ │ +│ │ │ +│ │ ... (스크롤 가능) │ +│ │ │ +├───────────────────────────────────────┴─────────────────────────────────────┤ +│ 자재 정보 │ +│ ───────────────── │ +│ 선택된 자재: MAT-STEEL-001 (철판 A타입) │ +│ │ +│ 기본 정보 (읽기 전용) │ +│ 자재 코드: MAT-STEEL-001 │ +│ 자재 이름: 철판 A타입 (1200x2400) │ +│ 수량: 50 EA │ +│ 카테고리: 철강 │ +│ │ +│ 배치 정보 (수정 가능) │ +│ 3D 위치 │ +│ X: [____10.00____] m Y: [____0.00____] m Z: [____10.00____] m │ +│ │ +│ 3D 크기 │ +│ 너비: [____8.00____] m 높이: [____4.00____] m 깊이: [____8.00____] m │ +│ │ +│ 외관 │ +│ 색상: [■ #ef4444] [색상 선택] │ +│ │ +│ 메모 │ +│ [_____________________________________________________________________] │ +│ │ +│ [배치 해제] [변경 적용] [초기화] │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.4. 3D 캔버스 상세 + +3D 캔버스 내부의 시각적 요소 + +``` +┌─────────────────────────────────────────────────┐ +│ 카메라 컨트롤 [리셋] │ +│ 회전: 45° | 기울기: 30° | 줌: 100% │ +├─────────────────────────────────────────────────┤ +│ Y (높이) │ +│ ↑ │ +│ │ │ +│ │ │ +│ │ ┌───────┐ (자재) │ +│ │ │ │ │ +│ Z (깊이) │ │ MAT-1 │ (선택됨) │ +│ ↗ │ │ │ │ +│ / └────┴───────┴──→ X (너비) │ +│ / │ +│ / ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ │ +│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ (그리드) │ +│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ │ +│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ │ +│ │ │ │ │ │ │□│ │ │ │ │ ← MAT-2 │ +│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ │ +│ ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ │ +│ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ │ +│ │ +│ 범례: │ +│ ■ 선택된 자재 □ 일반 자재 │ +│ ─ 그리드 라인 (5m 단위) │ +│ │ +│ 조작 가이드: │ +│ • 마우스 왼쪽 드래그: 카메라 회전 │ +│ • 마우스 휠: 줌 인/아웃 │ +│ • 자재 클릭: 선택 │ +│ • 선택된 자재 드래그: 위치 이동 │ +└─────────────────────────────────────────────────┘ +``` + +### 5.5. 자재 배치 플로우 + +자재를 배치하는 과정 + +``` +1. 자재 라이브러리에서 자재 선택 + ┌─────────────────┐ + │ MAT-STEEL-001 │ ← 클릭 + │ 철판 A타입 │ + │ [배치] │ + └─────────────────┘ + ↓ +2. 3D 캔버스에 자재가 임시로 표시됨 (투명) + ┌─────────────────┐ + │ 3D 캔버스 │ + │ │ + │ [반투명 박스] │ ← 마우스 커서 따라 이동 + │ │ + └─────────────────┘ + ↓ +3. 원하는 위치에 클릭하여 배치 + ┌─────────────────┐ + │ 3D 캔버스 │ + │ │ + │ [실제 박스] │ ← 배치 완료 + │ │ + └─────────────────┘ + ↓ +4. 자재 정보 패널에 자동으로 선택됨 + ┌─────────────────────────┐ + │ 자재 정보 패널 │ + │ ─────────────────── │ + │ 선택된 자재: │ + │ MAT-STEEL-001 │ + │ │ + │ 수량: 50 EA │ + │ 위치: X:10, Y:0, Z:10 │ + │ ... │ + └─────────────────────────┘ +``` + +### 5.6. 반응형 레이아웃 (모바일/태블릿) + +모바일에서는 패널을 접거나 탭으로 전환 + +``` +모바일 (세로 모드): +┌───────────────────────┐ +│ A구역 [저장] │ +├───────────────────────┤ +│ │ +│ 3D 캔버스 │ +│ (전체 화면) │ +│ │ +│ │ +├───────────────────────┤ +│ [자재 라이브러리] [정보]│ ← 탭 전환 +├───────────────────────┤ +│ 선택된 자재: │ +│ MAT-STEEL-001 │ +│ 수량: 50 EA │ +│ ... │ +└───────────────────────┘ + +태블릿 (가로 모드): +┌─────────────────────────────────────┐ +│ A구역 [저장] │ +├──────────────────┬──────────────────┤ +│ │ 자재 라이브러리 │ +│ 3D 캔버스 │ ────────────── │ +│ │ [검색: ______] │ +│ │ MAT-STEEL-001 │ +│ │ ... │ +├──────────────────┴──────────────────┤ +│ 선택된 자재: MAT-STEEL-001 │ +│ 수량: 50 EA | 위치: X:10, Z:10 │ +└─────────────────────────────────────┘ +``` + +--- + +## 6. 구현 단계 + +### Phase 1: 데이터베이스 및 백엔드 API ✅ **완료** + +1. ✅ 테이블 생성 스크립트 작성 (`create_yard_management_tables.sql`) +2. ✅ 마이그레이션 실행 +3. ✅ Service, Controller, Routes 구현 +4. ✅ API 클라이언트 구현 (yardLayoutApi, materialApi) + +### Phase 2: 메인 위젯 및 레이아웃 관리 🔄 **진행 중** + +1. ✅ types.ts에 위젯 타입 추가 +2. ✅ DashboardTopMenu에 위젯 추가 +3. ✅ DashboardDesigner에 위젯 타이틀/컨텐츠 추가 +4. ✅ YardManagement3DWidget 메인 컴포넌트 구현 +5. ⏳ YardLayoutList 컴포넌트 구현 +6. ⏳ YardLayoutCreateModal 컴포넌트 구현 + +### Phase 3: 3D 편집 화면 ⏳ **대기 중** + +1. ⏳ YardEditor 메인 컴포넌트 +2. ⏳ 상단 툴바 (뒤로가기, 저장, 자재 추가) +3. ⏳ 레이아웃 구성 (좌측 캔버스 + 우측 패널) + +### Phase 4: 3D 캔버스 기본 구조 ⏳ **대기 중** + +1. ⏳ Yard3DCanvas 컴포넌트 기본 구조 +2. ⏳ React Three Fiber 설정 +3. ⏳ 야드 바닥 그리드 렌더링 +4. ⏳ 카메라 컨트롤 (OrbitControls) +5. ⏳ 자재 3D 박스 렌더링 + +### Phase 5: 자재 배치 및 인터랙션 ⏳ **대기 중** + +1. ⏳ MaterialLibrary 컴포넌트 구현 +2. ⏳ 자재 선택 및 추가 +3. ⏳ 자재 드래그 앤 드롭 배치 +4. ⏳ 자재 클릭 선택 +5. ⏳ 자재 위치 이동 (드래그) + +### Phase 6: 자재 정보 패널 및 편집 ⏳ **대기 중** + +1. ⏳ MaterialInfoPanel 컴포넌트 구현 +2. ⏳ 자재 정보 표시 (읽기 전용 + 편집 가능) +3. ⏳ 자재 배치 정보 수정 +4. ⏳ 배치 해제 기능 +5. ⏳ 변경사항 저장 + +### Phase 7: 통합 및 최적화 ⏳ **대기 중** + +1. YardManagement3DWidget 통합 +2. 상태 관리 최적화 +3. 성능 최적화 (대량 자재 렌더링) +4. 에러 처리 및 로딩 상태 +5. 모바일/반응형 대응 (선택사항) + +### Phase 7: 대시보드 위젯 등록 + +1. types.ts에 위젯 타입 추가 +2. DashboardTopMenu에 위젯 추가 +3. CanvasElement에 위젯 렌더링 추가 +4. 위젯 설정 모달 (레이아웃 선택) + +--- + +## 7. 기술적 고려사항 + +### 7.1. 3D 렌더링 최적화 + +- 자재 수가 많을 경우 인스턴싱 사용 +- LOD (Level of Detail) 적용 고려 +- 카메라 거리에 따른 렌더링 최적화 + +### 7.2. 드래그 앤 드롭 + +- 3D 공간에서의 레이캐스팅 +- 그리드 스냅 기능 +- 충돌 감지 (자재 간 겹침 방지) + +### 7.3. 상태 관리 + +- 자재 위치 변경 시 실시간 업데이트 +- Debounce를 사용한 API 호출 최적화 +- 낙관적 업데이트 (Optimistic Update) + +### 7.4. 데이터 동기화 + +- 여러 사용자가 동시에 편집하는 경우 충돌 처리 +- WebSocket을 통한 실시간 동기화 (선택사항) + +### 7.5. UI/UX 규칙 + +#### 이모지 사용 금지 + +#### 모달 사용 규칙 + +**`window.alert`, `window.confirm` 사용 금지** + +모든 알림, 확인, 에러 메시지는 Shadcn UI 모달 컴포넌트 사용: + +- **일반 알림**: `Dialog` 컴포넌트 +- **확인 필요**: `AlertDialog` 컴포넌트 +- **삭제/해제 확인**: `AlertDialog` (Destructive 스타일) +- **성공 메시지**: `Dialog` 또는 `Toast` +- **에러 메시지**: `Dialog` (Error 스타일) + +**예시**: + +```typescript +// 잘못된 방법 ❌ +window.alert("저장되었습니다"); +if (window.confirm("삭제하시겠습니까?")) { ... } + +// 올바른 방법 ✅ + + + + 배치 해제 + + 이 자재를 야드에서 제거하시겠습니까? + + + + 취소 + 확인 + + + +``` + +--- + +## 8. API 명세서 + +### 8.1. 야드 레이아웃 API + +#### GET /api/yard-layouts + +**설명**: 모든 야드 레이아웃 목록 조회 + +**응답**: + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "name": "A구역", + "description": "메인 야드 A구역", + "placement_count": 15, + "created_at": "2025-01-01T00:00:00Z" + } + ] +} +``` + +#### GET /api/yard-layouts/:id + +**설명**: 특정 야드 레이아웃 상세 조회 + +**응답**: + +```json +{ + "success": true, + "data": { + "id": 1, + "name": "A구역", + "description": "메인 야드 A구역", + "created_at": "2025-01-01T00:00:00Z" + } +} +``` + +#### POST /api/yard-layouts + +**설명**: 새 야드 레이아웃 생성 + +**요청**: + +```json +{ + "name": "D구역", + "description": "신규 야드" +} +``` + +#### PUT /api/yard-layouts/:id + +**설명**: 야드 레이아웃 수정 (이름, 설명만) + +**요청**: + +```json +{ + "name": "D구역 (수정)", + "description": "수정된 설명" +} +``` + +#### DELETE /api/yard-layouts/:id + +**설명**: 야드 레이아웃 삭제 + +### 8.2. 자재 배치 API + +#### GET /api/yard-layouts/:id/materials + +**설명**: 특정 야드의 모든 자재 조회 + +**응답**: + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "material_code": "MAT-001", + "material_name": "철판 A타입", + "quantity": 50, + "unit": "EA", + "position_x": 10, + "position_y": 0, + "position_z": 10, + "size_x": 8, + "size_y": 4, + "size_z": 8, + "color": "#ef4444", + "status": "normal", + "memo": null + } + ] +} +``` + +#### POST /api/yard-layouts/:id/materials + +**설명**: 야드에 자재 추가 + +**요청**: + +```json +{ + "material_code": "MAT-005", + "material_name": "신규 자재", + "quantity": 10, + "unit": "EA", + "position_x": 20, + "position_y": 0, + "position_z": 20, + "size_x": 5, + "size_y": 5, + "size_z": 5, + "color": "#3b82f6" +} +``` + +#### PUT /api/yard-materials/:id + +**설명**: 자재 정보 수정 + +**요청**: + +```json +{ + "position_x": 25, + "position_z": 25, + "quantity": 55 +} +``` + +#### DELETE /api/yard-materials/:id + +**설명**: 자재 삭제 + +#### PUT /api/yard-layouts/:id/materials/batch + +**설명**: 여러 자재 일괄 업데이트 (드래그로 여러 자재 이동 시) + +**요청**: + +```json +{ + "materials": [ + { "id": 1, "position_x": 15, "position_z": 15 }, + { "id": 2, "position_x": 30, "position_z": 15 } + ] +} +``` + +--- + +## 9. 테스트 시나리오 + +### 9.1. 기본 기능 테스트 + +- [ ] 야드 레이아웃 목록 조회 +- [ ] 야드 레이아웃 생성 +- [ ] 야드 레이아웃 선택 +- [ ] 3D 캔버스 렌더링 +- [ ] 자재 목록 조회 및 표시 + +### 9.2. 자재 배치 테스트 + +- [ ] 자재 라이브러리에서 드래그하여 배치 +- [ ] 배치된 자재 클릭하여 선택 +- [ ] 선택된 자재 정보 패널 표시 +- [ ] 자재 드래그하여 위치 이동 +- [ ] 자재 정보 수정 (수량, 크기 등) +- [ ] 자재 삭제 + +### 9.3. 인터랙션 테스트 + +- [ ] 카메라 회전 (OrbitControls) +- [ ] 카메라 줌 인/아웃 +- [ ] 그리드 스냅 기능 +- [ ] 여러 자재 동시 이동 +- [ ] 자재 간 충돌 방지 + +### 9.4. 저장 및 로드 테스트 + +- [ ] 자재 배치 후 저장 +- [ ] 저장된 레이아웃 다시 로드 +- [ ] 레이아웃 삭제 +- [ ] 레이아웃 복제 (선택사항) + +--- + +## 10. 향후 확장 가능성 + +- 자재 검색 및 필터링 (상태별, 자재 코드별) +- 자재 배치 히스토리 (변경 이력) +- 자재 배치 템플릿 (자주 사용하는 배치 저장) +- 자재 입출고 연동 (실시간 재고 반영) +- 자재 경로 최적화 (피킹 경로 표시) +- AR/VR 지원 (모바일 AR로 실제 야드 확인) +- 다중 사용자 동시 편집 (WebSocket) +- 자재 배치 분석 (공간 활용률, 접근성 등) + +--- + +## 11. 파일 구조 + +``` +backend-node/src/ +├── services/ +│ └── YardLayoutService.ts (신규) +├── controllers/ +│ └── YardLayoutController.ts (신규) +├── routes/ +│ └── yardLayoutRoutes.ts (신규) +└── app.ts (수정) + +frontend/components/admin/dashboard/ +├── widgets/ +│ ├── YardManagement3DWidget.tsx (신규 - 메인) +│ ├── YardLayoutSelector.tsx (신규) +│ ├── YardLayoutCreator.tsx (신규) +│ ├── Yard3DCanvas.tsx (신규) +│ ├── MaterialInfoPanel.tsx (신규) +│ └── MaterialLibrary.tsx (신규) +├── types.ts (수정 - 위젯 타입 추가) +├── DashboardTopMenu.tsx (수정 - 메뉴 추가) +└── CanvasElement.tsx (수정 - 렌더링 추가) + +db/ +└── migrations/ + └── create_yard_tables.sql (신규) +``` + +--- + +## 12. 예상 개발 기간 + +- Phase 1 (DB & API): 1일 +- Phase 2 (선택/생성): 1일 +- Phase 3 (3D 기본): 1일 +- Phase 4 (배치 인터랙션): 2일 +- Phase 5 (정보 패널): 1일 +- Phase 6 (통합/최적화): 1일 +- Phase 7 (대시보드 등록): 0.5일 + +**총 예상 기간: 7.5일** + +--- + +## 13. 참고 자료 + +- React Three Fiber: https://docs.pmnd.rs/react-three-fiber +- @react-three/drei: https://github.com/pmndrs/drei +- Three.js: https://threejs.org/docs/ diff --git a/backend-node/.env.example b/backend-node/.env.example index fdba2895..807ae916 100644 --- a/backend-node/.env.example +++ b/backend-node/.env.example @@ -10,3 +10,8 @@ BOOKING_DATA_SOURCE=file MAINTENANCE_DATA_SOURCE=memory DOCUMENT_DATA_SOURCE=memory + +# OpenWeatherMap API 키 추가 (실시간 날씨) +# https://openweathermap.org/api 에서 무료 가입 후 발급 +OPENWEATHER_API_KEY=your_openweathermap_api_key_here + diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ae10a6fe..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); @@ -231,6 +235,14 @@ app.listen(PORT, HOST, async () => { logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); + // 대시보드 마이그레이션 실행 + try { + const { runDashboardMigration } = await import("./database/runMigration"); + await runDashboardMigration(); + } catch (error) { + logger.error(`❌ 대시보드 마이그레이션 실패:`, error); + } + // 배치 스케줄러 초기화 try { await BatchSchedulerService.initialize(); @@ -241,7 +253,9 @@ app.listen(PORT, HOST, async () => { // 리스크/알림 자동 갱신 시작 try { - const { RiskAlertCacheService } = await import('./services/riskAlertCacheService'); + const { RiskAlertCacheService } = await import( + "./services/riskAlertCacheService" + ); const cacheService = RiskAlertCacheService.getInstance(); cacheService.startAutoRefresh(); logger.info(`⏰ 리스크/알림 자동 갱신이 시작되었습니다. (10분 간격)`); 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/controllers/openApiProxyController.ts b/backend-node/src/controllers/openApiProxyController.ts index f737a833..b84dc218 100644 --- a/backend-node/src/controllers/openApiProxyController.ts +++ b/backend-node/src/controllers/openApiProxyController.ts @@ -17,19 +17,54 @@ export class OpenApiProxyController { console.log(`🌤️ 날씨 조회 요청: ${city}`); - // 기상청 API Hub 키 확인 + // 1순위: OpenWeatherMap API (실시간에 가까움, 10분마다 업데이트) + const openWeatherKey = process.env.OPENWEATHER_API_KEY; + if (openWeatherKey) { + try { + console.log(`🌍 OpenWeatherMap API 호출: ${city}`); + const response = await axios.get('https://api.openweathermap.org/data/2.5/weather', { + params: { + q: `${city},KR`, + appid: openWeatherKey, + units: 'metric', + lang: 'kr', + }, + timeout: 10000, + }); + + const data = response.data; + const weatherData = { + city: data.name, + country: data.sys.country, + temperature: Math.round(data.main.temp), + feelsLike: Math.round(data.main.feels_like), + humidity: data.main.humidity, + pressure: data.main.pressure, + weatherMain: data.weather[0].main, + weatherDescription: data.weather[0].description, + weatherIcon: data.weather[0].icon, + windSpeed: Math.round(data.wind.speed * 10) / 10, + clouds: data.clouds.all, + timestamp: new Date().toISOString(), + }; + + console.log(`✅ OpenWeatherMap 날씨 조회 성공: ${weatherData.city} ${weatherData.temperature}°C`); + res.json({ success: true, data: weatherData }); + return; + } catch (error) { + console.warn('⚠️ OpenWeatherMap API 실패, 기상청 API로 폴백:', error instanceof Error ? error.message : error); + } + } + + // 2순위: 기상청 API Hub (매시간 정시 데이터) const apiKey = process.env.KMA_API_KEY; - // API 키가 없으면 테스트 모드로 실시간 날씨 제공 + // API 키가 없으면 오류 반환 if (!apiKey) { - console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.'); - - const regionCode = getKMARegionCode(city as string); - const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string)); - - res.json({ - success: true, - data: weatherData, + console.log('⚠️ 기상청 API 키가 설정되지 않았습니다.'); + res.status(503).json({ + success: false, + message: '기상청 API 키가 설정되지 않았습니다. 관리자에게 문의하세요.', }); return; } @@ -48,32 +83,39 @@ export class OpenApiProxyController { // 기상청 API Hub 사용 (apihub.kma.go.kr) const now = new Date(); - // 기상청 데이터는 매시간 정시(XX:00)에 발표되고 약 10분 후 조회 가능 - // 현재 시각이 XX:10 이전이면 이전 시간 데이터 조회 - const minute = now.getMinutes(); - let targetTime = new Date(now); + // 한국 시간(KST = UTC+9)으로 변환 + const kstOffset = 9 * 60 * 60 * 1000; // 9시간을 밀리초로 + const kstNow = new Date(now.getTime() + kstOffset); - if (minute < 10) { - // 아직 이번 시간 데이터가 업데이트되지 않음 → 이전 시간으로 - targetTime = new Date(now.getTime() - 60 * 60 * 1000); - } + // 기상청 지상관측 데이터는 매시간 정시(XX:00)에 발표 + // 가장 최근의 정시 데이터를 가져오기 위해 현재 시간의 정시로 설정 + const targetTime = new Date(kstNow); // tm 파라미터: YYYYMMDDHH00 형식 (정시만 조회) - const year = targetTime.getFullYear(); - const month = String(targetTime.getMonth() + 1).padStart(2, '0'); - const day = String(targetTime.getDate()).padStart(2, '0'); - const hour = String(targetTime.getHours()).padStart(2, '0'); + const year = targetTime.getUTCFullYear(); + const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0'); + const day = String(targetTime.getUTCDate()).padStart(2, '0'); + const hour = String(targetTime.getUTCHours()).padStart(2, '0'); const tm = `${year}${month}${day}${hour}00`; + + console.log(`🕐 현재 시각(KST): ${kstNow.toISOString().slice(0, 16).replace('T', ' ')}, 조회 시각: ${tm}`); - // 기상청 API Hub - 지상관측시간자료 - const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm2.php'; + // 기상청 API Hub - 지상관측시간자료 (시간 범위 조회로 최신 데이터 확보) + // sfctm3: 시간 범위 조회 가능 (tm1~tm2) + const url = 'https://apihub.kma.go.kr/api/typ01/url/kma_sfctm3.php'; + + // 최근 1시간 범위 조회 (현재 시간 - 1시간 ~ 현재 시간) - KST 기준 + const tm1Time = new Date(kstNow.getTime() - 60 * 60 * 1000); // 1시간 전 + const tm1 = `${tm1Time.getUTCFullYear()}${String(tm1Time.getUTCMonth() + 1).padStart(2, '0')}${String(tm1Time.getUTCDate()).padStart(2, '0')}${String(tm1Time.getUTCHours()).padStart(2, '0')}00`; + const tm2 = tm; // 현재 시간 - console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 시간: ${tm})`); + console.log(`📡 기상청 API Hub 호출: ${regionCode.name} (관측소: ${regionCode.stnId}, 기간: ${tm1}~${tm2})`); const response = await axios.get(url, { params: { - tm: tm, - stn: 0, // 0 = 전체 관측소 데이터 조회 + tm1: tm1, + tm2: tm2, + stn: regionCode.stnId, // 특정 관측소만 조회 authKey: apiKey, help: 0, disp: 1, @@ -95,30 +137,36 @@ export class OpenApiProxyController { } catch (error: unknown) { console.error('❌ 날씨 조회 실패:', error); - // API 호출 실패 시 자동으로 테스트 모드로 전환 + // API 호출 실패 시 명확한 오류 메시지 반환 if (axios.isAxiosError(error)) { const status = error.response?.status; - // 모든 오류 → 테스트 데이터 반환 - console.log('⚠️ API 오류 발생. 테스트 데이터를 반환합니다.'); - const { city = '서울' } = req.query; - const regionCode = getKMARegionCode(city as string); - const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string)); - - res.json({ - success: true, - data: weatherData, - }); + if (status === 401 || status === 403) { + res.status(401).json({ + success: false, + message: '기상청 API 인증에 실패했습니다. API 키를 확인하세요.', + }); + } else if (status === 404) { + res.status(404).json({ + success: false, + message: '기상청 API에서 데이터를 찾을 수 없습니다.', + }); + } else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + res.status(504).json({ + success: false, + message: '기상청 API 연결 시간이 초과되었습니다. 잠시 후 다시 시도하세요.', + }); + } else { + res.status(500).json({ + success: false, + message: '기상청 API 호출 중 오류가 발생했습니다.', + error: error.message, + }); + } } else { - // 예상치 못한 오류 → 테스트 데이터 반환 - console.log('⚠️ 예상치 못한 오류. 테스트 데이터를 반환합니다.'); - const { city = '서울' } = req.query; - const regionCode = getKMARegionCode(city as string); - const weatherData = generateRealisticWeatherData(regionCode?.name || (city as string)); - - res.json({ - success: true, - data: weatherData, + res.status(500).json({ + success: false, + message: '날씨 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.', }); } } @@ -169,15 +217,19 @@ export class OpenApiProxyController { } catch (error: unknown) { console.error('❌ 환율 조회 실패:', error); - // API 호출 실패 시 실제 근사값 반환 - console.log('⚠️ API 오류 발생. 근사값을 반환합니다.'); - const { base = 'KRW', target = 'USD' } = req.query; - const approximateRate = generateRealisticExchangeRate(base as string, target as string); - - res.json({ - success: true, - data: approximateRate, - }); + // API 호출 실패 시 명확한 오류 메시지 반환 + if (axios.isAxiosError(error)) { + res.status(500).json({ + success: false, + message: '환율 정보를 가져오는 중 오류가 발생했습니다.', + error: error.message, + }); + } else { + res.status(500).json({ + success: false, + message: '환율 정보를 가져오는 중 예상치 못한 오류가 발생했습니다.', + }); + } } } @@ -605,19 +657,26 @@ function parseKMAHubWeatherData(data: any, regionCode: { name: string; stnId: st throw new Error('날씨 데이터를 파싱할 수 없습니다.'); } - // 요청한 관측소(stnId)의 데이터 찾기 - const targetLine = lines.find((line: string) => { + // 요청한 관측소(stnId)의 모든 데이터 찾기 (시간 범위 조회 시 여러 줄 반환됨) + const targetLines = lines.filter((line: string) => { const cols = line.trim().split(/\s+/); return cols[1] === regionCode.stnId; // STN 컬럼 (인덱스 1) }); - if (!targetLine) { + if (targetLines.length === 0) { throw new Error(`${regionCode.name} 관측소 데이터를 찾을 수 없습니다.`); } + + // 가장 최근 데이터 선택 (마지막 줄) + const targetLine = targetLines[targetLines.length - 1]; // 데이터 라인 파싱 (공백으로 구분) const values = targetLine.trim().split(/\s+/); + // 관측 시각 로깅 + const obsTime = values[0]; // YYMMDDHHMI + console.log(`🕐 관측 시각: ${obsTime} (${regionCode.name})`); + // 기상청 API Hub 데이터 형식 (실제 응답 기준): // [0]YYMMDDHHMI [1]STN [2]WD [3]WS [4]GST_WD [5]GST_WS [6]GST_TM [7]PA [8]PS [9]PT [10]PR [11]TA [12]TD [13]HM [14]PV [15]RN ... const temperature = parseFloat(values[11]) || 0; // TA: 기온 (인덱스 11) diff --git a/backend-node/src/database/runMigration.ts b/backend-node/src/database/runMigration.ts new file mode 100644 index 00000000..61b98241 --- /dev/null +++ b/backend-node/src/database/runMigration.ts @@ -0,0 +1,42 @@ +import { PostgreSQLService } from './PostgreSQLService'; + +/** + * 데이터베이스 마이그레이션 실행 + * dashboard_elements 테이블에 custom_title, show_header 컬럼 추가 + */ +export async function runDashboardMigration() { + try { + console.log('🔄 대시보드 마이그레이션 시작...'); + + // custom_title 컬럼 추가 + await PostgreSQLService.query(` + ALTER TABLE dashboard_elements + ADD COLUMN IF NOT EXISTS custom_title VARCHAR(255) + `); + console.log('✅ custom_title 컬럼 추가 완료'); + + // show_header 컬럼 추가 + await PostgreSQLService.query(` + ALTER TABLE dashboard_elements + ADD COLUMN IF NOT EXISTS show_header BOOLEAN DEFAULT true + `); + console.log('✅ show_header 컬럼 추가 완료'); + + // 기존 데이터 업데이트 + await PostgreSQLService.query(` + UPDATE dashboard_elements + SET show_header = true + WHERE show_header IS NULL + `); + console.log('✅ 기존 데이터 업데이트 완료'); + + console.log('✅ 대시보드 마이그레이션 완료!'); + } catch (error) { + console.error('❌ 대시보드 마이그레이션 실패:', error); + // 이미 컬럼이 있는 경우는 무시 + if (error instanceof Error && error.message.includes('already exists')) { + console.log('ℹ️ 컬럼이 이미 존재합니다.'); + } + } +} + 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/vehicleRoutes.ts b/backend-node/src/routes/vehicleRoutes.ts new file mode 100644 index 00000000..b8cfa8ac --- /dev/null +++ b/backend-node/src/routes/vehicleRoutes.ts @@ -0,0 +1,52 @@ +import express from "express"; +import { query } from "../database/db"; + +const router = express.Router(); + +/** + * 차량 위치 자동 업데이트 API + * - 모든 active/warning 상태 차량의 위치를 랜덤하게 조금씩 이동 + */ +router.post("/move", async (req, res) => { + try { + // move_vehicles() 함수 실행 + await query("SELECT move_vehicles()"); + + res.json({ + success: true, + message: "차량 위치가 업데이트되었습니다" + }); + } catch (error) { + console.error("차량 위치 업데이트 오류:", error); + res.status(500).json({ + success: false, + error: "차량 위치 업데이트 실패" + }); + } +}); + +/** + * 차량 위치 목록 조회 + */ +router.get("/locations", async (req, res) => { + try { + const result = await query(` + SELECT * FROM vehicle_locations + ORDER BY last_update DESC + `); + + res.json({ + success: true, + data: result.rows + }); + } catch (error) { + console.error("차량 위치 조회 오류:", error); + res.status(500).json({ + success: false, + error: "차량 위치 조회 실패" + }); + } +}); + +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/DashboardService.ts b/backend-node/src/services/DashboardService.ts index c25efe4f..c7650df2 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -1,89 +1,103 @@ -import { v4 as uuidv4 } from 'uuid'; -import { PostgreSQLService } from '../database/PostgreSQLService'; -import { - Dashboard, - DashboardElement, - CreateDashboardRequest, +import { v4 as uuidv4 } from "uuid"; +import { PostgreSQLService } from "../database/PostgreSQLService"; +import { + Dashboard, + DashboardElement, + CreateDashboardRequest, UpdateDashboardRequest, - DashboardListQuery -} from '../types/dashboard'; + DashboardListQuery, +} from "../types/dashboard"; /** * 대시보드 서비스 - Raw Query 방식 * PostgreSQL 직접 연결을 통한 CRUD 작업 */ export class DashboardService { - /** * 대시보드 생성 */ - static async createDashboard(data: CreateDashboardRequest, userId: string): Promise { + static async createDashboard( + data: CreateDashboardRequest, + userId: string + ): Promise { const dashboardId = uuidv4(); const now = new Date(); - + try { // 트랜잭션으로 대시보드와 요소들을 함께 생성 const result = await PostgreSQLService.transaction(async (client) => { // 1. 대시보드 메인 정보 저장 - await client.query(` + await client.query( + ` INSERT INTO dashboards ( id, title, description, is_public, created_by, - created_at, updated_at, tags, category, view_count - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - `, [ - dashboardId, - data.title, - data.description || null, - data.isPublic || false, - userId, - now, - now, - JSON.stringify(data.tags || []), - data.category || null, - 0 - ]); - + created_at, updated_at, tags, category, view_count, settings + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `, + [ + dashboardId, + data.title, + data.description || null, + data.isPublic || false, + userId, + now, + now, + JSON.stringify(data.tags || []), + data.category || null, + 0, + JSON.stringify(data.settings || {}), + ] + ); + // 2. 대시보드 요소들 저장 if (data.elements && data.elements.length > 0) { for (let i = 0; i < data.elements.length; i++) { const element = data.elements[i]; const elementId = uuidv4(); // 항상 새로운 UUID 생성 - - await client.query(` + + await client.query( + ` INSERT INTO dashboard_elements ( id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, - title, content, data_source_config, chart_config, + title, custom_title, show_header, content, data_source_config, chart_config, + list_config, yard_config, display_order, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - `, [ - elementId, - dashboardId, - element.type, - element.subtype, - element.position.x, - element.position.y, - element.size.width, - element.size.height, - element.title, - element.content || null, - JSON.stringify(element.dataSource || {}), - JSON.stringify(element.chartConfig || {}), - i, - now, - now - ]); + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + `, + [ + elementId, + dashboardId, + element.type, + element.subtype, + element.position.x, + element.position.y, + element.size.width, + element.size.height, + element.title, + element.customTitle || null, + element.showHeader !== false, // 기본값 true + element.content || null, + JSON.stringify(element.dataSource || {}), + JSON.stringify(element.chartConfig || {}), + JSON.stringify(element.listConfig || null), + JSON.stringify(element.yardConfig || null), + i, + now, + now, + ] + ); } } - + return dashboardId; }); - + // 생성된 대시보드 반환 try { const dashboard = await this.getDashboardById(dashboardId, userId); if (!dashboard) { - console.error('대시보드 생성은 성공했으나 조회에 실패:', dashboardId); + console.error("대시보드 생성은 성공했으나 조회에 실패:", dashboardId); // 생성은 성공했으므로 기본 정보만이라도 반환 return { id: dashboardId, @@ -97,13 +111,13 @@ export class DashboardService { tags: data.tags || [], category: data.category, viewCount: 0, - elements: data.elements || [] + elements: data.elements || [], }; } - + return dashboard; } catch (fetchError) { - console.error('생성된 대시보드 조회 중 오류:', fetchError); + console.error("생성된 대시보드 조회 중 오류:", fetchError); // 생성은 성공했으므로 기본 정보 반환 return { id: dashboardId, @@ -117,76 +131,79 @@ export class DashboardService { tags: data.tags || [], category: data.category, viewCount: 0, - elements: data.elements || [] + elements: data.elements || [], }; } - } catch (error) { - console.error('Dashboard creation error:', error); + console.error("Dashboard creation error:", error); throw error; } } - + /** * 대시보드 목록 조회 */ static async getDashboards(query: DashboardListQuery, userId?: string) { - const { - page = 1, - limit = 20, - search, - category, - isPublic, - createdBy + const { + page = 1, + limit = 20, + search, + category, + isPublic, + createdBy, } = query; - + const offset = (page - 1) * limit; - + try { // 기본 WHERE 조건 - let whereConditions = ['d.deleted_at IS NULL']; + let whereConditions = ["d.deleted_at IS NULL"]; let params: any[] = []; let paramIndex = 1; - + // 권한 필터링 if (userId) { - whereConditions.push(`(d.created_by = $${paramIndex} OR d.is_public = true)`); + whereConditions.push( + `(d.created_by = $${paramIndex} OR d.is_public = true)` + ); params.push(userId); paramIndex++; } else { - whereConditions.push('d.is_public = true'); + whereConditions.push("d.is_public = true"); } - + // 검색 조건 if (search) { - whereConditions.push(`(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})`); + whereConditions.push( + `(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})` + ); params.push(`%${search}%`, `%${search}%`); paramIndex += 2; } - + // 카테고리 필터 if (category) { whereConditions.push(`d.category = $${paramIndex}`); params.push(category); paramIndex++; } - + // 공개/비공개 필터 - if (typeof isPublic === 'boolean') { + if (typeof isPublic === "boolean") { whereConditions.push(`d.is_public = $${paramIndex}`); params.push(isPublic); paramIndex++; } - + // 작성자 필터 if (createdBy) { whereConditions.push(`d.created_by = $${paramIndex}`); params.push(createdBy); paramIndex++; } - - const whereClause = whereConditions.join(' AND '); - + + const whereClause = whereConditions.join(" AND "); + // 대시보드 목록 조회 (users 테이블 조인 제거) const dashboardQuery = ` SELECT @@ -211,22 +228,23 @@ export class DashboardService { ORDER BY d.updated_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; - - const dashboardResult = await PostgreSQLService.query( - dashboardQuery, - [...params, limit, offset] - ); - + + const dashboardResult = await PostgreSQLService.query(dashboardQuery, [ + ...params, + limit, + offset, + ]); + // 전체 개수 조회 const countQuery = ` SELECT COUNT(DISTINCT d.id) as total FROM dashboards d WHERE ${whereClause} `; - + const countResult = await PostgreSQLService.query(countQuery, params); - const total = parseInt(countResult.rows[0]?.total || '0'); - + const total = parseInt(countResult.rows[0]?.total || "0"); + return { dashboards: dashboardResult.rows.map((row: any) => ({ id: row.id, @@ -237,33 +255,36 @@ export class DashboardService { createdBy: row.created_by, createdAt: row.created_at, updatedAt: row.updated_at, - tags: JSON.parse(row.tags || '[]'), + tags: JSON.parse(row.tags || "[]"), category: row.category, - viewCount: parseInt(row.view_count || '0'), - elementsCount: parseInt(row.elements_count || '0') + viewCount: parseInt(row.view_count || "0"), + elementsCount: parseInt(row.elements_count || "0"), })), pagination: { page, limit, total, - totalPages: Math.ceil(total / limit) - } + totalPages: Math.ceil(total / limit), + }, }; } catch (error) { - console.error('Dashboard list error:', error); + console.error("Dashboard list error:", error); throw error; } } - + /** * 대시보드 상세 조회 */ - static async getDashboardById(dashboardId: string, userId?: string): Promise { + static async getDashboardById( + dashboardId: string, + userId?: string + ): Promise { try { // 1. 대시보드 기본 정보 조회 (권한 체크 포함) let dashboardQuery: string; let dashboardParams: any[]; - + if (userId) { dashboardQuery = ` SELECT d.* @@ -281,51 +302,62 @@ export class DashboardService { `; dashboardParams = [dashboardId]; } - - const dashboardResult = await PostgreSQLService.query(dashboardQuery, dashboardParams); - + + const dashboardResult = await PostgreSQLService.query( + dashboardQuery, + dashboardParams + ); + if (dashboardResult.rows.length === 0) { return null; } - + const dashboard = dashboardResult.rows[0]; - + // 2. 대시보드 요소들 조회 const elementsQuery = ` SELECT * FROM dashboard_elements WHERE dashboard_id = $1 ORDER BY display_order ASC `; - - const elementsResult = await PostgreSQLService.query(elementsQuery, [dashboardId]); - + + const elementsResult = await PostgreSQLService.query(elementsQuery, [ + dashboardId, + ]); + // 3. 요소 데이터 변환 - console.log('📊 대시보드 요소 개수:', elementsResult.rows.length); - - const elements: DashboardElement[] = elementsResult.rows.map((row: any, index: number) => { - const element = { + const elements: DashboardElement[] = elementsResult.rows.map( + (row: any) => ({ id: row.id, type: row.element_type, subtype: row.element_subtype, position: { x: row.position_x, - y: row.position_y + y: row.position_y, }, size: { width: row.width, - height: row.height + height: row.height, }, title: row.title, + customTitle: row.custom_title || undefined, + showHeader: row.show_header !== false, // 기본값 true content: row.content, - dataSource: JSON.parse(row.data_source_config || '{}'), - chartConfig: JSON.parse(row.chart_config || '{}') - }; - - console.log(`📊 위젯 #${index + 1}: type="${element.type}", subtype="${element.subtype}", title="${element.title}"`); - - return element; - }); - + dataSource: JSON.parse(row.data_source_config || "{}"), + chartConfig: JSON.parse(row.chart_config || "{}"), + listConfig: row.list_config + ? typeof row.list_config === "string" + ? JSON.parse(row.list_config) + : row.list_config + : undefined, + yardConfig: row.yard_config + ? typeof row.yard_config === "string" + ? JSON.parse(row.yard_config) + : row.yard_config + : undefined, + }) + ); + return { id: dashboard.id, title: dashboard.title, @@ -335,44 +367,48 @@ export class DashboardService { createdBy: dashboard.created_by, createdAt: dashboard.created_at, updatedAt: dashboard.updated_at, - tags: JSON.parse(dashboard.tags || '[]'), + tags: JSON.parse(dashboard.tags || "[]"), category: dashboard.category, - viewCount: parseInt(dashboard.view_count || '0'), - elements + viewCount: parseInt(dashboard.view_count || "0"), + settings: dashboard.settings || undefined, + elements, }; } catch (error) { - console.error('Dashboard get error:', error); + console.error("Dashboard get error:", error); throw error; } } - + /** * 대시보드 업데이트 */ static async updateDashboard( - dashboardId: string, - data: UpdateDashboardRequest, + dashboardId: string, + data: UpdateDashboardRequest, userId: string ): Promise { try { const result = await PostgreSQLService.transaction(async (client) => { // 권한 체크 - const authCheckResult = await client.query(` + const authCheckResult = await client.query( + ` SELECT id FROM dashboards WHERE id = $1 AND created_by = $2 AND deleted_at IS NULL - `, [dashboardId, userId]); - + `, + [dashboardId, userId] + ); + if (authCheckResult.rows.length === 0) { - throw new Error('대시보드를 찾을 수 없거나 수정 권한이 없습니다.'); + throw new Error("대시보드를 찾을 수 없거나 수정 권한이 없습니다."); } - + const now = new Date(); - + // 1. 대시보드 메인 정보 업데이트 const updateFields: string[] = []; const updateParams: any[] = []; let paramIndex = 1; - + if (data.title !== undefined) { updateFields.push(`title = $${paramIndex}`); updateParams.push(data.title); @@ -398,120 +434,146 @@ export class DashboardService { updateParams.push(data.category); paramIndex++; } - + if (data.settings !== undefined) { + updateFields.push(`settings = $${paramIndex}`); + updateParams.push(JSON.stringify(data.settings)); + paramIndex++; + } + updateFields.push(`updated_at = $${paramIndex}`); updateParams.push(now); paramIndex++; - + updateParams.push(dashboardId); - - if (updateFields.length > 1) { // updated_at 외에 다른 필드가 있는 경우 + + if (updateFields.length > 1) { + // updated_at 외에 다른 필드가 있는 경우 const updateQuery = ` UPDATE dashboards - SET ${updateFields.join(', ')} + SET ${updateFields.join(", ")} WHERE id = $${paramIndex} `; - + await client.query(updateQuery, updateParams); } - + // 2. 요소 업데이트 (있는 경우) if (data.elements) { // 기존 요소들 삭제 - await client.query(` + await client.query( + ` DELETE FROM dashboard_elements WHERE dashboard_id = $1 - `, [dashboardId]); - + `, + [dashboardId] + ); + // 새 요소들 추가 for (let i = 0; i < data.elements.length; i++) { const element = data.elements[i]; const elementId = uuidv4(); - - await client.query(` + + await client.query( + ` INSERT INTO dashboard_elements ( id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, - title, content, data_source_config, chart_config, + title, custom_title, show_header, content, data_source_config, chart_config, + list_config, yard_config, display_order, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - `, [ - elementId, - dashboardId, - element.type, - element.subtype, - element.position.x, - element.position.y, - element.size.width, - element.size.height, - element.title, - element.content || null, - JSON.stringify(element.dataSource || {}), - JSON.stringify(element.chartConfig || {}), - i, - now, - now - ]); + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + `, + [ + elementId, + dashboardId, + element.type, + element.subtype, + element.position.x, + element.position.y, + element.size.width, + element.size.height, + element.title, + element.customTitle || null, + element.showHeader !== false, // 기본값 true + element.content || null, + JSON.stringify(element.dataSource || {}), + JSON.stringify(element.chartConfig || {}), + JSON.stringify(element.listConfig || null), + JSON.stringify(element.yardConfig || null), + i, + now, + now, + ] + ); } } - + return dashboardId; }); - + // 업데이트된 대시보드 반환 return await this.getDashboardById(dashboardId, userId); - } catch (error) { - console.error('Dashboard update error:', error); + console.error("Dashboard update error:", error); throw error; } } - + /** * 대시보드 삭제 (소프트 삭제) */ - static async deleteDashboard(dashboardId: string, userId: string): Promise { + static async deleteDashboard( + dashboardId: string, + userId: string + ): Promise { try { const now = new Date(); - - const result = await PostgreSQLService.query(` + + const result = await PostgreSQLService.query( + ` UPDATE dashboards SET deleted_at = $1, updated_at = $2 WHERE id = $3 AND created_by = $4 AND deleted_at IS NULL - `, [now, now, dashboardId, userId]); - + `, + [now, now, dashboardId, userId] + ); + return (result.rowCount || 0) > 0; } catch (error) { - console.error('Dashboard delete error:', error); + console.error("Dashboard delete error:", error); throw error; } } - + /** * 조회수 증가 */ static async incrementViewCount(dashboardId: string): Promise { try { - await PostgreSQLService.query(` + await PostgreSQLService.query( + ` UPDATE dashboards SET view_count = view_count + 1 WHERE id = $1 AND deleted_at IS NULL - `, [dashboardId]); + `, + [dashboardId] + ); } catch (error) { - console.error('View count increment error:', error); + console.error("View count increment error:", error); // 조회수 증가 실패는 치명적이지 않으므로 에러를 던지지 않음 } } - + /** * 사용자 권한 체크 */ static async checkUserPermission( - dashboardId: string, - userId: string, - requiredPermission: 'view' | 'edit' | 'admin' = 'view' + dashboardId: string, + userId: string, + requiredPermission: "view" | "edit" | "admin" = "view" ): Promise { try { - const result = await PostgreSQLService.query(` + const result = await PostgreSQLService.query( + ` SELECT CASE WHEN d.created_by = $2 THEN 'admin' @@ -520,23 +582,26 @@ export class DashboardService { END as permission FROM dashboards d WHERE d.id = $1 AND d.deleted_at IS NULL - `, [dashboardId, userId]); - + `, + [dashboardId, userId] + ); + if (result.rows.length === 0) { return false; } - + const userPermission = result.rows[0].permission; - + // 권한 레벨 체크 - const permissionLevels = { 'view': 1, 'edit': 2, 'admin': 3 }; - const userLevel = permissionLevels[userPermission as keyof typeof permissionLevels] || 0; + const permissionLevels = { view: 1, edit: 2, admin: 3 }; + const userLevel = + permissionLevels[userPermission as keyof typeof permissionLevels] || 0; const requiredLevel = permissionLevels[requiredPermission]; - + return userLevel >= requiredLevel; } catch (error) { - console.error('Permission check error:', error); + console.error("Permission check error:", error); return false; } } -} \ No newline at end of file +} 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/backend-node/src/services/riskAlertService.ts b/backend-node/src/services/riskAlertService.ts index d911de94..514d3e95 100644 --- a/backend-node/src/services/riskAlertService.ts +++ b/backend-node/src/services/riskAlertService.ts @@ -25,8 +25,8 @@ export class RiskAlertService { const apiKey = process.env.KMA_API_KEY; if (!apiKey) { - console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.'); - return this.generateDummyWeatherAlerts(); + console.log('⚠️ 기상청 API 키가 없습니다. 빈 데이터를 반환합니다.'); + return []; } const alerts: Alert[] = []; @@ -109,7 +109,7 @@ export class RiskAlertService { console.log(`✅ 총 ${alerts.length}건의 기상특보 감지`); } catch (warningError: any) { console.error('❌ 기상청 특보 API 오류:', warningError.message); - return this.generateDummyWeatherAlerts(); + return []; } // 특보가 없으면 빈 배열 반환 (0건) @@ -120,8 +120,8 @@ export class RiskAlertService { return alerts; } catch (error: any) { console.error('❌ 기상청 특보 API 오류:', error.message); - // API 오류 시 더미 데이터 반환 - return this.generateDummyWeatherAlerts(); + // API 오류 시 빈 배열 반환 + return []; } } @@ -237,9 +237,9 @@ export class RiskAlertService { console.error('❌ 한국도로공사 API 오류:', error.message); } - // 모든 API 실패 시 더미 데이터 - console.log('ℹ️ 모든 교통사고 API 실패. 더미 데이터를 반환합니다.'); - return this.generateDummyAccidentAlerts(); + // 모든 API 실패 시 빈 배열 + console.log('ℹ️ 모든 교통사고 API 실패. 빈 배열을 반환합니다.'); + return []; } /** @@ -356,9 +356,9 @@ export class RiskAlertService { console.error('❌ 한국도로공사 API 오류:', error.message); } - // 모든 API 실패 시 더미 데이터 - console.log('ℹ️ 모든 도로공사 API 실패. 더미 데이터를 반환합니다.'); - return this.generateDummyRoadworkAlerts(); + // 모든 API 실패 시 빈 배열 + console.log('ℹ️ 모든 도로공사 API 실패. 빈 배열을 반환합니다.'); + return []; } /** @@ -467,82 +467,5 @@ export class RiskAlertService { return 'low'; } - /** - * 테스트용 날씨 특보 더미 데이터 - */ - private generateDummyWeatherAlerts(): Alert[] { - return [ - { - id: `weather-${Date.now()}-1`, - type: 'weather', - severity: 'high', - title: '대설특보', - location: '강원 영동지역', - description: '시간당 2cm 이상 폭설. 차량 운행 주의', - timestamp: new Date(Date.now() - 30 * 60000).toISOString(), - }, - { - id: `weather-${Date.now()}-2`, - type: 'weather', - severity: 'medium', - title: '강풍특보', - location: '남해안 전 지역', - description: '순간 풍속 20m/s 이상. 고속도로 주행 주의', - timestamp: new Date(Date.now() - 90 * 60000).toISOString(), - }, - ]; - } - - /** - * 테스트용 교통사고 더미 데이터 - */ - private generateDummyAccidentAlerts(): Alert[] { - return [ - { - id: `accident-${Date.now()}-1`, - type: 'accident', - severity: 'high', - title: '교통사고 발생', - location: '경부고속도로 서울방향 189km', - description: '3중 추돌사고로 2차로 통제 중. 우회 권장', - timestamp: new Date(Date.now() - 10 * 60000).toISOString(), - }, - { - id: `accident-${Date.now()}-2`, - type: 'accident', - severity: 'medium', - title: '사고 다발 지역', - location: '영동고속도로 강릉방향 160km', - description: '안개로 인한 가시거리 50m 이하. 서행 운전', - timestamp: new Date(Date.now() - 60 * 60000).toISOString(), - }, - ]; - } - - /** - * 테스트용 도로공사 더미 데이터 - */ - private generateDummyRoadworkAlerts(): Alert[] { - return [ - { - id: `construction-${Date.now()}-1`, - type: 'construction', - severity: 'medium', - title: '도로 공사', - location: '서울외곽순환 목동IC~화곡IC', - description: '야간 공사로 1차로 통제 (22:00~06:00)', - timestamp: new Date(Date.now() - 45 * 60000).toISOString(), - }, - { - id: `construction-${Date.now()}-2`, - type: 'construction', - severity: 'low', - title: '도로 통제', - location: '중부내륙고속도로 김천JC~현풍IC', - description: '도로 유지보수 작업. 차량 속도 제한 60km/h', - timestamp: new Date(Date.now() - 120 * 60000).toISOString(), - }, - ]; - } } diff --git a/backend-node/src/types/dashboard.ts b/backend-node/src/types/dashboard.ts index c37beae8..b03acbff 100644 --- a/backend-node/src/types/dashboard.ts +++ b/backend-node/src/types/dashboard.ts @@ -4,8 +4,8 @@ export interface DashboardElement { id: string; - type: 'chart' | 'widget'; - subtype: 'bar' | 'pie' | 'line' | 'exchange' | 'weather'; + type: "chart" | "widget"; + subtype: "bar" | "pie" | "line" | "exchange" | "weather"; position: { x: number; y: number; @@ -15,9 +15,11 @@ export interface DashboardElement { height: number; }; title: string; + customTitle?: string; // 사용자 정의 제목 (옵션) + showHeader?: boolean; // 헤더 표시 여부 (기본값: true) content?: string; dataSource?: { - type: 'api' | 'database' | 'static'; + type: "api" | "database" | "static"; endpoint?: string; query?: string; refreshInterval?: number; @@ -28,11 +30,21 @@ export interface DashboardElement { xAxis?: string; yAxis?: string; groupBy?: string; - aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min'; + aggregation?: "sum" | "avg" | "count" | "max" | "min"; colors?: string[]; title?: string; showLegend?: boolean; }; + listConfig?: { + columns?: any[]; + pagination?: any; + viewMode?: string; + cardColumns?: number; + }; + yardConfig?: { + layoutId: number; + layoutName?: string; + }; } export interface Dashboard { @@ -48,6 +60,10 @@ export interface Dashboard { tags?: string[]; category?: string; viewCount: number; + settings?: { + resolution?: string; + backgroundColor?: string; + }; elements: DashboardElement[]; } @@ -58,6 +74,10 @@ export interface CreateDashboardRequest { elements: DashboardElement[]; tags?: string[]; category?: string; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } export interface UpdateDashboardRequest { @@ -67,6 +87,10 @@ export interface UpdateDashboardRequest { elements?: DashboardElement[]; tags?: string[]; category?: string; + settings?: { + resolution?: string; + backgroundColor?: string; + }; } export interface DashboardListQuery { @@ -83,7 +107,7 @@ export interface DashboardShare { dashboardId: string; sharedWithUser?: string; sharedWithRole?: string; - permissionLevel: 'view' | 'edit' | 'admin'; + permissionLevel: "view" | "edit" | "admin"; createdBy: string; createdAt: string; expiresAt?: string; diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index dcf81963..d1ca6125 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -15,7 +15,18 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Plus, Search, MoreVertical, Edit, Trash2, Copy, Eye } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Plus, Search, MoreVertical, Edit, Trash2, Copy, CheckCircle2 } from "lucide-react"; /** * 대시보드 관리 페이지 @@ -29,6 +40,12 @@ export default function DashboardListPage() { const [searchTerm, setSearchTerm] = useState(""); const [error, setError] = useState(null); + // 모달 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null); + const [successDialogOpen, setSuccessDialogOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(""); + // 대시보드 목록 로드 const loadDashboards = async () => { try { @@ -48,38 +65,51 @@ export default function DashboardListPage() { loadDashboards(); }, [searchTerm]); - // 대시보드 삭제 - const handleDelete = async (id: string, title: string) => { - if (!confirm(`"${title}" 대시보드를 삭제하시겠습니까?`)) { - return; - } + // 대시보드 삭제 확인 모달 열기 + const handleDeleteClick = (id: string, title: string) => { + setDeleteTarget({ id, title }); + setDeleteDialogOpen(true); + }; + + // 대시보드 삭제 실행 + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; try { - await dashboardApi.deleteDashboard(id); - alert("대시보드가 삭제되었습니다."); + await dashboardApi.deleteDashboard(deleteTarget.id); + setDeleteDialogOpen(false); + setDeleteTarget(null); + setSuccessMessage("대시보드가 삭제되었습니다."); + setSuccessDialogOpen(true); loadDashboards(); } catch (err) { console.error("Failed to delete dashboard:", err); - alert("대시보드 삭제에 실패했습니다."); + setDeleteDialogOpen(false); + setError("대시보드 삭제에 실패했습니다."); } }; // 대시보드 복사 const handleCopy = async (dashboard: Dashboard) => { try { + // 전체 대시보드 정보(요소 포함)를 가져오기 + const fullDashboard = await dashboardApi.getDashboard(dashboard.id); + const newDashboard = await dashboardApi.createDashboard({ - title: `${dashboard.title} (복사본)`, - description: dashboard.description, - elements: dashboard.elements || [], + title: `${fullDashboard.title} (복사본)`, + description: fullDashboard.description, + elements: fullDashboard.elements || [], isPublic: false, - tags: dashboard.tags, - category: dashboard.category, + tags: fullDashboard.tags, + category: fullDashboard.category, + settings: (fullDashboard as any).settings, // 해상도와 배경색 설정도 복사 }); - alert("대시보드가 복사되었습니다."); + setSuccessMessage("대시보드가 복사되었습니다."); + setSuccessDialogOpen(true); loadDashboards(); } catch (err) { console.error("Failed to copy dashboard:", err); - alert("대시보드 복사에 실패했습니다."); + setError("대시보드 복사에 실패했습니다."); } }; @@ -156,8 +186,6 @@ export default function DashboardListPage() { 제목 설명 - 요소 수 - 상태 생성일 수정일 작업 @@ -166,29 +194,10 @@ export default function DashboardListPage() { {dashboards.map((dashboard) => ( - -
- {dashboard.title} - {dashboard.isPublic && ( - - 공개 - - )} -
-
+ {dashboard.title} {dashboard.description || "-"} - - {dashboard.elementsCount || 0}개 - - - {dashboard.isPublic ? ( - 공개 - ) : ( - 비공개 - )} - {formatDate(dashboard.createdAt)} {formatDate(dashboard.updatedAt)} @@ -199,10 +208,6 @@ export default function DashboardListPage() { - router.push(`/dashboard/${dashboard.id}`)} className="gap-2"> - - 보기 - router.push(`/admin/dashboard/edit/${dashboard.id}`)} className="gap-2" @@ -215,7 +220,7 @@ export default function DashboardListPage() { 복사 handleDelete(dashboard.id, dashboard.title)} + onClick={() => handleDeleteClick(dashboard.id, dashboard.title)} className="gap-2 text-red-600 focus:text-red-600" > @@ -231,6 +236,41 @@ export default function DashboardListPage() { )} + + {/* 삭제 확인 모달 */} + + + + 대시보드 삭제 + + "{deleteTarget?.title}" 대시보드를 삭제하시겠습니까? +
이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+ + {/* 성공 모달 */} + + + +
+ +
+ 완료 + {successMessage} +
+
+ +
+
+
); } diff --git a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx index 0705d77b..f8af0d0f 100644 --- a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx +++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx @@ -25,6 +25,10 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { title: string; description?: string; elements: DashboardElement[]; + settings?: { + backgroundColor?: string; + resolution?: string; + }; createdAt: string; updatedAt: string; } | null>(null); @@ -101,8 +105,8 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { return (
- {/* 대시보드 헤더 */} -
+ {/* 대시보드 헤더 - 보기 모드에서는 숨김 */} + {/*

{dashboard.title}

@@ -110,7 +114,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
- {/* 새로고침 버튼 */} + {/* 새로고침 버튼 *\/} - {/* 편집 버튼 */} + {/* 편집 버튼 *\/}
- {/* 메타 정보 */} + {/* 메타 정보 *\/}
생성: {new Date(dashboard.createdAt).toLocaleString()} 수정: {new Date(dashboard.updatedAt).toLocaleString()} 요소: {dashboard.elements.length}개
-
+
*/} {/* 대시보드 뷰어 */} -
- -
+
); } diff --git a/frontend/components/admin/MenuFormModal.tsx b/frontend/components/admin/MenuFormModal.tsx index f8d80592..ad9edfc4 100644 --- a/frontend/components/admin/MenuFormModal.tsx +++ b/frontend/components/admin/MenuFormModal.tsx @@ -47,12 +47,12 @@ export const MenuFormModal: React.FC = ({ uiTexts, }) => { // console.log("🎯 MenuFormModal 렌더링 - Props:", { - // isOpen, - // menuId, - // parentId, - // menuType, - // level, - // parentCompanyCode, + // isOpen, + // menuId, + // parentId, + // menuType, + // level, + // parentCompanyCode, // }); // 다국어 텍스트 가져오기 함수 @@ -75,12 +75,18 @@ export const MenuFormModal: React.FC = ({ }); // 화면 할당 관련 상태 - const [urlType, setUrlType] = useState<"direct" | "screen">("screen"); // URL 직접 입력 or 화면 할당 (기본값: 화면 할당) + const [urlType, setUrlType] = useState<"direct" | "screen" | "dashboard">("screen"); // URL 직접 입력 or 화면 할당 or 대시보드 할당 (기본값: 화면 할당) const [selectedScreen, setSelectedScreen] = useState(null); const [screens, setScreens] = useState([]); const [screenSearchText, setScreenSearchText] = useState(""); const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false); + // 대시보드 할당 관련 상태 + const [selectedDashboard, setSelectedDashboard] = useState(null); + const [dashboards, setDashboards] = useState([]); + const [dashboardSearchText, setDashboardSearchText] = useState(""); + const [isDashboardDropdownOpen, setIsDashboardDropdownOpen] = useState(false); + const [loading, setLoading] = useState(false); const [isEdit, setIsEdit] = useState(false); const [companies, setCompanies] = useState([]); @@ -93,21 +99,6 @@ export const MenuFormModal: React.FC = ({ try { const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기 - // console.log("🔍 화면 목록 로드 디버깅:", { - // totalScreens: response.data.length, - // firstScreen: response.data[0], - // firstScreenFields: response.data[0] ? Object.keys(response.data[0]) : [], - // firstScreenValues: response.data[0] ? Object.values(response.data[0]) : [], - // allScreenIds: response.data - // .map((s) => ({ - // screenId: s.screenId, - // legacyId: s.id, - // name: s.screenName, - // code: s.screenCode, - // })) - // .slice(0, 5), // 처음 5개만 출력 - // }); - setScreens(response.data); console.log("✅ 화면 목록 로드 완료:", response.data.length); } catch (error) { @@ -116,15 +107,28 @@ export const MenuFormModal: React.FC = ({ } }; + // 대시보드 목록 로드 + const loadDashboards = async () => { + try { + const { dashboardApi } = await import("@/lib/api/dashboard"); + const response = await dashboardApi.getMyDashboards(); + setDashboards(response.dashboards || []); + console.log("✅ 대시보드 목록 로드 완료:", response.dashboards?.length || 0); + } catch (error) { + console.error("❌ 대시보드 목록 로드 실패:", error); + toast.error("대시보드 목록을 불러오는데 실패했습니다."); + } + }; + // 화면 선택 시 URL 자동 설정 const handleScreenSelect = (screen: ScreenDefinition) => { // console.log("🖥️ 화면 선택 디버깅:", { - // screen, - // screenId: screen.screenId, - // screenIdType: typeof screen.screenId, - // legacyId: screen.id, - // allFields: Object.keys(screen), - // screenValues: Object.values(screen), + // screen, + // screenId: screen.screenId, + // screenIdType: typeof screen.screenId, + // legacyId: screen.id, + // allFields: Object.keys(screen), + // screenValues: Object.values(screen), // }); // ScreenDefinition에서는 screenId 필드를 사용 @@ -155,24 +159,42 @@ export const MenuFormModal: React.FC = ({ })); // console.log("🖥️ 화면 선택 완료:", { - // screenId: screen.screenId, - // legacyId: screen.id, - // actualScreenId, - // screenName: screen.screenName, - // menuType: menuType, - // formDataMenuType: formData.menuType, - // isAdminMenu, - // generatedUrl: screenUrl, + // screenId: screen.screenId, + // legacyId: screen.id, + // actualScreenId, + // screenName: screen.screenName, + // menuType: menuType, + // formDataMenuType: formData.menuType, + // isAdminMenu, + // generatedUrl: screenUrl, // }); }; + // 대시보드 선택 시 URL 자동 설정 + const handleDashboardSelect = (dashboard: any) => { + setSelectedDashboard(dashboard); + setIsDashboardDropdownOpen(false); + + // 대시보드 URL 생성 + let dashboardUrl = `/dashboard/${dashboard.id}`; + + // 현재 메뉴 타입이 관리자인지 확인 (0 또는 "admin") + const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0"; + if (isAdminMenu) { + dashboardUrl += "?mode=admin"; + } + + setFormData((prev) => ({ ...prev, menuUrl: dashboardUrl })); + toast.success(`대시보드가 선택되었습니다: ${dashboard.title}`); + }; + // URL 타입 변경 시 처리 - const handleUrlTypeChange = (type: "direct" | "screen") => { + const handleUrlTypeChange = (type: "direct" | "screen" | "dashboard") => { // console.log("🔄 URL 타입 변경:", { - // from: urlType, - // to: type, - // currentSelectedScreen: selectedScreen?.screenName, - // currentUrl: formData.menuUrl, + // from: urlType, + // to: type, + // currentSelectedScreen: selectedScreen?.screenName, + // currentUrl: formData.menuUrl, // }); setUrlType(type); @@ -286,10 +308,10 @@ export const MenuFormModal: React.FC = ({ const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1]; if (screenId) { // console.log("🔍 기존 메뉴에서 화면 ID 추출:", { - // menuUrl, - // screenId, - // hasAdminParam: menuUrl.includes("mode=admin"), - // currentScreensCount: screens.length, + // menuUrl, + // screenId, + // hasAdminParam: menuUrl.includes("mode=admin"), + // currentScreensCount: screens.length, // }); // 화면 설정 함수 @@ -298,15 +320,15 @@ export const MenuFormModal: React.FC = ({ if (screen) { setSelectedScreen(screen); // console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", { - // screen, - // originalUrl: menuUrl, - // hasAdminParam: menuUrl.includes("mode=admin"), + // screen, + // originalUrl: menuUrl, + // hasAdminParam: menuUrl.includes("mode=admin"), // }); return true; } else { // console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", { - // screenId, - // availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })), + // screenId, + // availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })), // }); return false; } @@ -325,30 +347,34 @@ export const MenuFormModal: React.FC = ({ }, 500); } } + } else if (menuUrl.startsWith("/dashboard/")) { + setUrlType("dashboard"); + setSelectedScreen(null); + // 대시보드 ID 추출 및 선택은 useEffect에서 처리됨 } else { setUrlType("direct"); setSelectedScreen(null); } // console.log("설정된 폼 데이터:", { - // objid: menu.objid || menu.OBJID, - // parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0", - // menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "", - // menuUrl: menu.menu_url || menu.MENU_URL || "", - // menuDesc: menu.menu_desc || menu.MENU_DESC || "", - // seq: menu.seq || menu.SEQ || 1, - // menuType: convertedMenuType, - // status: convertedStatus, - // companyCode: companyCode, - // langKey: langKey, + // objid: menu.objid || menu.OBJID, + // parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0", + // menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "", + // menuUrl: menu.menu_url || menu.MENU_URL || "", + // menuDesc: menu.menu_desc || menu.MENU_DESC || "", + // seq: menu.seq || menu.SEQ || 1, + // menuType: convertedMenuType, + // status: convertedStatus, + // companyCode: companyCode, + // langKey: langKey, // }); } } catch (error: any) { console.error("메뉴 정보 로딩 오류:", error); // console.error("오류 상세 정보:", { - // message: error?.message, - // stack: error?.stack, - // response: error?.response, + // message: error?.message, + // stack: error?.stack, + // response: error?.response, // }); toast.error(getText(MENU_MANAGEMENT_KEYS.MESSAGE_ERROR_LOAD_MENU_INFO)); } finally { @@ -391,11 +417,11 @@ export const MenuFormModal: React.FC = ({ }); // console.log("메뉴 등록 기본값 설정:", { - // parentObjId: parentId || "0", - // menuType: defaultMenuType, - // status: "ACTIVE", - // companyCode: "", - // langKey: "", + // parentObjId: parentId || "0", + // menuType: defaultMenuType, + // status: "ACTIVE", + // companyCode: "", + // langKey: "", // }); } }, [menuId, parentId, menuType]); @@ -430,10 +456,11 @@ export const MenuFormModal: React.FC = ({ } }, [isOpen, formData.companyCode]); - // 화면 목록 로드 + // 화면 목록 및 대시보드 목록 로드 useEffect(() => { if (isOpen) { loadScreens(); + loadDashboards(); } }, [isOpen]); @@ -449,9 +476,9 @@ export const MenuFormModal: React.FC = ({ if (screen) { setSelectedScreen(screen); // console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", { - // screenId, - // screenName: screen.screenName, - // menuUrl, + // screenId, + // screenName: screen.screenName, + // menuUrl, // }); } } @@ -459,6 +486,23 @@ export const MenuFormModal: React.FC = ({ } }, [screens, isEdit, formData.menuUrl, urlType, selectedScreen]); + // 대시보드 목록 로드 완료 후 기존 메뉴의 할당된 대시보드 설정 + useEffect(() => { + if (dashboards.length > 0 && isEdit && formData.menuUrl && urlType === "dashboard") { + const menuUrl = formData.menuUrl; + if (menuUrl.startsWith("/dashboard/")) { + const dashboardId = menuUrl.replace("/dashboard/", ""); + if (dashboardId && !selectedDashboard) { + console.log("🔄 대시보드 목록 로드 완료 - 기존 할당 대시보드 자동 설정"); + const dashboard = dashboards.find((d) => d.id === dashboardId); + if (dashboard) { + setSelectedDashboard(dashboard); + } + } + } + } + }, [dashboards, isEdit, formData.menuUrl, urlType, selectedDashboard]); + // 드롭다운 외부 클릭 시 닫기 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -471,9 +515,13 @@ export const MenuFormModal: React.FC = ({ setIsScreenDropdownOpen(false); setScreenSearchText(""); } + if (!target.closest(".dashboard-dropdown")) { + setIsDashboardDropdownOpen(false); + setDashboardSearchText(""); + } }; - if (isLangKeyDropdownOpen || isScreenDropdownOpen) { + if (isLangKeyDropdownOpen || isScreenDropdownOpen || isDashboardDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } @@ -751,6 +799,12 @@ export const MenuFormModal: React.FC = ({ 화면 할당 +
+ + +
+ ); } @@ -310,36 +574,60 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { if (type === "chart") { switch (subtype) { case "bar": - return "📊 바 차트"; + return "바 차트"; case "horizontal-bar": - return "📊 수평 바 차트"; + return "수평 바 차트"; + case "stacked-bar": + return "누적 바 차트"; case "pie": - return "🥧 원형 차트"; + return "원형 차트"; + case "donut": + return "도넛 차트"; case "line": - return "📈 꺾은선 차트"; + return "꺾은선 차트"; + case "area": + return "영역 차트"; + case "combo": + return "콤보 차트"; default: - return "📊 차트"; + return "차트"; } } else if (type === "widget") { switch (subtype) { case "exchange": - return "💱 환율 위젯"; + return "환율 위젯"; case "weather": - return "☁️ 날씨 위젯"; + return "날씨 위젯"; case "clock": - return "⏰ 시계 위젯"; + return "시계 위젯"; case "calculator": - return "🧮 계산기 위젯"; + return "계산기 위젯"; case "vehicle-map": - return "🚚 차량 위치 지도"; + return "차량 위치 지도"; case "calendar": - return "📅 달력 위젯"; + return "달력 위젯"; case "driver-management": - return "🚚 기사 관리 위젯"; + return "기사 관리 위젯"; case "list": - return "📋 리스트 위젯"; + return "리스트 위젯"; + case "map-summary": + return "커스텀 지도 카드"; + case "status-summary": + return "커스텀 상태 카드"; + case "risk-alert": + return "리스크 알림 위젯"; + case "todo": + return "할 일 위젯"; + case "booking-alert": + return "예약 알림 위젯"; + case "maintenance": + return "정비 일정 위젯"; + case "document": + return "문서 위젯"; + case "yard-management-3d": + return "야드 관리 3D"; default: - return "🔧 위젯"; + return "위젯"; } } return "요소"; @@ -378,6 +666,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/DashboardSaveModal.tsx b/frontend/components/admin/dashboard/DashboardSaveModal.tsx new file mode 100644 index 00000000..49d6ad7c --- /dev/null +++ b/frontend/components/admin/dashboard/DashboardSaveModal.tsx @@ -0,0 +1,321 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectGroup, + SelectLabel, +} from "@/components/ui/select"; +import { Loader2, Save } from "lucide-react"; +import { menuApi } from "@/lib/api/menu"; + +interface MenuItem { + id: string; + name: string; + url?: string; + parent_id?: string; + children?: MenuItem[]; +} + +interface DashboardSaveModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (data: { + title: string; + description: string; + assignToMenu: boolean; + menuType?: "admin" | "user"; + menuId?: string; + }) => Promise; + initialTitle?: string; + initialDescription?: string; + isEditing?: boolean; +} + +export function DashboardSaveModal({ + isOpen, + onClose, + onSave, + initialTitle = "", + initialDescription = "", + isEditing = false, +}: DashboardSaveModalProps) { + const [title, setTitle] = useState(initialTitle); + const [description, setDescription] = useState(initialDescription); + const [assignToMenu, setAssignToMenu] = useState(false); + const [menuType, setMenuType] = useState<"admin" | "user">("admin"); + const [selectedMenuId, setSelectedMenuId] = useState(""); + const [adminMenus, setAdminMenus] = useState([]); + const [userMenus, setUserMenus] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMenus, setLoadingMenus] = useState(false); + + useEffect(() => { + if (isOpen) { + setTitle(initialTitle); + setDescription(initialDescription); + setAssignToMenu(false); + setMenuType("admin"); + setSelectedMenuId(""); + loadMenus(); + } + }, [isOpen, initialTitle, initialDescription]); + + const loadMenus = async () => { + setLoadingMenus(true); + try { + const [adminData, userData] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]); + + // API 응답이 배열인지 확인하고 처리 + const adminMenuList = Array.isArray(adminData) ? adminData : adminData?.data || []; + const userMenuList = Array.isArray(userData) ? userData : userData?.data || []; + + setAdminMenus(adminMenuList); + setUserMenus(userMenuList); + } catch (error) { + // console.error("메뉴 목록 로드 실패:", error); + setAdminMenus([]); + setUserMenus([]); + } finally { + setLoadingMenus(false); + } + }; + + const flattenMenus = ( + menus: MenuItem[], + prefix = "", + parentPath = "", + ): { id: string; label: string; uniqueKey: string }[] => { + if (!Array.isArray(menus)) { + return []; + } + + const result: { id: string; label: string; uniqueKey: string }[] = []; + menus.forEach((menu, index) => { + // 메뉴 ID 추출 (objid 또는 id) + const menuId = (menu as any).objid || menu.id || ""; + const uniqueKey = `${parentPath}-${menuId}-${index}`; + + // 메뉴 이름 추출 + const menuName = + menu.name || + (menu as any).menu_name_kor || + (menu as any).MENU_NAME_KOR || + (menu as any).menuNameKor || + (menu as any).title || + "이름없음"; + + // lev 필드로 레벨 확인 (lev > 1인 메뉴만 추가) + const menuLevel = (menu as any).lev || 0; + + if (menuLevel > 1) { + result.push({ + id: menuId, + label: prefix + menuName, + uniqueKey, + }); + } + + // 하위 메뉴가 있으면 재귀 호출 + if (menu.children && Array.isArray(menu.children) && menu.children.length > 0) { + result.push(...flattenMenus(menu.children, prefix + menuName + " > ", uniqueKey)); + } + }); + + return result; + }; + + const handleSave = async () => { + if (!title.trim()) { + alert("대시보드 이름을 입력해주세요."); + return; + } + + if (assignToMenu && !selectedMenuId) { + alert("메뉴를 선택해주세요."); + return; + } + + setLoading(true); + try { + await onSave({ + title: title.trim(), + description: description.trim(), + assignToMenu, + menuType: assignToMenu ? menuType : undefined, + menuId: assignToMenu ? selectedMenuId : undefined, + }); + onClose(); + } catch (error) { + // console.error("저장 실패:", error); + } finally { + setLoading(false); + } + }; + + const currentMenus = menuType === "admin" ? adminMenus : userMenus; + const flatMenus = flattenMenus(currentMenus); + + return ( + + + + {isEditing ? "대시보드 수정" : "대시보드 저장"} + + +
+ {/* 대시보드 이름 */} +
+ + setTitle(e.target.value)} + placeholder="예: 생산 현황 대시보드" + className="w-full" + /> +
+ + {/* 대시보드 설명 */} +
+ +