일단 야드관리 3d 드래그앤 드랍까지

This commit is contained in:
dohyeons 2025-10-17 15:26:21 +09:00
parent b41da3261c
commit 184d687f0f
23 changed files with 3401 additions and 96 deletions

View File

@ -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. 상태 관리 최적화

View File

@ -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);

View File

@ -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();

View File

@ -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();

View File

@ -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;

View File

@ -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;

View File

@ -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();

View File

@ -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();

View File

@ -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: () => <div className="flex h-full items-center justify-center text-sm text-gray-500"> ...</div>,
});
interface CanvasElementProps {
element: DashboardElement;
isSelected: boolean;
@ -707,6 +713,17 @@ export function CanvasElement({
}}
/>
</div>
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
// 야드 관리 3D 위젯 렌더링
<div className="widget-interactive-area h-full w-full">
<YardManagement3DWidget
isEditMode={true}
config={element.yardConfig}
onConfigChange={(newConfig) => {
onUpdate(element.id, { yardConfig: newConfig });
}}
/>
</div>
) : element.type === "widget" && element.subtype === "todo" ? (
// To-Do 위젯 렌더링
<div className="widget-interactive-area h-full w-full">

View File

@ -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 "위젯 내용이 여기에 표시됩니다";
}

View File

@ -181,6 +181,7 @@ export function DashboardTopMenu({
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="list"> </SelectItem>
<SelectItem value="yard-management-3d"> 3D</SelectItem>
{/* <SelectItem value="map">지도</SelectItem> */}
<SelectItem value="map-summary"> </SelectItem>
{/* <SelectItem value="list-summary">커스텀 목록 카드</SelectItem> */}

View File

@ -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; // 레이아웃 이름 (표시용)
}

View File

@ -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<YardLayout[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingLayout, setEditingLayout] = useState<YardLayout | null>(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 (
<div className="h-full w-full">
<YardEditor layout={editingLayout} onBack={handleEditComplete} />
</div>
);
}
// 편집 모드: 레이아웃 선택 UI
if (isEditMode) {
return (
<div className="flex h-full w-full flex-col bg-white">
<div className="flex items-center justify-between border-b p-4">
<div>
<h3 className="text-sm font-semibold text-gray-700"> </h3>
<p className="mt-1 text-xs text-gray-500">
{config?.layoutName ? `선택됨: ${config.layoutName}` : "표시할 야드 레이아웃을 선택하세요"}
</p>
</div>
<Button onClick={() => setIsCreateModalOpen(true)} size="sm">
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-auto p-4">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<div className="text-sm text-gray-500"> ...</div>
</div>
) : layouts.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-4xl">🏗</div>
<div className="text-sm text-gray-600"> </div>
<div className="mt-1 text-xs text-gray-400"> </div>
</div>
</div>
) : (
<div className="grid gap-3">
{layouts.map((layout) => (
<div
key={layout.id}
className={`rounded-lg border p-3 transition-all ${
config?.layoutId === layout.id ? "border-blue-500 bg-blue-50" : "border-gray-200 bg-white"
}`}
>
<div className="flex items-start justify-between gap-3">
<button onClick={() => handleSelectLayout(layout)} className="flex-1 text-left hover:opacity-80">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{layout.name}</span>
{config?.layoutId === layout.id && <Check className="h-4 w-4 text-blue-600" />}
</div>
{layout.description && <p className="mt-1 text-xs text-gray-500">{layout.description}</p>}
<div className="mt-2 text-xs text-gray-400"> : {layout.placement_count}</div>
</button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
setEditingLayout(layout);
}}
>
</Button>
</div>
</div>
))}
</div>
)}
</div>
{/* 생성 모달 */}
<YardLayoutCreateModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onCreate={handleCreateLayout}
/>
</div>
);
}
// 뷰 모드: 선택된 레이아웃의 3D 뷰어 표시
if (!config?.layoutId) {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mb-2 text-4xl">🏗</div>
<div className="text-sm font-medium text-gray-600"> </div>
<div className="mt-1 text-xs text-gray-400"> </div>
</div>
</div>
);
}
// 선택된 레이아웃의 3D 뷰어 표시
return (
<div className="h-full w-full">
<Yard3DViewer layoutId={config.layoutId} />
</div>
);
}

View File

@ -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<void>;
}
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 (
<Dialog
open={isOpen}
onOpenChange={(open) => {
handleOpen(open);
if (!open) onClose();
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 자재 정보 */}
<div className="rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-sm font-medium text-gray-600"> </div>
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded border" style={{ backgroundColor: material.default_color }} />
<div>
<div className="font-medium">{material.material_name}</div>
<div className="text-sm text-gray-600">{material.material_code}</div>
</div>
</div>
</div>
{/* 수량 */}
<div className="space-y-2">
<Label htmlFor="quantity"></Label>
<div className="flex items-center gap-2">
<Input
id="quantity"
type="number"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
min="1"
className="flex-1"
/>
<span className="text-sm text-gray-600">{material.unit}</span>
</div>
</div>
{/* 3D 위치 */}
<div className="space-y-2">
<Label>3D </Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="posX" className="text-xs text-gray-600">
X ()
</Label>
<Input
id="posX"
type="number"
value={positionX}
onChange={(e) => setPositionX(e.target.value)}
step="0.5"
/>
</div>
<div>
<Label htmlFor="posY" className="text-xs text-gray-600">
Y ()
</Label>
<Input
id="posY"
type="number"
value={positionY}
onChange={(e) => setPositionY(e.target.value)}
step="0.5"
/>
</div>
<div>
<Label htmlFor="posZ" className="text-xs text-gray-600">
Z ()
</Label>
<Input
id="posZ"
type="number"
value={positionZ}
onChange={(e) => setPositionZ(e.target.value)}
step="0.5"
/>
</div>
</div>
</div>
{/* 3D 크기 */}
<div className="space-y-2">
<Label>3D </Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="sizeX" className="text-xs text-gray-600">
</Label>
<Input
id="sizeX"
type="number"
value={sizeX}
onChange={(e) => setSizeX(e.target.value)}
min="1"
step="0.5"
/>
</div>
<div>
<Label htmlFor="sizeY" className="text-xs text-gray-600">
</Label>
<Input
id="sizeY"
type="number"
value={sizeY}
onChange={(e) => setSizeY(e.target.value)}
min="1"
step="0.5"
/>
</div>
<div>
<Label htmlFor="sizeZ" className="text-xs text-gray-600">
</Label>
<Input
id="sizeZ"
type="number"
value={sizeZ}
onChange={(e) => setSizeZ(e.target.value)}
min="1"
step="0.5"
/>
</div>
</div>
</div>
{/* 색상 */}
<div className="space-y-2">
<Label htmlFor="color"></Label>
<div className="flex items-center gap-2">
<input
id="color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-10 w-20 cursor-pointer rounded border"
/>
<Input value={color} onChange={(e) => setColor(e.target.value)} className="flex-1" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isAdding}>
</Button>
<Button onClick={handleAdd} disabled={isAdding}>
{isAdding ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"배치"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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<YardPlacement>) => void;
onRemove: (id: number) => void;
}
export default function MaterialEditPanel({ placement, onClose, onUpdate, onRemove }: MaterialEditPanelProps) {
const [editData, setEditData] = useState<Partial<YardPlacement>>({});
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 (
<div className="w-80 border-l bg-white p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold"> </h3>
<Button variant="ghost" size="sm" onClick={onClose}>
</Button>
</div>
<div className="space-y-4">
{/* 읽기 전용 정보 */}
<div className="space-y-3 rounded-lg bg-gray-50 p-3">
<div className="text-xs font-medium text-gray-500"> ( )</div>
<div>
<div className="text-xs text-gray-600"> </div>
<div className="mt-1 text-sm font-medium">{placement.material_code}</div>
</div>
<div>
<div className="text-xs text-gray-600"> </div>
<div className="mt-1 text-sm font-medium">{placement.material_name}</div>
</div>
<div>
<div className="text-xs text-gray-600"></div>
<div className="mt-1 text-sm font-medium">
{placement.quantity} {placement.unit}
</div>
</div>
</div>
{/* 배치 정보 (편집 가능) */}
<div className="space-y-3">
<div className="text-xs font-medium text-gray-500"> ( )</div>
{/* 3D 위치 */}
<div>
<Label className="text-xs"></Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="edit-posX" className="text-xs text-gray-600">
X
</Label>
<Input
id="edit-posX"
type="number"
value={editData.position_x ?? placement.position_x}
onChange={(e) => setEditData({ ...editData, position_x: parseFloat(e.target.value) || 0 })}
step="0.5"
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="edit-posY" className="text-xs text-gray-600">
Y
</Label>
<Input
id="edit-posY"
type="number"
value={editData.position_y ?? placement.position_y}
onChange={(e) => setEditData({ ...editData, position_y: parseFloat(e.target.value) || 0 })}
step="0.5"
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="edit-posZ" className="text-xs text-gray-600">
Z
</Label>
<Input
id="edit-posZ"
type="number"
value={editData.position_z ?? placement.position_z}
onChange={(e) => setEditData({ ...editData, position_z: parseFloat(e.target.value) || 0 })}
step="0.5"
className="h-8 text-xs"
/>
</div>
</div>
</div>
{/* 3D 크기 */}
<div>
<Label className="text-xs"></Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label htmlFor="edit-sizeX" className="text-xs text-gray-600">
</Label>
<Input
id="edit-sizeX"
type="number"
value={editData.size_x ?? placement.size_x}
onChange={(e) => setEditData({ ...editData, size_x: parseFloat(e.target.value) || 1 })}
min="1"
step="0.5"
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="edit-sizeY" className="text-xs text-gray-600">
</Label>
<Input
id="edit-sizeY"
type="number"
value={editData.size_y ?? placement.size_y}
onChange={(e) => setEditData({ ...editData, size_y: parseFloat(e.target.value) || 1 })}
min="1"
step="0.5"
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="edit-sizeZ" className="text-xs text-gray-600">
</Label>
<Input
id="edit-sizeZ"
type="number"
value={editData.size_z ?? placement.size_z}
onChange={(e) => setEditData({ ...editData, size_z: parseFloat(e.target.value) || 1 })}
min="1"
step="0.5"
className="h-8 text-xs"
/>
</div>
</div>
</div>
{/* 색상 */}
<div>
<Label htmlFor="edit-color" className="text-xs">
</Label>
<div className="mt-1 flex items-center gap-2">
<input
id="edit-color"
type="color"
value={editData.color ?? placement.color}
onChange={(e) => setEditData({ ...editData, color: e.target.value })}
className="h-8 w-16 cursor-pointer rounded border"
/>
<Input
value={editData.color ?? placement.color}
onChange={(e) => setEditData({ ...editData, color: e.target.value })}
className="h-8 flex-1 text-xs"
/>
</div>
</div>
{/* 메모 */}
<div>
<Label htmlFor="edit-memo" className="text-xs">
</Label>
<Textarea
id="edit-memo"
value={editData.memo ?? placement.memo ?? ""}
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
placeholder="메모를 입력하세요"
rows={3}
className="text-xs"
/>
</div>
{/* 적용 버튼 */}
<Button onClick={handleApply} className="w-full" size="sm">
</Button>
</div>
{/* 배치 해제 */}
<div className="border-t pt-4">
<Button variant="destructive" onClick={() => setIsDeleteDialogOpen(true)} className="w-full" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* 삭제 확인 모달 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
&quot;{placement.material_name}&quot; ({placement.quantity} {placement.unit})
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleRemove} className="bg-red-600 hover:bg-red-700">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -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<TempMaterial[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [searchText, setSearchText] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [selectedMaterial, setSelectedMaterial] = useState<TempMaterial | null>(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 (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 검색 및 필터 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="자재 코드 또는 이름 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-9"
/>
</div>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
>
<option value=""> </option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
{/* 자재 목록 */}
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : materials.length === 0 ? (
<div className="flex h-64 items-center justify-center text-gray-500">
{searchText || selectedCategory ? "검색 결과가 없습니다" : "등록된 자재가 없습니다"}
</div>
) : (
<div className="max-h-96 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]"></TableHead>
<TableHead> </TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material) => (
<TableRow
key={material.id}
className={`cursor-pointer ${
selectedMaterial?.id === material.id ? "bg-blue-50" : "hover:bg-gray-50"
}`}
onClick={() => setSelectedMaterial(material)}
>
<TableCell>
<div className="h-6 w-6 rounded border" style={{ backgroundColor: material.default_color }} />
</TableCell>
<TableCell className="font-medium">{material.material_code}</TableCell>
<TableCell>{material.material_name}</TableCell>
<TableCell>{material.category}</TableCell>
<TableCell>{material.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 선택된 자재 정보 */}
{selectedMaterial && (
<div className="rounded-lg bg-blue-50 p-4">
<div className="mb-2 text-sm font-medium text-blue-900"> </div>
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded border" style={{ backgroundColor: selectedMaterial.default_color }} />
<div className="flex-1">
<div className="font-medium">{selectedMaterial.material_name}</div>
<div className="text-sm text-gray-600">{selectedMaterial.material_code}</div>
</div>
</div>
{selectedMaterial.description && (
<div className="mt-2 text-sm text-gray-600">{selectedMaterial.description}</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
</Button>
<Button onClick={handleSelectMaterial} disabled={!selectedMaterial}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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<THREE.Mesh>(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 (
<Box
ref={meshRef}
position={[placement.position_x, placement.position_y, placement.position_z]}
args={[placement.size_x, placement.size_y, placement.size_z]}
onClick={(e) => {
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";
}
}}
>
<meshStandardMaterial
color={placement.color}
opacity={isSelected ? 1 : 0.8}
transparent
emissive={isSelected ? "#ffffff" : "#000000"}
emissiveIntensity={isSelected ? 0.2 : 0}
/>
</Box>
);
}
// 3D 씬 컴포넌트
function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) {
const [isDraggingAny, setIsDraggingAny] = useState(false);
const orbitControlsRef = useRef<any>(null);
return (
<>
{/* 조명 */}
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} />
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
{/* 바닥 그리드 */}
<Grid
args={[100, 100]}
cellSize={5}
cellThickness={0.5}
cellColor="#6b7280"
sectionSize={10}
sectionThickness={1}
sectionColor="#374151"
fadeDistance={200}
fadeStrength={1}
followCamera={false}
infiniteGrid={true}
/>
{/* 자재 박스들 */}
{placements.map((placement) => (
<MaterialBox
key={placement.id}
placement={placement}
isSelected={selectedPlacementId === placement.id}
onClick={() => 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;
}
}}
/>
))}
{/* 카메라 컨트롤 */}
<OrbitControls
ref={orbitControlsRef}
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={10}
maxDistance={200}
maxPolarAngle={Math.PI / 2}
enabled={!isDraggingAny}
/>
</>
);
}
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 (
<div className="h-full w-full bg-gray-900" onClick={handleCanvasClick}>
<Canvas
camera={{
position: [50, 30, 50],
fov: 50,
}}
shadows
>
<Suspense fallback={null}>
<Scene
placements={placements}
selectedPlacementId={selectedPlacementId}
onPlacementClick={onPlacementClick}
onPlacementDrag={onPlacementDrag}
/>
</Suspense>
</Canvas>
</div>
);
}

View File

@ -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<YardPlacement[]>([]);
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
<div className="mt-2 text-sm text-gray-600">3D ...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mb-2 text-4xl"></div>
<div className="text-sm font-medium text-gray-600">{error}</div>
</div>
</div>
);
}
if (placements.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mb-2 text-4xl">📦</div>
<div className="text-sm font-medium text-gray-600"> </div>
</div>
</div>
);
}
return (
<div className="flex h-full w-full">
{/* 3D 캔버스 */}
<div className="flex-1">
<Yard3DCanvas
placements={placements}
selectedPlacementId={selectedPlacement?.id || null}
onPlacementClick={setSelectedPlacement}
/>
</div>
{/* 선택된 자재 정보 패널 (우측) */}
{selectedPlacement && (
<div className="w-80 border-l bg-white p-4">
<div className="mb-4">
<h3 className="text-sm font-semibold text-gray-700"> </h3>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500"> </label>
<div className="mt-1 text-sm font-medium">{selectedPlacement.material_code}</div>
</div>
<div>
<label className="text-xs text-gray-500"></label>
<div className="mt-1 text-sm font-medium">{selectedPlacement.material_name}</div>
</div>
<div>
<label className="text-xs text-gray-500"></label>
<div className="mt-1 text-sm">
{selectedPlacement.quantity} {selectedPlacement.unit}
</div>
</div>
<div>
<label className="text-xs text-gray-500"> (X, Y, Z)</label>
<div className="mt-1 text-sm">
({selectedPlacement.position_x.toFixed(1)}, {selectedPlacement.position_y.toFixed(1)},{" "}
{selectedPlacement.position_z.toFixed(1)})
</div>
</div>
<div>
<label className="text-xs text-gray-500"> (W × H × D)</label>
<div className="mt-1 text-sm">
{selectedPlacement.size_x} × {selectedPlacement.size_y} × {selectedPlacement.size_z}
</div>
</div>
{selectedPlacement.memo && (
<div>
<label className="text-xs text-gray-500"></label>
<div className="mt-1 text-sm text-gray-700">{selectedPlacement.memo}</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -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: () => (
<div className="flex h-full items-center justify-center bg-gray-900">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
),
});
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<YardPlacement[]>([]);
const [materials, setMaterials] = useState<TempMaterial[]>([]);
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
const [selectedMaterial, setSelectedMaterial] = useState<TempMaterial | null>(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<YardPlacement>) => {
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 (
<div className="flex h-full flex-col bg-white">
{/* 상단 툴바 */}
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<div>
<h2 className="text-lg font-semibold">{layout.name}</h2>
{layout.description && <p className="text-sm text-gray-500">{layout.description}</p>}
</div>
</div>
<Button size="sm" onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
{/* 메인 컨텐츠 영역 */}
<div className="flex flex-1 overflow-hidden">
{/* 좌측: 3D 캔버스 */}
<div className="flex-1">
{isLoading ? (
<div className="flex h-full items-center justify-center bg-gray-50">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
) : (
<Yard3DCanvas
placements={placements}
selectedPlacementId={selectedPlacement?.id || null}
onPlacementClick={setSelectedPlacement}
onPlacementDrag={handlePlacementDrag}
/>
)}
</div>
{/* 우측: 자재 목록 또는 편집 패널 */}
<div className="w-80 border-l bg-white">
{selectedPlacement ? (
// 선택된 자재 편집 패널
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b p-4">
<h3 className="text-sm font-semibold"> </h3>
<Button variant="ghost" size="sm" onClick={() => setSelectedPlacement(null)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-auto p-4">
<div className="space-y-4">
{/* 읽기 전용 정보 */}
<div>
<Label className="text-xs text-gray-500"> </Label>
<div className="mt-1 text-sm font-medium">{selectedPlacement.material_code}</div>
</div>
<div>
<Label className="text-xs text-gray-500"></Label>
<div className="mt-1 text-sm font-medium">{selectedPlacement.material_name}</div>
</div>
<div>
<Label className="text-xs text-gray-500"> ( )</Label>
<div className="mt-1 text-sm">
{selectedPlacement.quantity} {selectedPlacement.unit}
</div>
</div>
{/* 편집 가능 정보 */}
<div className="space-y-3 border-t pt-4">
<Label className="text-sm font-semibold"> </Label>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs">X</Label>
<Input
type="number"
step="0.5"
value={selectedPlacement.position_x}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
position_x: parseFloat(e.target.value),
})
}
/>
</div>
<div>
<Label className="text-xs">Y</Label>
<Input
type="number"
step="0.5"
value={selectedPlacement.position_y}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
position_y: parseFloat(e.target.value),
})
}
/>
</div>
<div>
<Label className="text-xs">Z</Label>
<Input
type="number"
step="0.5"
value={selectedPlacement.position_z}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
position_z: parseFloat(e.target.value),
})
}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"></Label>
<Input
type="number"
step="1"
value={selectedPlacement.size_x}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
size_x: parseFloat(e.target.value),
})
}
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
step="1"
value={selectedPlacement.size_y}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
size_y: parseFloat(e.target.value),
})
}
/>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="number"
step="1"
value={selectedPlacement.size_z}
onChange={(e) =>
handlePlacementUpdate(selectedPlacement.id, {
size_z: parseFloat(e.target.value),
})
}
/>
</div>
</div>
<div>
<Label className="text-xs"></Label>
<Input
type="color"
value={selectedPlacement.color}
onChange={(e) => handlePlacementUpdate(selectedPlacement.id, { color: e.target.value })}
/>
</div>
</div>
<Button
variant="destructive"
size="sm"
className="w-full"
onClick={() => handlePlacementRemove(selectedPlacement.id)}
>
</Button>
</div>
</div>
</div>
) : (
// 자재 목록
<div className="flex h-full flex-col">
<div className="border-b p-4">
<h3 className="mb-2 text-sm font-semibold"> </h3>
<Input
placeholder="자재 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="text-sm"
/>
</div>
<div className="flex-1 overflow-auto">
{filteredMaterials.length === 0 ? (
<div className="flex h-full items-center justify-center p-4 text-center text-sm text-gray-500">
</div>
) : (
<div className="p-2">
{filteredMaterials.map((material) => {
const isPlaced = placements.some((p) => p.material_code === material.material_code);
return (
<button
key={material.id}
onClick={() => !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"
}`}
>
<div className="mb-1 text-sm font-medium text-gray-900">{material.material_name}</div>
<div className="text-xs text-gray-500">{material.material_code}</div>
<div className="mt-1 text-xs text-gray-400">{material.category}</div>
{isPlaced && <div className="mt-1 text-xs text-blue-600"></div>}
</button>
);
})}
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -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<void>;
}
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 (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 야드 이름 */}
<div className="space-y-2">
<Label htmlFor="yard-name">
<span className="text-red-500">*</span>
</Label>
<Input
id="yard-name"
value={name}
onChange={(e) => {
setName(e.target.value);
setError("");
}}
onKeyDown={handleKeyDown}
placeholder="예: A구역, 1번 야드"
disabled={isCreating}
autoFocus
/>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="yard-description"></Label>
<Textarea
id="yard-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="야드에 대한 설명을 입력하세요 (선택사항)"
rows={3}
disabled={isCreating}
/>
</div>
{/* 에러 메시지 */}
{error && <div className="rounded-md bg-red-50 p-3 text-sm text-red-600">{error}</div>}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
</Button>
<Button onClick={handleCreate} disabled={!name.trim() || isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"생성"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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<void>;
onDuplicate: (id: number, newName: string) => Promise<void>;
}
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<YardLayout | null>(null);
const [duplicateTarget, setDuplicateTarget] = useState<YardLayout | null>(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 (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
);
}
return (
<div className="flex h-full flex-col space-y-4">
{/* 검색 및 정렬 */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="야드 이름 또는 설명 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-9"
/>
</div>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value as "recent" | "name")}
className="rounded-md border border-gray-300 px-3 py-2 text-sm"
>
<option value="recent"> </option>
<option value="name"></option>
</select>
</div>
{/* 테이블 */}
{sortedLayouts.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<div className="text-center text-gray-500">
{searchText ? "검색 결과가 없습니다" : "등록된 야드가 없습니다"}
</div>
</div>
) : (
<div className="flex-1 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"> </TableHead>
<TableHead> </TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedLayouts.map((layout) => (
<TableRow key={layout.id} className="cursor-pointer hover:bg-gray-50" onClick={() => onSelect(layout)}>
<TableCell className="font-medium">{layout.name}</TableCell>
<TableCell className="text-gray-600">{layout.description || "-"}</TableCell>
<TableCell className="text-center">{layout.placement_count}</TableCell>
<TableCell className="text-sm text-gray-500">{formatDate(layout.updated_at)}</TableCell>
<TableCell className="text-center">
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onSelect(layout)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicateClick(layout)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteTarget(layout)} className="text-red-600">
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 총 개수 */}
<div className="text-sm text-gray-500"> {sortedLayouts.length}</div>
{/* 삭제 확인 모달 */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{deleteTarget?.name}&quot; ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"삭제"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 복제 모달 */}
<Dialog open={!!duplicateTarget} onOpenChange={() => setDuplicateTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="duplicate-name"> </Label>
<Input
id="duplicate-name"
value={duplicateName}
onChange={(e) => setDuplicateName(e.target.value)}
placeholder="야드 이름을 입력하세요"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDuplicateTarget(null)} disabled={isDuplicating}>
</Button>
<Button onClick={handleDuplicateConfirm} disabled={!duplicateName.trim() || isDuplicating}>
{isDuplicating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"복제"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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 <ListWidget element={element} />;
case "warehouse-3d":
return <Warehouse3DWidget element={element} />;
case "yard-management-3d":
return <YardManagement3DWidget isEditMode={false} config={element.yardConfig} />;
// === 차량 관련 (추가 위젯) ===
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<Record<string, QueryResult>>({});
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());

View File

@ -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");
},
};