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