일단 야드관리 3d 드래그앤 드랍까지
This commit is contained in:
parent
b41da3261c
commit
184d687f0f
|
|
@ -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. 상태 관리 최적화
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 "위젯 내용이 여기에 표시됩니다";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> */}
|
||||
|
|
|
|||
|
|
@ -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; // 레이아웃 이름 (표시용)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
"{placement.material_name}" ({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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
정말로 "{deleteTarget?.name}" 야드를 삭제하시겠습니까?
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue