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/src/app.ts b/backend-node/src/app.ts index 6660bc13..37965d00 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -55,7 +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 warehouseRoutes from "./routes/warehouseRoutes"; // 창고 관리 +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"; // 임시 주석 @@ -205,7 +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/warehouse", warehouseRoutes); // 창고 관리 +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); @@ -235,7 +237,7 @@ app.listen(PORT, HOST, async () => { // 대시보드 마이그레이션 실행 try { - const { runDashboardMigration } = await import('./database/runMigration'); + const { runDashboardMigration } = await import("./database/runMigration"); await runDashboardMigration(); } catch (error) { logger.error(`❌ 대시보드 마이그레이션 실패:`, error); 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/WarehouseController.ts b/backend-node/src/controllers/WarehouseController.ts deleted file mode 100644 index 1fe140e8..00000000 --- a/backend-node/src/controllers/WarehouseController.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Request, Response } from "express"; -import { WarehouseService } from "../services/WarehouseService"; - -export class WarehouseController { - private warehouseService: WarehouseService; - - constructor() { - this.warehouseService = new WarehouseService(); - } - - // 창고 및 자재 데이터 조회 - getWarehouseData = async (req: Request, res: Response) => { - try { - const data = await this.warehouseService.getWarehouseData(); - - return res.json({ - success: true, - warehouses: data.warehouses, - materials: data.materials, - }); - } catch (error: any) { - console.error("창고 데이터 조회 오류:", error); - return res.status(500).json({ - success: false, - message: "창고 데이터를 불러오는데 실패했습니다.", - error: error.message, - }); - } - }; - - // 특정 창고 정보 조회 - getWarehouseById = async (req: Request, res: Response) => { - try { - const { id } = req.params; - const warehouse = await this.warehouseService.getWarehouseById(id); - - if (!warehouse) { - return res.status(404).json({ - success: false, - message: "창고를 찾을 수 없습니다.", - }); - } - - return res.json({ - success: true, - data: warehouse, - }); - } catch (error: any) { - console.error("창고 조회 오류:", error); - return res.status(500).json({ - success: false, - message: "창고 정보를 불러오는데 실패했습니다.", - error: error.message, - }); - } - }; - - // 창고별 자재 목록 조회 - getMaterialsByWarehouse = async (req: Request, res: Response) => { - try { - const { warehouseId } = req.params; - const materials = - await this.warehouseService.getMaterialsByWarehouse(warehouseId); - - return res.json({ - success: true, - data: materials, - }); - } catch (error: any) { - console.error("자재 목록 조회 오류:", error); - return res.status(500).json({ - success: false, - message: "자재 목록을 불러오는데 실패했습니다.", - error: error.message, - }); - } - }; - - // 창고 통계 조회 - getWarehouseStats = async (req: Request, res: Response) => { - try { - const stats = await this.warehouseService.getWarehouseStats(); - - return res.json({ - success: true, - data: stats, - }); - } catch (error: any) { - console.error("창고 통계 조회 오류:", error); - return res.status(500).json({ - success: false, - message: "창고 통계를 불러오는데 실패했습니다.", - error: error.message, - }); - } - }; -} diff --git a/backend-node/src/controllers/YardLayoutController.ts b/backend-node/src/controllers/YardLayoutController.ts new file mode 100644 index 00000000..652f74a2 --- /dev/null +++ b/backend-node/src/controllers/YardLayoutController.ts @@ -0,0 +1,299 @@ +import { Request, Response } from "express"; +import YardLayoutService from "../services/YardLayoutService"; + +export class YardLayoutController { + // 모든 야드 레이아웃 목록 조회 + async getAllLayouts(req: Request, res: Response) { + try { + const layouts = await YardLayoutService.getAllLayouts(); + res.json({ success: true, data: layouts }); + } catch (error: any) { + console.error("Error fetching yard layouts:", error); + res.status(500).json({ + success: false, + message: "야드 레이아웃 목록 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 특정 야드 레이아웃 상세 조회 + async getLayoutById(req: Request, res: Response) { + try { + const { id } = req.params; + const layout = await YardLayoutService.getLayoutById(parseInt(id)); + + if (!layout) { + return res.status(404).json({ + success: false, + message: "야드 레이아웃을 찾을 수 없습니다.", + }); + } + + return res.json({ success: true, data: layout }); + } catch (error: any) { + console.error("Error fetching yard layout:", error); + return res.status(500).json({ + success: false, + message: "야드 레이아웃 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 새 야드 레이아웃 생성 + async createLayout(req: Request, res: Response) { + try { + const { name, description } = req.body; + + if (!name) { + return res.status(400).json({ + success: false, + message: "야드 이름은 필수입니다.", + }); + } + + const created_by = (req as any).user?.userId || "system"; + const layout = await YardLayoutService.createLayout({ + name, + description, + created_by, + }); + + return res.status(201).json({ success: true, data: layout }); + } catch (error: any) { + console.error("Error creating yard layout:", error); + return res.status(500).json({ + success: false, + message: "야드 레이아웃 생성 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 야드 레이아웃 수정 + async updateLayout(req: Request, res: Response) { + try { + const { id } = req.params; + const { name, description } = req.body; + + const layout = await YardLayoutService.updateLayout(parseInt(id), { + name, + description, + }); + + if (!layout) { + return res.status(404).json({ + success: false, + message: "야드 레이아웃을 찾을 수 없습니다.", + }); + } + + return res.json({ success: true, data: layout }); + } catch (error: any) { + console.error("Error updating yard layout:", error); + return res.status(500).json({ + success: false, + message: "야드 레이아웃 수정 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 야드 레이아웃 삭제 + async deleteLayout(req: Request, res: Response) { + try { + const { id } = req.params; + const layout = await YardLayoutService.deleteLayout(parseInt(id)); + + if (!layout) { + return res.status(404).json({ + success: false, + message: "야드 레이아웃을 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + message: "야드 레이아웃이 삭제되었습니다.", + }); + } catch (error: any) { + console.error("Error deleting yard layout:", error); + return res.status(500).json({ + success: false, + message: "야드 레이아웃 삭제 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 특정 야드의 모든 배치 자재 조회 + async getPlacementsByLayoutId(req: Request, res: Response) { + try { + const { id } = req.params; + const placements = await YardLayoutService.getPlacementsByLayoutId( + parseInt(id) + ); + + res.json({ success: true, data: placements }); + } catch (error: any) { + console.error("Error fetching placements:", error); + res.status(500).json({ + success: false, + message: "배치 자재 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 야드에 자재 배치 추가 + async addMaterialPlacement(req: Request, res: Response) { + try { + const { id } = req.params; + const placementData = req.body; + + if (!placementData.external_material_id || !placementData.material_code) { + return res.status(400).json({ + success: false, + message: "자재 정보가 필요합니다.", + }); + } + + const placement = await YardLayoutService.addMaterialPlacement( + parseInt(id), + placementData + ); + + return res.status(201).json({ success: true, data: placement }); + } catch (error: any) { + console.error("Error adding material placement:", error); + + if (error.code === "23505") { + // 유니크 제약 조건 위반 + return res.status(409).json({ + success: false, + message: "이미 배치된 자재입니다.", + }); + } + + return res.status(500).json({ + success: false, + message: "자재 배치 추가 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 배치 정보 수정 + async updatePlacement(req: Request, res: Response) { + try { + const { id } = req.params; + const placementData = req.body; + + const placement = await YardLayoutService.updatePlacement( + parseInt(id), + placementData + ); + + if (!placement) { + return res.status(404).json({ + success: false, + message: "배치 정보를 찾을 수 없습니다.", + }); + } + + return res.json({ success: true, data: placement }); + } catch (error: any) { + console.error("Error updating placement:", error); + return res.status(500).json({ + success: false, + message: "배치 정보 수정 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 배치 해제 + async removePlacement(req: Request, res: Response) { + try { + const { id } = req.params; + const placement = await YardLayoutService.removePlacement(parseInt(id)); + + if (!placement) { + return res.status(404).json({ + success: false, + message: "배치 정보를 찾을 수 없습니다.", + }); + } + + return res.json({ success: true, message: "배치가 해제되었습니다." }); + } catch (error: any) { + console.error("Error removing placement:", error); + return res.status(500).json({ + success: false, + message: "배치 해제 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 여러 배치 일괄 업데이트 + async batchUpdatePlacements(req: Request, res: Response) { + try { + const { id } = req.params; + const { placements } = req.body; + + if (!Array.isArray(placements) || placements.length === 0) { + return res.status(400).json({ + success: false, + message: "배치 목록이 필요합니다.", + }); + } + + const updatedPlacements = await YardLayoutService.batchUpdatePlacements( + parseInt(id), + placements + ); + + return res.json({ success: true, data: updatedPlacements }); + } catch (error: any) { + console.error("Error batch updating placements:", error); + return res.status(500).json({ + success: false, + message: "배치 일괄 업데이트 중 오류가 발생했습니다.", + error: error.message, + }); + } + } + + // 야드 레이아웃 복제 + async duplicateLayout(req: Request, res: Response) { + try { + const { id } = req.params; + const { name } = req.body; + + if (!name) { + return res.status(400).json({ + success: false, + message: "새 야드 이름은 필수입니다.", + }); + } + + const layout = await YardLayoutService.duplicateLayout( + parseInt(id), + name + ); + + return res.status(201).json({ success: true, data: layout }); + } catch (error: any) { + console.error("Error duplicating yard layout:", error); + return res.status(500).json({ + success: false, + message: "야드 레이아웃 복제 중 오류가 발생했습니다.", + error: error.message, + }); + } + } +} + +export default new YardLayoutController(); diff --git a/backend-node/src/routes/materialRoutes.ts b/backend-node/src/routes/materialRoutes.ts new file mode 100644 index 00000000..a85e10f6 --- /dev/null +++ b/backend-node/src/routes/materialRoutes.ts @@ -0,0 +1,15 @@ +import express from "express"; +import MaterialController from "../controllers/MaterialController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 임시 자재 마스터 관리 +router.get("/temp", MaterialController.getTempMaterials); +router.get("/temp/categories", MaterialController.getCategories); +router.get("/temp/:code", MaterialController.getTempMaterialByCode); + +export default router; diff --git a/backend-node/src/routes/warehouseRoutes.ts b/backend-node/src/routes/warehouseRoutes.ts deleted file mode 100644 index 15625a35..00000000 --- a/backend-node/src/routes/warehouseRoutes.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Router } from "express"; -import { WarehouseController } from "../controllers/WarehouseController"; - -const router = Router(); -const warehouseController = new WarehouseController(); - -// 창고 및 자재 데이터 조회 -router.get("/data", warehouseController.getWarehouseData); - -// 특정 창고 정보 조회 -router.get("/:id", warehouseController.getWarehouseById); - -// 창고별 자재 목록 조회 -router.get( - "/:warehouseId/materials", - warehouseController.getMaterialsByWarehouse -); - -// 창고 통계 조회 -router.get("/stats/summary", warehouseController.getWarehouseStats); - -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 f8816555..c7650df2 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -61,8 +61,9 @@ export class DashboardService { id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, 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, $16, $17) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) `, [ elementId, @@ -79,6 +80,8 @@ export class DashboardService { element.content || null, JSON.stringify(element.dataSource || {}), JSON.stringify(element.chartConfig || {}), + JSON.stringify(element.listConfig || null), + JSON.stringify(element.yardConfig || null), i, now, now, @@ -342,6 +345,16 @@ export class DashboardService { content: row.content, 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, }) ); @@ -465,8 +478,9 @@ export class DashboardService { id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, 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, $16, $17) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) `, [ elementId, @@ -483,6 +497,8 @@ export class DashboardService { element.content || null, JSON.stringify(element.dataSource || {}), JSON.stringify(element.chartConfig || {}), + JSON.stringify(element.listConfig || null), + JSON.stringify(element.yardConfig || null), i, now, now, 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/WarehouseService.ts b/backend-node/src/services/WarehouseService.ts deleted file mode 100644 index fe0433c7..00000000 --- a/backend-node/src/services/WarehouseService.ts +++ /dev/null @@ -1,170 +0,0 @@ -import pool from "../database/db"; - -export class WarehouseService { - // 창고 및 자재 데이터 조회 - async getWarehouseData() { - try { - // 창고 목록 조회 - const warehousesResult = await pool.query(` - SELECT - id, - name, - position_x, - position_y, - position_z, - size_x, - size_y, - size_z, - color, - capacity, - current_usage, - status, - description, - created_at, - updated_at - FROM warehouse - WHERE status = 'active' - ORDER BY id - `); - - // 자재 목록 조회 - const materialsResult = await pool.query(` - SELECT - id, - warehouse_id, - name, - material_code, - quantity, - unit, - position_x, - position_y, - position_z, - size_x, - size_y, - size_z, - color, - status, - last_updated, - created_at - FROM warehouse_material - ORDER BY warehouse_id, id - `); - - return { - warehouses: warehousesResult, - materials: materialsResult, - }; - } catch (error) { - throw error; - } - } - - // 특정 창고 정보 조회 - async getWarehouseById(id: string) { - try { - const result = await pool.query( - ` - SELECT - id, - name, - position_x, - position_y, - position_z, - size_x, - size_y, - size_z, - color, - capacity, - current_usage, - status, - description, - created_at, - updated_at - FROM warehouse - WHERE id = $1 - `, - [id] - ); - - return result[0] || null; - } catch (error) { - throw error; - } - } - - // 창고별 자재 목록 조회 - async getMaterialsByWarehouse(warehouseId: string) { - try { - const result = await pool.query( - ` - SELECT - id, - warehouse_id, - name, - material_code, - quantity, - unit, - position_x, - position_y, - position_z, - size_x, - size_y, - size_z, - color, - status, - last_updated, - created_at - FROM warehouse_material - WHERE warehouse_id = $1 - ORDER BY id - `, - [warehouseId] - ); - - return result; - } catch (error) { - throw error; - } - } - - // 창고 통계 조회 - async getWarehouseStats() { - try { - const result = await pool.query(` - SELECT - COUNT(DISTINCT w.id) as total_warehouses, - COUNT(m.id) as total_materials, - SUM(w.capacity) as total_capacity, - SUM(w.current_usage) as total_usage, - ROUND(AVG((w.current_usage::numeric / NULLIF(w.capacity, 0)) * 100), 2) as avg_usage_percent - FROM warehouse w - LEFT JOIN warehouse_material m ON w.id = m.warehouse_id - WHERE w.status = 'active' - `); - - // 상태별 자재 수 - const statusResult = await pool.query(` - SELECT - status, - COUNT(*) as count - FROM warehouse_material - GROUP BY status - `); - - const statusCounts = statusResult.reduce( - (acc: Record, row: any) => { - acc[row.status] = parseInt(row.count); - return acc; - }, - {} as Record - ); - - return { - ...result[0], - materialsByStatus: statusCounts, - }; - } catch (error) { - throw error; - } - } -} 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/types/dashboard.ts b/backend-node/src/types/dashboard.ts index 789adda3..b03acbff 100644 --- a/backend-node/src/types/dashboard.ts +++ b/backend-node/src/types/dashboard.ts @@ -35,6 +35,16 @@ export interface DashboardElement { title?: string; showLegend?: boolean; }; + listConfig?: { + columns?: any[]; + pagination?: any; + viewMode?: string; + cardColumns?: number; + }; + yardConfig?: { + layoutId: number; + layoutName?: string; + }; } export interface Dashboard { diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 9b93bca6..e294a797 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -98,17 +98,6 @@ const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/Docu loading: () => 로딩 중..., }); -const Warehouse3DWidget = dynamic( - () => - import("@/components/admin/dashboard/widgets/Warehouse3DWidget").then((mod) => ({ - default: mod.Warehouse3DWidget, - })), - { - ssr: false, - loading: () => 로딩 중..., - }, -); - // 시계 위젯 임포트 import { ClockWidget } from "./widgets/ClockWidget"; // 달력 위젯 임포트 @@ -117,6 +106,12 @@ import { CalendarWidget } from "./widgets/CalendarWidget"; import { DriverManagementWidget } from "./widgets/DriverManagementWidget"; import { ListWidget } from "./widgets/ListWidget"; +// 야드 관리 3D 위젯 +const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DWidget"), { + ssr: false, + loading: () => 로딩 중..., +}); + interface CanvasElementProps { element: DashboardElement; isSelected: boolean; @@ -231,20 +226,16 @@ export function CanvasElement({ const subGridSize = Math.floor(cellSize / 3); const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px) - + // X 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드) const nearestGridX = Math.round(rawX / gridSize) * gridSize; const distToGridX = Math.abs(rawX - nearestGridX); - const snappedX = distToGridX <= magneticThreshold - ? nearestGridX - : Math.round(rawX / subGridSize) * subGridSize; - + const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize; + // Y 좌표 스냅 (큰 그리드 우선, 없으면 서브그리드) const nearestGridY = Math.round(rawY / gridSize) * gridSize; const distToGridY = Math.abs(rawY - nearestGridY); - const snappedY = distToGridY <= magneticThreshold - ? nearestGridY - : Math.round(rawY / subGridSize) * subGridSize; + const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize; setTempPosition({ x: snappedX, y: snappedY }); } else if (isResizing) { @@ -293,45 +284,49 @@ export function CanvasElement({ const subGridSize = Math.floor(cellSize / 3); const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; - + // 위치 스냅 const nearestGridX = Math.round(newX / gridSize) * gridSize; const distToGridX = Math.abs(newX - nearestGridX); - const snappedX = distToGridX <= magneticThreshold - ? nearestGridX - : Math.round(newX / subGridSize) * subGridSize; - + const snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(newX / subGridSize) * subGridSize; + const nearestGridY = Math.round(newY / gridSize) * gridSize; const distToGridY = Math.abs(newY - nearestGridY); - const snappedY = distToGridY <= magneticThreshold - ? nearestGridY - : Math.round(newY / subGridSize) * subGridSize; - + const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(newY / subGridSize) * subGridSize; + // 크기 스냅 (그리드 칸 단위로 스냅하되, 마지막 GAP은 제외) // 예: 1칸 = cellSize, 2칸 = cellSize*2 + GAP, 3칸 = cellSize*3 + GAP*2 const calculateGridWidth = (cells: number) => cells * cellSize + Math.max(0, cells - 1) * 5; - + // 가장 가까운 그리드 칸 수 계산 const nearestWidthCells = Math.round(newWidth / gridSize); const nearestGridWidth = calculateGridWidth(nearestWidthCells); const distToGridWidth = Math.abs(newWidth - nearestGridWidth); - const snappedWidth = distToGridWidth <= magneticThreshold - ? nearestGridWidth - : Math.round(newWidth / subGridSize) * subGridSize; - + const snappedWidth = + distToGridWidth <= magneticThreshold ? nearestGridWidth : Math.round(newWidth / subGridSize) * subGridSize; + const nearestHeightCells = Math.round(newHeight / gridSize); const nearestGridHeight = calculateGridWidth(nearestHeightCells); const distToGridHeight = Math.abs(newHeight - nearestGridHeight); - const snappedHeight = distToGridHeight <= magneticThreshold - ? nearestGridHeight - : Math.round(newHeight / subGridSize) * subGridSize; + const snappedHeight = + distToGridHeight <= magneticThreshold ? nearestGridHeight : Math.round(newHeight / subGridSize) * subGridSize; // 임시 크기/위치 저장 (스냅됨) setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) }); setTempSize({ width: snappedWidth, height: snappedHeight }); } }, - [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth, cellSize], + [ + isDragging, + isResizing, + dragStart, + resizeStart, + element.size.width, + element.type, + element.subtype, + canvasWidth, + cellSize, + ], ); // 마우스 업 처리 (이미 스냅된 위치 사용) @@ -356,7 +351,7 @@ export function CanvasElement({ if (isResizing && tempPosition && tempSize) { // tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨 // 다시 스냅하지 않고 그대로 사용! - let finalX = tempPosition.x; + const finalX = tempPosition.x; const finalY = tempPosition.y; let finalWidth = tempSize.width; const finalHeight = tempSize.height; @@ -715,6 +710,17 @@ export function CanvasElement({ }} /> + ) : element.type === "widget" && element.subtype === "yard-management-3d" ? ( + // 야드 관리 3D 위젯 렌더링 + + { + onUpdate(element.id, { yardConfig: newConfig }); + }} + /> + ) : element.type === "widget" && element.subtype === "todo" ? ( // To-Do 위젯 렌더링 @@ -735,11 +741,6 @@ export function CanvasElement({ - ) : element.type === "widget" && element.subtype === "warehouse-3d" ? ( - // 창고 현황 3D 위젯 렌더링 - - - ) : ( // 기타 위젯 렌더링 ( // 충돌 방지 기능이 포함된 업데이트 핸들러 const handleUpdateWithCollisionDetection = useCallback( (id: string, updates: Partial) => { + // position이나 size가 아닌 다른 속성 업데이트는 충돌 감지 없이 바로 처리 + if (!updates.position && !updates.size) { + onUpdateElement(id, updates); + return; + } + // 업데이트할 요소 찾기 const elementIndex = elements.findIndex((el) => el.id === id); if (elementIndex === -1) { @@ -58,9 +64,38 @@ export const DashboardCanvas = forwardRef( return; } + // position이나 size와 다른 속성이 함께 있으면 분리해서 처리 + const positionSizeUpdates: any = {}; + const otherUpdates: any = {}; + + Object.keys(updates).forEach((key) => { + if (key === "position" || key === "size") { + positionSizeUpdates[key] = (updates as any)[key]; + } else { + otherUpdates[key] = (updates as any)[key]; + } + }); + + // 다른 속성들은 먼저 바로 업데이트 + if (Object.keys(otherUpdates).length > 0) { + onUpdateElement(id, otherUpdates); + } + + // position/size가 없으면 여기서 종료 + if (Object.keys(positionSizeUpdates).length === 0) { + return; + } + // 임시로 업데이트된 요소 배열 생성 const updatedElements = elements.map((el) => - el.id === id ? { ...el, ...updates, position: updates.position || el.position, size: updates.size || el.size } : el + el.id === id + ? { + ...el, + ...positionSizeUpdates, + position: positionSizeUpdates.position || el.position, + size: positionSizeUpdates.size || el.size, + } + : el, ); // 서브 그리드 크기 계산 (cellSize / 3) @@ -85,7 +120,7 @@ export const DashboardCanvas = forwardRef( } }); }, - [elements, onUpdateElement, cellSize, canvasWidth] + [elements, onUpdateElement, cellSize, canvasWidth], ); // 드래그 오버 처리 @@ -124,20 +159,17 @@ export const DashboardCanvas = forwardRef( const subGridSize = Math.floor(cellSize / 3); const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; - + // X 좌표 스냅 const nearestGridX = Math.round(rawX / gridSize) * gridSize; const distToGridX = Math.abs(rawX - nearestGridX); - let snappedX = distToGridX <= magneticThreshold - ? nearestGridX - : Math.round(rawX / subGridSize) * subGridSize; - + let snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize; + // Y 좌표 스냅 const nearestGridY = Math.round(rawY / gridSize) * gridSize; const distToGridY = Math.abs(rawY - nearestGridY); - const snappedY = distToGridY <= magneticThreshold - ? nearestGridY - : Math.round(rawY / subGridSize) * subGridSize; + const snappedY = + distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize; // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장 diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 516493f5..27115ee1 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -345,6 +345,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D dataSource: el.dataSource, chartConfig: el.chartConfig, listConfig: el.listConfig, + yardConfig: el.yardConfig, })); let savedDashboard; @@ -449,7 +450,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D router.push(`/dashboard/${dashboardId}`) : undefined} dashboardTitle={dashboardTitle} onAddElement={addElementFromMenu} resolution={resolution} @@ -459,111 +459,111 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D onBackgroundColorChange={setCanvasBackgroundColor} /> - {/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */} - {/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */} - - + + + + + + {/* 요소 설정 모달 */} + {configModalElement && ( + <> + {configModalElement.type === "widget" && configModalElement.subtype === "list" ? ( + + ) : ( + + )} + > + )} + + {/* 저장 모달 */} + setSaveModalOpen(false)} + onSave={handleSave} + initialTitle={dashboardTitle} + initialDescription={dashboardDescription} + isEditing={!!dashboardId} + /> + + {/* 저장 성공 모달 */} + { + setSuccessModalOpen(false); + router.push("/admin/dashboard"); }} > - - - - - {/* 요소 설정 모달 */} - {configModalElement && ( - <> - {configModalElement.type === "widget" && configModalElement.subtype === "list" ? ( - - ) : ( - - )} - > - )} - - {/* 저장 모달 */} - setSaveModalOpen(false)} - onSave={handleSave} - initialTitle={dashboardTitle} - initialDescription={dashboardDescription} - isEditing={!!dashboardId} - /> - - {/* 저장 성공 모달 */} - { - setSuccessModalOpen(false); - router.push("/admin/dashboard"); - }} - > - - - - + + + + + + 저장 완료 + 대시보드가 성공적으로 저장되었습니다. + + + { + setSuccessModalOpen(false); + router.push("/admin/dashboard"); + }} + > + 확인 + - 저장 완료 - 대시보드가 성공적으로 저장되었습니다. - - - { - setSuccessModalOpen(false); - router.push("/admin/dashboard"); - }} - > - 확인 - - - - + + - {/* 초기화 확인 모달 */} - - - - 캔버스 초기화 - - 모든 요소가 삭제됩니다. 이 작업은 되돌릴 수 없습니다. - - 계속하시겠습니까? - - - - 취소 - - 초기화 - - - - + {/* 초기화 확인 모달 */} + + + + 캔버스 초기화 + + 모든 요소가 삭제됩니다. 이 작업은 되돌릴 수 없습니다. + + 계속하시겠습니까? + + + + 취소 + + 초기화 + + + + ); @@ -624,8 +624,8 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { return "정비 일정 위젯"; case "document": return "문서 위젯"; - case "warehouse-3d": - return "창고 현황 (3D)"; + case "yard-management-3d": + return "야드 관리 3D"; default: return "위젯"; } @@ -666,8 +666,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string { return "driver-management"; case "list": return "list-widget"; - case "warehouse-3d": - return "warehouse-3d"; + case "yard-management-3d": + return "yard-3d"; default: return "위젯 내용이 여기에 표시됩니다"; } diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 00957f91..35062400 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -181,11 +181,11 @@ export function DashboardTopMenu({ 데이터 위젯 리스트 위젯 + 야드 관리 3D {/* 지도 */} 커스텀 지도 카드 {/* 커스텀 목록 카드 */} 커스텀 상태 카드 - 창고 현황 (3D) 일반 위젯 diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 73a4bf89..08308cc4 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -36,7 +36,7 @@ export type ElementSubtype = | "maintenance" | "document" | "list" - | "warehouse-3d"; // 위젯 타입 + | "yard-management-3d"; // 야드 관리 3D 위젯 export interface Position { x: number; @@ -64,6 +64,7 @@ export interface DashboardElement { calendarConfig?: CalendarConfig; // 달력 설정 driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정 listConfig?: ListWidgetConfig; // 리스트 위젯 설정 + yardConfig?: YardManagementConfig; // 야드 관리 3D 설정 } export interface DragData { @@ -272,3 +273,9 @@ export interface ListColumn { align?: "left" | "center" | "right"; // 정렬 visible?: boolean; // 표시 여부 (기본: true) } + +// 야드 관리 3D 설정 +export interface YardManagementConfig { + layoutId: number; // 선택된 야드 레이아웃 ID + layoutName?: string; // 레이아웃 이름 (표시용) +} diff --git a/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx b/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx deleted file mode 100644 index 480909b8..00000000 --- a/frontend/components/admin/dashboard/widgets/Warehouse3DWidget.tsx +++ /dev/null @@ -1,418 +0,0 @@ -"use client"; - -import React, { useRef, useState, useEffect, Suspense } from "react"; -import { Canvas, useFrame } from "@react-three/fiber"; -import { OrbitControls, Text, Box, Html } from "@react-three/drei"; -import * as THREE from "three"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Loader2, Maximize2, Info } from "lucide-react"; - -interface WarehouseData { - id: string; - name: string; - position_x: number; - position_y: number; - position_z: number; - size_x: number; - size_y: number; - size_z: number; - color: string; - capacity: number; - current_usage: number; - status: string; - description?: string; -} - -interface MaterialData { - id: string; - warehouse_id: string; - name: string; - material_code: string; - quantity: number; - unit: string; - position_x: number; - position_y: number; - position_z: number; - size_x: number; - size_y: number; - size_z: number; - color: string; - status: string; -} - -interface Warehouse3DWidgetProps { - element?: any; -} - -// 창고 3D 박스 컴포넌트 -function WarehouseBox({ - warehouse, - onClick, - isSelected, -}: { - warehouse: WarehouseData; - onClick: () => void; - isSelected: boolean; -}) { - const meshRef = useRef(null); - const [hovered, setHovered] = useState(false); - - useFrame(() => { - if (meshRef.current) { - if (isSelected) { - meshRef.current.scale.lerp(new THREE.Vector3(1.05, 1.05, 1.05), 0.1); - } else if (hovered) { - meshRef.current.scale.lerp(new THREE.Vector3(1.02, 1.02, 1.02), 0.1); - } else { - meshRef.current.scale.lerp(new THREE.Vector3(1, 1, 1), 0.1); - } - } - }); - - const usagePercentage = (warehouse.current_usage / warehouse.capacity) * 100; - - return ( - - { - e.stopPropagation(); - onClick(); - }} - onPointerOver={() => setHovered(true)} - onPointerOut={() => setHovered(false)} - > - - - - - {/* 창고 테두리 */} - - - - - - {/* 창고 이름 라벨 */} - - {warehouse.name} - - - {/* 사용률 표시 */} - - - {usagePercentage.toFixed(0)}% 사용중 - - - - ); -} - -// 자재 3D 박스 컴포넌트 -function MaterialBox({ - material, - onClick, - isSelected, -}: { - material: MaterialData; - onClick: () => void; - isSelected: boolean; -}) { - const meshRef = useRef(null); - const [hovered, setHovered] = useState(false); - - useFrame(() => { - if (meshRef.current && (isSelected || hovered)) { - meshRef.current.rotation.y += 0.01; - } - }); - - const statusColor = - { - stocked: material.color, - reserved: "#FFA500", - urgent: "#FF0000", - out_of_stock: "#808080", - }[material.status] || material.color; - - return ( - - { - e.stopPropagation(); - onClick(); - }} - onPointerOver={() => setHovered(true)} - onPointerOut={() => setHovered(false)} - > - - - - - {(hovered || isSelected) && ( - - - {material.name} - - {material.quantity} {material.unit} - - - - )} - - ); -} - -// 3D 씬 컴포넌트 -function Scene({ - warehouses, - materials, - onSelectWarehouse, - onSelectMaterial, - selectedWarehouse, - selectedMaterial, -}: { - warehouses: WarehouseData[]; - materials: MaterialData[]; - onSelectWarehouse: (warehouse: WarehouseData | null) => void; - onSelectMaterial: (material: MaterialData | null) => void; - selectedWarehouse: WarehouseData | null; - selectedMaterial: MaterialData | null; -}) { - return ( - <> - {/* 조명 */} - - - - - {/* 바닥 그리드 */} - - - {/* 창고들 */} - {warehouses.map((warehouse) => ( - { - if (selectedWarehouse?.id === warehouse.id) { - onSelectWarehouse(null); - } else { - onSelectWarehouse(warehouse); - onSelectMaterial(null); - } - }} - isSelected={selectedWarehouse?.id === warehouse.id} - /> - ))} - - {/* 자재들 */} - {materials.map((material) => ( - { - if (selectedMaterial?.id === material.id) { - onSelectMaterial(null); - } else { - onSelectMaterial(material); - } - }} - isSelected={selectedMaterial?.id === material.id} - /> - ))} - - {/* 카메라 컨트롤 */} - - > - ); -} - -export function Warehouse3DWidget({ element }: Warehouse3DWidgetProps) { - const [warehouses, setWarehouses] = useState([]); - const [materials, setMaterials] = useState([]); - const [loading, setLoading] = useState(true); - const [selectedWarehouse, setSelectedWarehouse] = useState(null); - const [selectedMaterial, setSelectedMaterial] = useState(null); - const [isFullscreen, setIsFullscreen] = useState(false); - - useEffect(() => { - loadData(); - }, []); - - const loadData = async () => { - try { - setLoading(true); - // API 호출 (백엔드 API 구현 필요) - const response = await fetch("/api/warehouse/data"); - if (response.ok) { - const data = await response.json(); - setWarehouses(data.warehouses || []); - setMaterials(data.materials || []); - } else { - // 임시 더미 데이터 (개발용) - // console.log("API 실패, 더미 데이터 사용"); - } - } catch (error) { - // console.error("창고 데이터 로드 실패:", error); - } finally { - setLoading(false); - } - }; - - if (loading) { - return ( - - - - - - ); - } - - return ( - - - {element?.customTitle || "창고 현황 (3D)"} - - - {warehouses.length}개 창고 | {materials.length}개 자재 - - setIsFullscreen(!isFullscreen)} className="text-gray-500 hover:text-gray-700"> - - - - - - {/* 3D 뷰 */} - - - - - - - - - {/* 정보 패널 */} - - {/* 선택된 창고 정보 */} - {selectedWarehouse && ( - - - - - 창고 정보 - - - - - 이름: {selectedWarehouse.name} - - - ID: {selectedWarehouse.id} - - - 용량: {selectedWarehouse.current_usage} /{" "} - {selectedWarehouse.capacity} - - - 사용률:{" "} - {((selectedWarehouse.current_usage / selectedWarehouse.capacity) * 100).toFixed(1)}% - - - 상태:{" "} - - {selectedWarehouse.status} - - - {selectedWarehouse.description && ( - - 설명: {selectedWarehouse.description} - - )} - - - )} - - {/* 선택된 자재 정보 */} - {selectedMaterial && ( - - - - - 자재 정보 - - - - - 이름: {selectedMaterial.name} - - - 코드: {selectedMaterial.material_code} - - - 수량: {selectedMaterial.quantity} {selectedMaterial.unit} - - - 위치:{" "} - {warehouses.find((w) => w.id === selectedMaterial.warehouse_id)?.name} - - - 상태:{" "} - - {selectedMaterial.status} - - - - - )} - - {/* 창고 목록 */} - {!selectedWarehouse && !selectedMaterial && ( - - - 창고 목록 - - - {warehouses.map((warehouse) => { - const warehouseMaterials = materials.filter((m) => m.warehouse_id === warehouse.id); - return ( - setSelectedWarehouse(warehouse)} - className="w-full rounded-lg border p-2 text-left transition-colors hover:bg-gray-50" - > - - {warehouse.name} - {warehouseMaterials.length}개 - - - {((warehouse.current_usage / warehouse.capacity) * 100).toFixed(0)}% 사용중 - - - ); - })} - - - )} - - - - ); -} diff --git a/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx new file mode 100644 index 00000000..2ba2e697 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/YardManagement3DWidget.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Plus, Check } from "lucide-react"; +import YardLayoutList from "./yard-3d/YardLayoutList"; +import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal"; +import YardEditor from "./yard-3d/YardEditor"; +import Yard3DViewer from "./yard-3d/Yard3DViewer"; +import { yardLayoutApi } from "@/lib/api/yardLayoutApi"; +import type { YardManagementConfig } from "../types"; + +interface YardLayout { + id: number; + name: string; + description: string; + placement_count: number; + updated_at: string; +} + +interface YardManagement3DWidgetProps { + isEditMode?: boolean; + config?: YardManagementConfig; + onConfigChange?: (config: YardManagementConfig) => void; +} + +export default function YardManagement3DWidget({ + isEditMode = false, + config, + onConfigChange, +}: YardManagement3DWidgetProps) { + const [layouts, setLayouts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [editingLayout, setEditingLayout] = useState(null); + + // 레이아웃 목록 로드 + const loadLayouts = async () => { + try { + setIsLoading(true); + const response = await yardLayoutApi.getAllLayouts(); + if (response.success) { + setLayouts(response.data); + } + } catch (error) { + console.error("야드 레이아웃 목록 조회 실패:", error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (isEditMode) { + loadLayouts(); + } + }, [isEditMode]); + + // 레이아웃 선택 (편집 모드에서만) + const handleSelectLayout = (layout: YardLayout) => { + if (onConfigChange) { + onConfigChange({ + layoutId: layout.id, + layoutName: layout.name, + }); + } + }; + + // 새 레이아웃 생성 + const handleCreateLayout = async (name: string, description: string) => { + try { + const response = await yardLayoutApi.createLayout({ name, description }); + if (response.success) { + await loadLayouts(); + setIsCreateModalOpen(false); + setEditingLayout(response.data); + } + } catch (error) { + console.error("야드 레이아웃 생성 실패:", error); + throw error; + } + }; + + // 편집 완료 + const handleEditComplete = () => { + if (editingLayout && onConfigChange) { + onConfigChange({ + layoutId: editingLayout.id, + layoutName: editingLayout.name, + }); + } + setEditingLayout(null); + loadLayouts(); + }; + + // 편집 모드: 편집 중인 경우 YardEditor 표시 + if (isEditMode && editingLayout) { + return ( + + + + ); + } + + // 편집 모드: 레이아웃 선택 UI + if (isEditMode) { + return ( + + + + 야드 레이아웃 선택 + + {config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"} + + + setIsCreateModalOpen(true)} size="sm"> + 새 야드 생성 + + + + + {isLoading ? ( + + 로딩 중... + + ) : layouts.length === 0 ? ( + + + 🏗️ + 생성된 야드 레이아웃이 없습니다 + 먼저 야드 레이아웃을 생성하세요 + + + ) : ( + + {layouts.map((layout) => ( + + + handleSelectLayout(layout)} className="flex-1 text-left hover:opacity-80"> + + {layout.name} + {config?.layoutId === layout.id && } + + {layout.description && {layout.description}} + 배치된 자재: {layout.placement_count}개 + + { + e.stopPropagation(); + setEditingLayout(layout); + }} + > + 편집 + + + + ))} + + )} + + + {/* 생성 모달 */} + setIsCreateModalOpen(false)} + onCreate={handleCreateLayout} + /> + + ); + } + + // 뷰 모드: 선택된 레이아웃의 3D 뷰어 표시 + if (!config?.layoutId) { + return ( + + + 🏗️ + 야드 레이아웃이 설정되지 않았습니다 + 대시보드 편집에서 레이아웃을 선택하세요 + + + ); + } + + // 선택된 레이아웃의 3D 뷰어 표시 + return ( + + + + ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx new file mode 100644 index 00000000..2d813744 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialAddModal.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; + +interface TempMaterial { + id: number; + material_code: string; + material_name: string; + category: string; + unit: string; + default_color: string; + description: string; +} + +interface MaterialAddModalProps { + isOpen: boolean; + material: TempMaterial | null; + onClose: () => void; + onAdd: (placementData: any) => Promise; +} + +export default function MaterialAddModal({ isOpen, material, onClose, onAdd }: MaterialAddModalProps) { + const [quantity, setQuantity] = useState("1"); + const [positionX, setPositionX] = useState("0"); + const [positionY, setPositionY] = useState("0"); + const [positionZ, setPositionZ] = useState("0"); + const [sizeX, setSizeX] = useState("5"); + const [sizeY, setSizeY] = useState("5"); + const [sizeZ, setSizeZ] = useState("5"); + const [color, setColor] = useState(""); + const [isAdding, setIsAdding] = useState(false); + + // 모달이 열릴 때 기본값 설정 + const handleOpen = (open: boolean) => { + if (open && material) { + setColor(material.default_color); + setQuantity("1"); + setPositionX("0"); + setPositionY("0"); + setPositionZ("0"); + setSizeX("5"); + setSizeY("5"); + setSizeZ("5"); + } + }; + + // 자재 추가 + const handleAdd = async () => { + if (!material) return; + + setIsAdding(true); + try { + await onAdd({ + external_material_id: `TEMP-${Date.now()}`, + material_code: material.material_code, + material_name: material.material_name, + quantity: parseInt(quantity) || 1, + unit: material.unit, + position_x: parseFloat(positionX) || 0, + position_y: parseFloat(positionY) || 0, + position_z: parseFloat(positionZ) || 0, + size_x: parseFloat(sizeX) || 5, + size_y: parseFloat(sizeY) || 5, + size_z: parseFloat(sizeZ) || 5, + color: color || material.default_color, + }); + onClose(); + } catch (error) { + console.error("자재 추가 실패:", error); + } finally { + setIsAdding(false); + } + }; + + if (!material) return null; + + return ( + { + handleOpen(open); + if (!open) onClose(); + }} + > + + + 자재 배치 설정 + + + + {/* 자재 정보 */} + + 선택한 자재 + + + + {material.material_name} + {material.material_code} + + + + + {/* 수량 */} + + 수량 + + setQuantity(e.target.value)} + min="1" + className="flex-1" + /> + {material.unit} + + + + {/* 3D 위치 */} + + 3D 위치 + + + + X (좌우) + + setPositionX(e.target.value)} + step="0.5" + /> + + + + Y (높이) + + setPositionY(e.target.value)} + step="0.5" + /> + + + + Z (앞뒤) + + setPositionZ(e.target.value)} + step="0.5" + /> + + + + + {/* 3D 크기 */} + + 3D 크기 + + + + 너비 + + setSizeX(e.target.value)} + min="1" + step="0.5" + /> + + + + 높이 + + setSizeY(e.target.value)} + min="1" + step="0.5" + /> + + + + 깊이 + + setSizeZ(e.target.value)} + min="1" + step="0.5" + /> + + + + + {/* 색상 */} + + 색상 + + setColor(e.target.value)} + className="h-10 w-20 cursor-pointer rounded border" + /> + setColor(e.target.value)} className="flex-1" /> + + + + + + + 취소 + + + {isAdding ? ( + <> + + 추가 중... + > + ) : ( + "배치" + )} + + + + + ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialEditPanel.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialEditPanel.tsx new file mode 100644 index 00000000..d2388711 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialEditPanel.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Trash2 } from "lucide-react"; + +interface YardPlacement { + id: number; + external_material_id: string; + material_code: string; + material_name: string; + quantity: number; + unit: string; + position_x: number; + position_y: number; + position_z: number; + size_x: number; + size_y: number; + size_z: number; + color: string; + memo?: string; +} + +interface MaterialEditPanelProps { + placement: YardPlacement | null; + onClose: () => void; + onUpdate: (id: number, updates: Partial) => void; + onRemove: (id: number) => void; +} + +export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemove }: MaterialEditPanelProps) { + const [editData, setEditData] = useState>({}); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + // placement 변경 시 editData 초기화 + useEffect(() => { + if (placement) { + setEditData({ + position_x: placement.position_x, + position_y: placement.position_y, + position_z: placement.position_z, + size_x: placement.size_x, + size_y: placement.size_y, + size_z: placement.size_z, + color: placement.color, + memo: placement.memo, + }); + } + }, [placement]); + + if (!placement) return null; + + // 변경사항 적용 + const handleApply = () => { + onUpdate(placement.id, editData); + }; + + // 배치 해제 + const handleRemove = () => { + onRemove(placement.id); + setIsDeleteDialogOpen(false); + }; + + return ( + + + 자재 정보 + + 닫기 + + + + + {/* 읽기 전용 정보 */} + + 자재 정보 (읽기 전용) + + 자재 코드 + {placement.material_code} + + + 자재 이름 + {placement.material_name} + + + 수량 + + {placement.quantity} {placement.unit} + + + + + {/* 배치 정보 (편집 가능) */} + + 배치 정보 (편집 가능) + + {/* 3D 위치 */} + + 위치 + + + + X + + setEditData({ ...editData, position_x: parseFloat(e.target.value) || 0 })} + step="0.5" + className="h-8 text-xs" + /> + + + + Y + + setEditData({ ...editData, position_y: parseFloat(e.target.value) || 0 })} + step="0.5" + className="h-8 text-xs" + /> + + + + Z + + setEditData({ ...editData, position_z: parseFloat(e.target.value) || 0 })} + step="0.5" + className="h-8 text-xs" + /> + + + + + {/* 3D 크기 */} + + 크기 + + + + 너비 + + setEditData({ ...editData, size_x: parseFloat(e.target.value) || 1 })} + min="1" + step="0.5" + className="h-8 text-xs" + /> + + + + 높이 + + setEditData({ ...editData, size_y: parseFloat(e.target.value) || 1 })} + min="1" + step="0.5" + className="h-8 text-xs" + /> + + + + 깊이 + + setEditData({ ...editData, size_z: parseFloat(e.target.value) || 1 })} + min="1" + step="0.5" + className="h-8 text-xs" + /> + + + + + {/* 색상 */} + + + 색상 + + + setEditData({ ...editData, color: e.target.value })} + className="h-8 w-16 cursor-pointer rounded border" + /> + setEditData({ ...editData, color: e.target.value })} + className="h-8 flex-1 text-xs" + /> + + + + {/* 메모 */} + + + 메모 + + setEditData({ ...editData, memo: e.target.value })} + placeholder="메모를 입력하세요" + rows={3} + className="text-xs" + /> + + + {/* 적용 버튼 */} + + 변경사항 적용 + + + + {/* 배치 해제 */} + + setIsDeleteDialogOpen(true)} className="w-full" size="sm"> + + 배치 해제 + + + + + {/* 삭제 확인 모달 */} + + + + 배치 해제 + + 정말로 이 자재를 배치 해제하시겠습니까? + + "{placement.material_name}" ({placement.quantity} {placement.unit}) + + + + 취소 + + 해제 + + + + + + ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/MaterialLibrary.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialLibrary.tsx new file mode 100644 index 00000000..317ef37a --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/MaterialLibrary.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Search, Loader2 } from "lucide-react"; +import { materialApi } from "@/lib/api/yardLayoutApi"; + +interface TempMaterial { + id: number; + material_code: string; + material_name: string; + category: string; + unit: string; + default_color: string; + description: string; +} + +interface MaterialLibraryProps { + isOpen: boolean; + onClose: () => void; + onSelect: (material: TempMaterial) => void; +} + +export default function MaterialLibrary({ isOpen, onClose, onSelect }: MaterialLibraryProps) { + const [materials, setMaterials] = useState([]); + const [categories, setCategories] = useState([]); + const [searchText, setSearchText] = useState(""); + const [selectedCategory, setSelectedCategory] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [selectedMaterial, setSelectedMaterial] = useState(null); + + // 자재 목록 로드 + const loadMaterials = async () => { + try { + setIsLoading(true); + const [materialsResponse, categoriesResponse] = await Promise.all([ + materialApi.getTempMaterials({ + search: searchText || undefined, + category: selectedCategory || undefined, + page: 1, + limit: 50, + }), + materialApi.getCategories(), + ]); + + if (materialsResponse.success) { + setMaterials(materialsResponse.data); + } + + if (categoriesResponse.success) { + setCategories(categoriesResponse.data); + } + } catch (error) { + console.error("자재 목록 조회 실패:", error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (isOpen) { + loadMaterials(); + } + }, [isOpen, searchText, selectedCategory]); + + // 자재 선택 및 추가 + const handleSelectMaterial = () => { + if (selectedMaterial) { + onSelect(selectedMaterial); + setSelectedMaterial(null); + onClose(); + } + }; + + // 모달 닫기 + const handleClose = () => { + setSelectedMaterial(null); + setSearchText(""); + setSelectedCategory(""); + onClose(); + }; + + return ( + + + + 자재 선택 + + + + {/* 검색 및 필터 */} + + + + setSearchText(e.target.value)} + className="pl-9" + /> + + setSelectedCategory(e.target.value)} + className="rounded-md border border-gray-300 px-3 py-2 text-sm" + > + 전체 카테고리 + {categories.map((category) => ( + + {category} + + ))} + + + + {/* 자재 목록 */} + {isLoading ? ( + + + + ) : materials.length === 0 ? ( + + {searchText || selectedCategory ? "검색 결과가 없습니다" : "등록된 자재가 없습니다"} + + ) : ( + + + + + 색상 + 자재 코드 + 자재 이름 + 카테고리 + 단위 + + + + {materials.map((material) => ( + setSelectedMaterial(material)} + > + + + + {material.material_code} + {material.material_name} + {material.category} + {material.unit} + + ))} + + + + )} + + {/* 선택된 자재 정보 */} + {selectedMaterial && ( + + 선택된 자재 + + + + {selectedMaterial.material_name} + {selectedMaterial.material_code} + + + {selectedMaterial.description && ( + {selectedMaterial.description} + )} + + )} + + + + + 취소 + + + 선택 + + + + + ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx new file mode 100644 index 00000000..b9995e26 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { Canvas, useThree } from "@react-three/fiber"; +import { OrbitControls, Grid, Box } from "@react-three/drei"; +import { Suspense, useRef, useState, useEffect } from "react"; +import * as THREE from "three"; + +interface YardPlacement { + id: number; + external_material_id: string; + material_code: string; + material_name: string; + quantity: number; + unit: string; + position_x: number; + position_y: number; + position_z: number; + size_x: number; + size_y: number; + size_z: number; + color: string; +} + +interface Yard3DCanvasProps { + placements: YardPlacement[]; + selectedPlacementId: number | null; + onPlacementClick: (placement: YardPlacement) => void; + onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void; +} + +// 자재 박스 컴포넌트 (드래그 가능) +function MaterialBox({ + placement, + isSelected, + onClick, + onDrag, + onDragStart, + onDragEnd, +}: { + placement: YardPlacement; + isSelected: boolean; + onClick: () => void; + onDrag?: (position: { x: number; y: number; z: number }) => void; + onDragStart?: () => void; + onDragEnd?: () => void; +}) { + const meshRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const dragStartPos = useRef<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 }); + const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const { camera, gl } = useThree(); + + // 드래그 중이 아닐 때 위치 업데이트 + useEffect(() => { + if (!isDragging && meshRef.current) { + meshRef.current.position.set(placement.position_x, placement.position_y, placement.position_z); + } + }, [placement.position_x, placement.position_y, placement.position_z, isDragging]); + + // 전역 이벤트 리스너 등록 + useEffect(() => { + const handleGlobalMouseMove = (e: MouseEvent) => { + if (isDragging && onDrag && meshRef.current) { + e.preventDefault(); + e.stopPropagation(); + + // 마우스 이동 거리 계산 (픽셀) + const deltaX = e.clientX - mouseStartPos.current.x; + const deltaY = e.clientY - mouseStartPos.current.y; + + // 카메라 거리를 고려한 스케일 팩터 + const distance = camera.position.distanceTo(meshRef.current.position); + const scaleFactor = distance / 500; // 조정 가능한 값 + + // 카메라 방향 벡터 + const cameraDirection = new THREE.Vector3(); + camera.getWorldDirection(cameraDirection); + + // 카메라의 우측 벡터 (X축 이동용) + const right = new THREE.Vector3(); + right.crossVectors(camera.up, cameraDirection).normalize(); + + // 실제 3D 공간에서의 이동량 계산 + const moveRight = right.multiplyScalar(-deltaX * scaleFactor); + const moveForward = new THREE.Vector3(-cameraDirection.x, 0, -cameraDirection.z) + .normalize() + .multiplyScalar(deltaY * scaleFactor); + + // 최종 위치 계산 + const finalX = dragStartPos.current.x + moveRight.x + moveForward.x; + const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z; + + // NaN 검증 + if (isNaN(finalX) || isNaN(finalZ)) { + return; + } + + // 즉시 mesh 위치 업데이트 (부드러운 드래그) + meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ); + + // 상태 업데이트 (저장용) + onDrag({ + x: finalX, + y: dragStartPos.current.y, + z: finalZ, + }); + } + }; + + const handleGlobalMouseUp = () => { + if (isDragging) { + setIsDragging(false); + gl.domElement.style.cursor = isSelected ? "grab" : "pointer"; + if (onDragEnd) { + onDragEnd(); + } + } + }; + + if (isDragging) { + window.addEventListener("mousemove", handleGlobalMouseMove); + window.addEventListener("mouseup", handleGlobalMouseUp); + + return () => { + window.removeEventListener("mousemove", handleGlobalMouseMove); + window.removeEventListener("mouseup", handleGlobalMouseUp); + }; + } + }, [isDragging, onDrag, onDragEnd, camera, isSelected, gl.domElement]); + + const handlePointerDown = (e: any) => { + e.stopPropagation(); + + // 뷰어 모드(onDrag 없음)에서는 클릭만 처리 + if (!onDrag) { + return; + } + + // 편집 모드에서 선택되었고 드래그 가능한 경우 + if (isSelected && meshRef.current) { + // 드래그 시작 시점의 자재 위치 저장 (숫자로 변환) + dragStartPos.current = { + x: Number(placement.position_x), + y: Number(placement.position_y), + z: Number(placement.position_z), + }; + + // 마우스 시작 위치 저장 + mouseStartPos.current = { + x: e.clientX, + y: e.clientY, + }; + + setIsDragging(true); + gl.domElement.style.cursor = "grabbing"; + if (onDragStart) { + onDragStart(); + } + } + }; + + return ( + { + e.stopPropagation(); + e.nativeEvent?.stopPropagation(); + e.nativeEvent?.stopImmediatePropagation(); + console.log("3D Box clicked:", placement.material_name); + onClick(); + }} + onPointerDown={handlePointerDown} + onPointerOver={() => { + // 뷰어 모드(onDrag 없음)에서는 기본 커서, 편집 모드에서는 grab 커서 + if (onDrag) { + gl.domElement.style.cursor = isSelected ? "grab" : "pointer"; + } else { + gl.domElement.style.cursor = "pointer"; + } + }} + onPointerOut={() => { + if (!isDragging) { + gl.domElement.style.cursor = "default"; + } + }} + > + + + ); +} + +// 3D 씬 컴포넌트 +function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) { + const [isDraggingAny, setIsDraggingAny] = useState(false); + const orbitControlsRef = useRef(null); + + return ( + <> + {/* 조명 */} + + + + + {/* 바닥 그리드 */} + + + {/* 자재 박스들 */} + {placements.map((placement) => ( + onPlacementClick(placement)} + onDrag={onPlacementDrag ? (position) => onPlacementDrag(placement.id, position) : undefined} + onDragStart={() => { + setIsDraggingAny(true); + if (orbitControlsRef.current) { + orbitControlsRef.current.enabled = false; + } + }} + onDragEnd={() => { + setIsDraggingAny(false); + if (orbitControlsRef.current) { + orbitControlsRef.current.enabled = true; + } + }} + /> + ))} + + {/* 카메라 컨트롤 */} + + > + ); +} + +export default function Yard3DCanvas({ + placements, + selectedPlacementId, + onPlacementClick, + onPlacementDrag, +}: Yard3DCanvasProps) { + const handleCanvasClick = (e: any) => { + // Canvas의 빈 공간을 클릭했을 때만 선택 해제 + // e.target이 canvas 엘리먼트인 경우 + if (e.target.tagName === "CANVAS") { + onPlacementClick(null as any); + } + }; + + return ( + + + + + + + + ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx new file mode 100644 index 00000000..2c6f1bf4 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DViewer.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Yard3DCanvas from "./Yard3DCanvas"; +import { yardLayoutApi } from "@/lib/api/yardLayoutApi"; +import { Loader2 } from "lucide-react"; + +interface YardPlacement { + id: number; + yard_layout_id: number; + external_material_id: string; + material_code: string; + material_name: string; + quantity: number; + unit: string; + position_x: number; + position_y: number; + position_z: number; + size_x: number; + size_y: number; + size_z: number; + color: string; + status?: string; + memo?: string; +} + +interface Yard3DViewerProps { + layoutId: number; +} + +export default function Yard3DViewer({ layoutId }: Yard3DViewerProps) { + const [placements, setPlacements] = useState([]); + const [selectedPlacement, setSelectedPlacement] = useState(null); + const [layoutName, setLayoutName] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // 선택 변경 로그 + const handlePlacementClick = (placement: YardPlacement | null) => { + console.log("Yard3DViewer - Placement clicked:", placement?.material_name); + setSelectedPlacement(placement); + }; + + // 선택 상태 변경 감지 + useEffect(() => { + console.log("selectedPlacement changed:", selectedPlacement?.material_name); + }, [selectedPlacement]); + + // 야드 레이아웃 및 배치 데이터 로드 + useEffect(() => { + const loadData = async () => { + try { + setIsLoading(true); + setError(null); + + // 야드 레이아웃 정보 조회 + const layoutResponse = await yardLayoutApi.getLayoutById(layoutId); + if (layoutResponse.success) { + setLayoutName(layoutResponse.data.name); + } + + // 배치 데이터 조회 + const placementsResponse = await yardLayoutApi.getPlacementsByLayoutId(layoutId); + if (placementsResponse.success) { + setPlacements(placementsResponse.data); + } else { + setError("배치 데이터를 불러올 수 없습니다."); + } + } catch (err) { + console.error("데이터 로드 실패:", err); + setError("데이터를 불러오는 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [layoutId]); + + if (isLoading) { + return ( + + + + 3D 장면 로딩 중... + + + ); + } + + if (error) { + return ( + + + ⚠️ + {error} + + + ); + } + + if (placements.length === 0) { + return ( + + + 📦 + 배치된 자재가 없습니다 + + + ); + } + + return ( + + {/* 3D 캔버스 */} + + + {/* 야드 이름 (좌측 상단) */} + {layoutName && ( + + {layoutName} + + )} + + {/* 선택된 자재 정보 패널 (우측 상단) */} + {selectedPlacement && ( + + + 자재 정보 + { + setSelectedPlacement(null); + }} + className="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600" + > + ✕ + + + + + + 자재명 + {selectedPlacement.material_name} + + + + 수량 + + {selectedPlacement.quantity} {selectedPlacement.unit} + + + + + )} + + ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx new file mode 100644 index 00000000..1d93f6a9 --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -0,0 +1,461 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { ArrowLeft, Save, Loader2, X } from "lucide-react"; +import { yardLayoutApi, materialApi } from "@/lib/api/yardLayoutApi"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import dynamic from "next/dynamic"; + +const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { + ssr: false, + loading: () => ( + + + + ), +}); + +interface TempMaterial { + id: number; + material_code: string; + material_name: string; + category: string; + unit: string; + default_color: string; + description: string; +} + +interface YardLayout { + id: number; + name: string; + description: string; + placement_count?: number; + updated_at: string; +} + +interface YardPlacement { + id: number; + yard_layout_id: number; + external_material_id: string; + material_code: string; + material_name: string; + quantity: number; + unit: string; + position_x: number; + position_y: number; + position_z: number; + size_x: number; + size_y: number; + size_z: number; + color: string; + memo?: string; +} + +interface YardEditorProps { + layout: YardLayout; + onBack: () => void; +} + +export default function YardEditor({ layout, onBack }: YardEditorProps) { + const [placements, setPlacements] = useState([]); + const [materials, setMaterials] = useState([]); + const [selectedPlacement, setSelectedPlacement] = useState(null); + const [selectedMaterial, setSelectedMaterial] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + + // 배치 목록 & 자재 목록 로드 + useEffect(() => { + const loadData = async () => { + try { + setIsLoading(true); + const [placementsRes, materialsRes] = await Promise.all([ + yardLayoutApi.getPlacementsByLayoutId(layout.id), + materialApi.getTempMaterials({ limit: 100 }), + ]); + + if (placementsRes.success) { + setPlacements(placementsRes.data); + } + if (materialsRes.success) { + setMaterials(materialsRes.data); + } + } catch (error) { + console.error("데이터 로드 실패:", error); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [layout.id]); + + // 자재 클릭 → 배치 추가 + const handleMaterialClick = async (material: TempMaterial) => { + // 이미 배치되었는지 확인 + const alreadyPlaced = placements.find((p) => p.material_code === material.material_code); + if (alreadyPlaced) { + alert("이미 배치된 자재입니다."); + return; + } + + setSelectedMaterial(material); + + // 기본 위치에 배치 + const placementData = { + external_material_id: `TEMP-${material.id}`, + material_code: material.material_code, + material_name: material.material_name, + quantity: 1, + unit: material.unit, + position_x: 0, + position_y: 0, + position_z: 0, + size_x: 5, + size_y: 5, + size_z: 5, + color: material.default_color, + }; + + try { + const response = await yardLayoutApi.addMaterialPlacement(layout.id, placementData); + if (response.success) { + setPlacements((prev) => [...prev, response.data]); + setSelectedPlacement(response.data); + setSelectedMaterial(null); + } + } catch (error: any) { + console.error("자재 배치 실패:", error); + alert("자재 배치에 실패했습니다."); + } + }; + + // 자재 드래그 (3D 캔버스에서) + const handlePlacementDrag = (id: number, position: { x: number; y: number; z: number }) => { + const updatedPosition = { + position_x: Math.round(position.x * 2) / 2, + position_y: position.y, + position_z: Math.round(position.z * 2) / 2, + }; + + setPlacements((prev) => + prev.map((p) => + p.id === id + ? { + ...p, + ...updatedPosition, + } + : p, + ), + ); + + // 선택된 자재도 업데이트 + if (selectedPlacement?.id === id) { + setSelectedPlacement((prev) => + prev + ? { + ...prev, + ...updatedPosition, + } + : null, + ); + } + }; + + // 자재 배치 해제 + const handlePlacementRemove = async (id: number) => { + try { + const response = await yardLayoutApi.removePlacement(id); + if (response.success) { + setPlacements((prev) => prev.filter((p) => p.id !== id)); + setSelectedPlacement(null); + } + } catch (error) { + console.error("배치 해제 실패:", error); + alert("배치 해제에 실패했습니다."); + } + }; + + // 위치/크기/색상 업데이트 + const handlePlacementUpdate = (id: number, updates: Partial) => { + setPlacements((prev) => prev.map((p) => (p.id === id ? { ...p, ...updates } : p))); + }; + + // 저장 + const handleSave = async () => { + setIsSaving(true); + try { + const response = await yardLayoutApi.batchUpdatePlacements( + layout.id, + placements.map((p) => ({ + id: p.id, + position_x: p.position_x, + position_y: p.position_y, + position_z: p.position_z, + size_x: p.size_x, + size_y: p.size_y, + size_z: p.size_z, + color: p.color, + })), + ); + + if (response.success) { + alert("저장되었습니다"); + } + } catch (error) { + console.error("저장 실패:", error); + alert("저장에 실패했습니다"); + } finally { + setIsSaving(false); + } + }; + + // 필터링된 자재 목록 + const filteredMaterials = materials.filter( + (m) => + m.material_name.toLowerCase().includes(searchTerm.toLowerCase()) || + m.material_code.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + return ( + + {/* 상단 툴바 */} + + + + + 목록으로 + + + {layout.name} + {layout.description && {layout.description}} + + + + + {isSaving ? ( + <> + + 저장 중... + > + ) : ( + <> + + 저장 + > + )} + + + + {/* 메인 컨텐츠 영역 */} + + {/* 좌측: 3D 캔버스 */} + + {isLoading ? ( + + + + ) : ( + + )} + + + {/* 우측: 자재 목록 또는 편집 패널 */} + + {selectedPlacement ? ( + // 선택된 자재 편집 패널 + + + 자재 정보 + setSelectedPlacement(null)}> + + + + + + + {/* 읽기 전용 정보 */} + + 자재 코드 + {selectedPlacement.material_code} + + + + 자재명 + {selectedPlacement.material_name} + + + + 수량 (변경 불가) + + {selectedPlacement.quantity} {selectedPlacement.unit} + + + + {/* 편집 가능 정보 */} + + 배치 정보 + + + + X + + handlePlacementUpdate(selectedPlacement.id, { + position_x: parseFloat(e.target.value), + }) + } + /> + + + Y + + handlePlacementUpdate(selectedPlacement.id, { + position_y: parseFloat(e.target.value), + }) + } + /> + + + Z + + handlePlacementUpdate(selectedPlacement.id, { + position_z: parseFloat(e.target.value), + }) + } + /> + + + + + + 너비 + + handlePlacementUpdate(selectedPlacement.id, { + size_x: parseFloat(e.target.value), + }) + } + /> + + + 높이 + + handlePlacementUpdate(selectedPlacement.id, { + size_y: parseFloat(e.target.value), + }) + } + /> + + + 깊이 + + handlePlacementUpdate(selectedPlacement.id, { + size_z: parseFloat(e.target.value), + }) + } + /> + + + + + 색상 + handlePlacementUpdate(selectedPlacement.id, { color: e.target.value })} + /> + + + + handlePlacementRemove(selectedPlacement.id)} + > + 배치 해제 + + + + + ) : ( + // 자재 목록 + + + 자재 목록 + setSearchTerm(e.target.value)} + className="text-sm" + /> + + + + {filteredMaterials.length === 0 ? ( + + 검색 결과가 없습니다 + + ) : ( + + {filteredMaterials.map((material) => { + const isPlaced = placements.some((p) => p.material_code === material.material_code); + return ( + !isPlaced && handleMaterialClick(material)} + disabled={isPlaced} + className={`mb-2 w-full rounded-lg border p-3 text-left transition-all ${ + isPlaced + ? "cursor-not-allowed border-gray-200 bg-gray-50 opacity-50" + : "cursor-pointer border-gray-200 bg-white hover:border-blue-500 hover:shadow-sm" + }`} + > + {material.material_name} + {material.material_code} + {material.category} + {isPlaced && 배치됨} + + ); + })} + + )} + + + )} + + + + ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx new file mode 100644 index 00000000..14514f9f --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutCreateModal.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Loader2 } from "lucide-react"; + +interface YardLayoutCreateModalProps { + isOpen: boolean; + onClose: () => void; + onCreate: (name: string, description: string) => Promise; +} + +export default function YardLayoutCreateModal({ isOpen, onClose, onCreate }: YardLayoutCreateModalProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(""); + + // 생성 실행 + const handleCreate = async () => { + if (!name.trim()) { + setError("야드 이름을 입력하세요"); + return; + } + + setIsCreating(true); + setError(""); + + try { + await onCreate(name.trim(), description.trim()); + setName(""); + setDescription(""); + } catch (error: any) { + console.error("야드 생성 실패:", error); + setError(error.message || "야드 생성에 실패했습니다"); + } finally { + setIsCreating(false); + } + }; + + // 모달 닫기 + const handleClose = () => { + if (isCreating) return; + setName(""); + setDescription(""); + setError(""); + onClose(); + }; + + // Enter 키 처리 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleCreate(); + } + }; + + return ( + + + + 새 야드 생성 + 야드의 이름과 설명을 입력하세요 + + + + {/* 야드 이름 */} + + + 야드 이름 * + + { + setName(e.target.value); + setError(""); + }} + onKeyDown={handleKeyDown} + placeholder="예: A구역, 1번 야드" + disabled={isCreating} + autoFocus + /> + + + {/* 설명 */} + + 설명 + setDescription(e.target.value)} + placeholder="야드에 대한 설명을 입력하세요 (선택사항)" + rows={3} + disabled={isCreating} + /> + + + {/* 에러 메시지 */} + {error && {error}} + + + + + 취소 + + + {isCreating ? ( + <> + + 생성 중... + > + ) : ( + "생성" + )} + + + + + ); +} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutList.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutList.tsx new file mode 100644 index 00000000..ab80f20f --- /dev/null +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardLayoutList.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Search, MoreVertical, Loader2 } from "lucide-react"; + +interface YardLayout { + id: number; + name: string; + description: string; + placement_count: number; + updated_at: string; +} + +interface YardLayoutListProps { + layouts: YardLayout[]; + isLoading: boolean; + onSelect: (layout: YardLayout) => void; + onDelete: (id: number) => Promise; + onDuplicate: (id: number, newName: string) => Promise; +} + +export default function YardLayoutList({ layouts, isLoading, onSelect, onDelete, onDuplicate }: YardLayoutListProps) { + const [searchText, setSearchText] = useState(""); + const [sortOrder, setSortOrder] = useState<"recent" | "name">("recent"); + const [deleteTarget, setDeleteTarget] = useState(null); + const [duplicateTarget, setDuplicateTarget] = useState(null); + const [duplicateName, setDuplicateName] = useState(""); + const [isDeleting, setIsDeleting] = useState(false); + const [isDuplicating, setIsDuplicating] = useState(false); + + // 검색 필터링 + const filteredLayouts = layouts.filter((layout) => { + if (!searchText) return true; + return ( + layout.name.toLowerCase().includes(searchText.toLowerCase()) || + layout.description?.toLowerCase().includes(searchText.toLowerCase()) + ); + }); + + // 정렬 + const sortedLayouts = [...filteredLayouts].sort((a, b) => { + if (sortOrder === "recent") { + return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); + } else { + return a.name.localeCompare(b.name); + } + }); + + // 날짜 포맷팅 + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + }; + + // 삭제 확인 + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + + setIsDeleting(true); + try { + await onDelete(deleteTarget.id); + setDeleteTarget(null); + } catch (error) { + console.error("삭제 실패:", error); + } finally { + setIsDeleting(false); + } + }; + + // 복제 실행 + const handleDuplicateConfirm = async () => { + if (!duplicateTarget || !duplicateName.trim()) return; + + setIsDuplicating(true); + try { + await onDuplicate(duplicateTarget.id, duplicateName); + setDuplicateTarget(null); + setDuplicateName(""); + } catch (error) { + console.error("복제 실패:", error); + } finally { + setIsDuplicating(false); + } + }; + + // 복제 모달 열기 + const handleDuplicateClick = (layout: YardLayout) => { + setDuplicateTarget(layout); + setDuplicateName(`${layout.name} (복사본)`); + }; + + if (isLoading) { + return ( + + + + ); + } + + return ( + + {/* 검색 및 정렬 */} + + + + setSearchText(e.target.value)} + className="pl-9" + /> + + setSortOrder(e.target.value as "recent" | "name")} + className="rounded-md border border-gray-300 px-3 py-2 text-sm" + > + 최근 수정순 + 이름순 + + + + {/* 테이블 */} + {sortedLayouts.length === 0 ? ( + + + {searchText ? "검색 결과가 없습니다" : "등록된 야드가 없습니다"} + + + ) : ( + + + + + 야드명 + 설명 + 배치 자재 + 최종 수정 + 작업 + + + + {sortedLayouts.map((layout) => ( + onSelect(layout)}> + {layout.name} + {layout.description || "-"} + {layout.placement_count}개 + {formatDate(layout.updated_at)} + + + e.stopPropagation()}> + + + + + + onSelect(layout)}>편집 + handleDuplicateClick(layout)}>복제 + setDeleteTarget(layout)} className="text-red-600"> + 삭제 + + + + + + ))} + + + + )} + + {/* 총 개수 */} + 총 {sortedLayouts.length}개 + + {/* 삭제 확인 모달 */} + setDeleteTarget(null)}> + + + 야드 삭제 + + 정말로 "{deleteTarget?.name}" 야드를 삭제하시겠습니까? + + 배치된 자재 정보도 함께 삭제됩니다. + + + + 취소 + + {isDeleting ? ( + <> + + 삭제 중... + > + ) : ( + "삭제" + )} + + + + + + {/* 복제 모달 */} + setDuplicateTarget(null)}> + + + 야드 복제 + 새로운 야드의 이름을 입력하세요 + + + + 야드 이름 + setDuplicateName(e.target.value)} + placeholder="야드 이름을 입력하세요" + /> + + + + setDuplicateTarget(null)} disabled={isDuplicating}> + 취소 + + + {isDuplicating ? ( + <> + + 복제 중... + > + ) : ( + "복제" + )} + + + + + + ); +} diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 1a8d007f..9b6e83f8 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -39,13 +39,9 @@ const ListWidget = dynamic( { ssr: false }, ); -const Warehouse3DWidget = dynamic( - () => - import("@/components/admin/dashboard/widgets/Warehouse3DWidget").then((mod) => ({ - default: mod.Warehouse3DWidget, - })), - { ssr: false }, -); +const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboard/widgets/YardManagement3DWidget"), { + ssr: false, +}); /** * 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리 @@ -85,8 +81,8 @@ function renderWidget(element: DashboardElement) { case "list": return ; - case "warehouse-3d": - return ; + case "yard-management-3d": + return ; // === 차량 관련 (추가 위젯) === case "vehicle-status": @@ -135,7 +131,13 @@ interface DashboardViewerProps { * - 실시간 데이터 업데이트 * - 편집 화면과 동일한 레이아웃 (중앙 정렬, 고정 크기) */ -export function DashboardViewer({ elements, dashboardId, refreshInterval, backgroundColor = "#f9fafb", resolution = "fhd" }: DashboardViewerProps) { +export function DashboardViewer({ + elements, + dashboardId, + refreshInterval, + backgroundColor = "#f9fafb", + resolution = "fhd", +}: DashboardViewerProps) { const [elementData, setElementData] = useState>({}); const [loadingElements, setLoadingElements] = useState>(new Set()); diff --git a/frontend/lib/api/yardLayoutApi.ts b/frontend/lib/api/yardLayoutApi.ts new file mode 100644 index 00000000..2dbd9f4c --- /dev/null +++ b/frontend/lib/api/yardLayoutApi.ts @@ -0,0 +1,84 @@ +import { apiCall } from "./client"; + +// 야드 레이아웃 관리 API +export const yardLayoutApi = { + // 모든 야드 레이아웃 목록 조회 + async getAllLayouts() { + return apiCall("GET", "/yard-layouts"); + }, + + // 특정 야드 레이아웃 상세 조회 + async getLayoutById(id: number) { + return apiCall("GET", `/yard-layouts/${id}`); + }, + + // 새 야드 레이아웃 생성 + async createLayout(data: { name: string; description?: string }) { + return apiCall("POST", "/yard-layouts", data); + }, + + // 야드 레이아웃 수정 + async updateLayout(id: number, data: { name?: string; description?: string }) { + return apiCall("PUT", `/yard-layouts/${id}`, data); + }, + + // 야드 레이아웃 삭제 + async deleteLayout(id: number) { + return apiCall("DELETE", `/yard-layouts/${id}`); + }, + + // 야드 레이아웃 복제 + async duplicateLayout(id: number, name: string) { + return apiCall("POST", `/yard-layouts/${id}/duplicate`, { name }); + }, + + // 특정 야드의 배치 자재 목록 조회 + async getPlacementsByLayoutId(layoutId: number) { + return apiCall("GET", `/yard-layouts/${layoutId}/placements`); + }, + + // 야드에 자재 배치 추가 + async addMaterialPlacement(layoutId: number, data: any) { + return apiCall("POST", `/yard-layouts/${layoutId}/placements`, data); + }, + + // 배치 정보 수정 + async updatePlacement(placementId: number, data: any) { + return apiCall("PUT", `/yard-layouts/placements/${placementId}`, data); + }, + + // 배치 해제 + async removePlacement(placementId: number) { + return apiCall("DELETE", `/yard-layouts/placements/${placementId}`); + }, + + // 여러 배치 일괄 업데이트 + async batchUpdatePlacements(layoutId: number, placements: any[]) { + return apiCall("PUT", `/yard-layouts/${layoutId}/placements/batch`, { placements }); + }, +}; + +// 자재 관리 API +export const materialApi = { + // 임시 자재 마스터 목록 조회 + async getTempMaterials(params?: { search?: string; category?: string; page?: number; limit?: number }) { + const queryParams = new URLSearchParams(); + if (params?.search) queryParams.append("search", params.search); + if (params?.category) queryParams.append("category", params.category); + if (params?.page) queryParams.append("page", params.page.toString()); + if (params?.limit) queryParams.append("limit", params.limit.toString()); + + const queryString = queryParams.toString(); + return apiCall("GET", `/materials/temp${queryString ? `?${queryString}` : ""}`); + }, + + // 특정 자재 상세 조회 + async getTempMaterialByCode(code: string) { + return apiCall("GET", `/materials/temp/${code}`); + }, + + // 카테고리 목록 조회 + async getCategories() { + return apiCall("GET", "/materials/temp/categories"); + }, +};
+ {config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"} +
{layout.description}