diff --git a/WORK_HISTORY_SETUP.md b/WORK_HISTORY_SETUP.md new file mode 100644 index 00000000..223b3975 --- /dev/null +++ b/WORK_HISTORY_SETUP.md @@ -0,0 +1,150 @@ +# 작업 이력 관리 시스템 설치 가이드 + +## 📋 개요 + +작업 이력 관리 시스템이 추가되었습니다. 입고/출고/이송/정비 작업을 관리하고 통계를 확인할 수 있습니다. + +## 🚀 설치 방법 + +### 1. 데이터베이스 마이그레이션 실행 + +PostgreSQL 데이터베이스에 작업 이력 테이블을 생성해야 합니다. + +```bash +# 방법 1: psql 명령어 사용 (로컬 PostgreSQL) +psql -U postgres -d plm -f db/migrations/20241020_create_work_history.sql + +# 방법 2: Docker 컨테이너 사용 +docker exec -i psql -U postgres -d plm < db/migrations/20241020_create_work_history.sql + +# 방법 3: pgAdmin 또는 DBeaver 사용 +# db/migrations/20241020_create_work_history.sql 파일을 열어서 실행 +``` + +### 2. 백엔드 재시작 + +```bash +cd backend-node +npm run dev +``` + +### 3. 프론트엔드 확인 + +대시보드 편집 화면에서 다음 위젯들을 추가할 수 있습니다: + +- **작업 이력**: 작업 목록을 테이블 형식으로 표시 +- **운송 통계**: 오늘 작업, 총 운송량, 정시 도착률 등 통계 표시 + +## 📊 주요 기능 + +### 작업 이력 위젯 + +- 작업 번호, 일시, 유형, 차량, 경로, 화물, 중량, 상태 표시 +- 유형별 필터링 (입고/출고/이송/정비) +- 상태별 필터링 (대기/진행중/완료/취소) +- 실시간 자동 새로고침 + +### 운송 통계 위젯 + +- 오늘 작업 건수 및 완료율 +- 총 운송량 (톤) +- 누적 거리 (km) +- 정시 도착률 (%) +- 작업 유형별 분포 차트 + +## 🔧 API 엔드포인트 + +### 작업 이력 관리 + +- `GET /api/work-history` - 작업 이력 목록 조회 +- `GET /api/work-history/:id` - 작업 이력 단건 조회 +- `POST /api/work-history` - 작업 이력 생성 +- `PUT /api/work-history/:id` - 작업 이력 수정 +- `DELETE /api/work-history/:id` - 작업 이력 삭제 + +### 통계 및 분석 + +- `GET /api/work-history/stats` - 작업 이력 통계 +- `GET /api/work-history/trend?months=6` - 월별 추이 +- `GET /api/work-history/routes?limit=5` - 주요 운송 경로 + +## 📝 샘플 데이터 + +마이그레이션 실행 시 자동으로 4건의 샘플 데이터가 생성됩니다: + +1. 입고 작업 (완료) +2. 출고 작업 (진행중) +3. 이송 작업 (대기) +4. 정비 작업 (완료) + +## 🎯 사용 방법 + +### 1. 대시보드에 위젯 추가 + +1. 대시보드 편집 모드로 이동 +2. 상단 메뉴에서 "위젯 추가" 선택 +3. "작업 이력" 또는 "운송 통계" 선택 +4. 원하는 위치에 배치 +5. 저장 + +### 2. 작업 이력 필터링 + +- 유형 선택: 전체/입고/출고/이송/정비 +- 상태 선택: 전체/대기/진행중/완료/취소 +- 새로고침 버튼으로 수동 갱신 + +### 3. 통계 확인 + +운송 통계 위젯에서 다음 정보를 확인할 수 있습니다: + +- 오늘 작업 건수 +- 완료율 +- 총 운송량 +- 정시 도착률 +- 작업 유형별 분포 + +## 🔍 문제 해결 + +### 데이터가 표시되지 않는 경우 + +1. 데이터베이스 마이그레이션이 실행되었는지 확인 +2. 백엔드 서버가 실행 중인지 확인 +3. 브라우저 콘솔에서 API 에러 확인 + +### API 에러가 발생하는 경우 + +```bash +# 백엔드 로그 확인 +cd backend-node +npm run dev +``` + +### 위젯이 표시되지 않는 경우 + +1. 프론트엔드 재시작 +2. 브라우저 캐시 삭제 +3. 페이지 새로고침 + +## 📚 관련 파일 + +### 백엔드 + +- `backend-node/src/types/workHistory.ts` - 타입 정의 +- `backend-node/src/services/workHistoryService.ts` - 비즈니스 로직 +- `backend-node/src/controllers/workHistoryController.ts` - API 컨트롤러 +- `backend-node/src/routes/workHistoryRoutes.ts` - 라우트 정의 + +### 프론트엔드 + +- `frontend/types/workHistory.ts` - 타입 정의 +- `frontend/components/dashboard/widgets/WorkHistoryWidget.tsx` - 작업 이력 위젯 +- `frontend/components/dashboard/widgets/TransportStatsWidget.tsx` - 운송 통계 위젯 + +### 데이터베이스 + +- `db/migrations/20241020_create_work_history.sql` - 테이블 생성 스크립트 + +## 🎉 완료! + +작업 이력 관리 시스템이 성공적으로 설치되었습니다! + diff --git a/YARD_MANAGEMENT_3D_DATA_BINDING_REDESIGN.md b/YARD_MANAGEMENT_3D_DATA_BINDING_REDESIGN.md new file mode 100644 index 00000000..0b131635 --- /dev/null +++ b/YARD_MANAGEMENT_3D_DATA_BINDING_REDESIGN.md @@ -0,0 +1,426 @@ +# 야드 관리 3D - 데이터 바인딩 시스템 재설계 + +## 1. 개요 + +### 현재 방식의 문제점 + +- 고정된 임시 자재 마스터(`temp_material_master`) 테이블에 의존 +- 실제 외부 시스템의 자재 데이터와 연동 불가 +- 자재 목록이 제한적이고 유연성 부족 +- 사용자가 직접 데이터를 선택하거나 입력할 수 없음 + +### 새로운 방식의 목표 + +- 차트/리스트 위젯과 동일한 데이터 소스 선택 방식 적용 +- DB 커넥션 또는 REST API를 통해 실제 자재 데이터 연동 +- 사용자가 자재명, 수량 등을 직접 매핑 및 입력 가능 +- 설정되지 않은 요소는 뷰어에서 명확히 표시 + +--- + +## 2. 핵심 변경사항 + +### 2.1 요소(Element) 개념 도입 + +- 기존: 자재 목록에서 클릭 → 즉시 배치 +- 변경: [+ 요소 추가] 버튼 클릭 → 3D 캔버스에 즉시 빈 요소 배치 → 우측 패널이 데이터 바인딩 설정 화면으로 전환 + +### 2.2 데이터 소스 선택 + +- 현재 DB (내부 PostgreSQL) +- 외부 DB 커넥션 +- REST API + +### 2.3 데이터 매핑 + +- 자재명 필드 선택 (데이터 소스에서) +- 수량 필드 선택 (데이터 소스에서) +- 단위 직접 입력 (예: EA, BOX, KG 등) +- 색상 선택 + +--- + +## 3. 데이터베이스 스키마 변경 + +### 3.1 기존 테이블 수정: `yard_material_placement` + +```sql +-- 기존 컬럼 변경 +ALTER TABLE yard_material_placement + -- 기존 컬럼 제거 (외부 자재 ID 관련) + DROP COLUMN IF EXISTS external_material_id, + + -- 데이터 소스 정보 추가 + ADD COLUMN data_source_type VARCHAR(20), -- 'database', 'external_db', 'rest_api' + ADD COLUMN data_source_config JSONB, -- 데이터 소스 설정 + + -- 데이터 바인딩 정보 추가 + ADD COLUMN data_binding JSONB, -- 필드 매핑 정보 + + -- 자재 정보를 NULL 허용으로 변경 (설정 전에는 NULL) + ALTER COLUMN material_code DROP NOT NULL, + ALTER COLUMN material_name DROP NOT NULL, + ALTER COLUMN quantity DROP NOT NULL; +``` + +### 3.2 data_source_config 구조 + +```typescript +interface DataSourceConfig { + type: "database" | "external_db" | "rest_api"; + + // type === 'database' (현재 DB) + query?: string; + + // type === 'external_db' (외부 DB) + connectionId?: number; + query?: string; + + // type === 'rest_api' + url?: string; + method?: "GET" | "POST"; + headers?: Record; + queryParams?: Record; + body?: string; + dataPath?: string; // 응답에서 데이터 배열 경로 (예: "data.items") +} +``` + +### 3.3 data_binding 구조 + +```typescript +interface DataBinding { + // 데이터 소스의 특정 행 선택 + selectedRowIndex?: number; + + // 필드 매핑 (데이터 소스에서 선택) + materialNameField?: string; // 자재명이 들어있는 컬럼명 + quantityField?: string; // 수량이 들어있는 컬럼명 + + // 단위는 사용자가 직접 입력 + unit: string; // 예: "EA", "BOX", "KG", "M" 등 +} +``` + +--- + +## 4. UI/UX 설계 + +### 4.1 편집 모드 (YardEditor) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [← 목록으로] 야드명: A구역 [저장] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────┐ ┌──────────────────────────┐│ +│ │ │ │ ││ +│ │ │ │ [+ 요소 추가] ││ +│ │ │ │ ││ +│ │ 3D 캔버스 │ │ ┌────────────────────┐ ││ +│ │ │ │ │ □ 요소 1 │ ││ +│ │ │ │ │ 자재: 철판 A │ ││ +│ │ │ │ │ 수량: 50 EA │ ││ +│ │ │ │ │ [편집] [삭제] │ ││ +│ │ │ │ └────────────────────┘ ││ +│ │ │ │ ││ +│ │ │ │ ┌────────────────────┐ ││ +│ │ │ │ │ □ 요소 2 (미설정) │ ││ +│ │ │ │ │ 데이터 바인딩 │ ││ +│ │ │ │ │ 설정 필요 │ ││ +│ │ │ │ │ [설정] [삭제] │ ││ +│ │ │ │ └────────────────────┘ ││ +│ │ │ │ ││ +│ └───────────────────────────┘ └──────────────────────────┘│ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 4.1.1 요소 목록 (우측 패널) + +- **[+ 요소 추가]** 버튼: 새 요소 생성 +- **요소 카드**: + - 설정 완료: 자재명, 수량 표시 + [편집] [삭제] 버튼 + - 미설정: "데이터 바인딩 설정 필요" + [설정] [삭제] 버튼 + +#### 4.1.2 요소 추가 흐름 + +``` +1. [+ 요소 추가] 클릭 + ↓ +2. 3D 캔버스의 기본 위치(0,0,0)에 회색 반투명 박스로 빈 요소 즉시 배치 + ↓ +3. 요소가 자동 선택됨 + ↓ +4. 우측 패널이 "데이터 바인딩 설정" 화면으로 자동 전환 + (요소 목록에서 [설정] 버튼을 클릭해도 동일한 화면) +``` + +### 4.2 데이터 바인딩 설정 패널 (우측) + +**[+ 요소 추가] 버튼 클릭 시 또는 [설정] 버튼 클릭 시 우측 패널이 아래와 같이 변경됩니다:** + +``` +┌──────────────────────────────────────┐ +│ 데이터 바인딩 설정 [← 목록]│ +├──────────────────────────────────────┤ +│ │ +│ ┌─ 1단계: 데이터 소스 선택 ─────────────────────────┐ │ +│ │ │ │ +│ │ ○ 현재 DB ○ 외부 DB ○ REST API │ │ +│ │ │ │ +│ │ [현재 DB 선택 시] │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ +│ │ │ SELECT material_name, quantity, unit │ │ │ +│ │ │ FROM inventory │ │ │ +│ │ │ WHERE status = 'available' │ │ │ +│ │ └────────────────────────────────────────────┘ │ │ +│ │ [실행] 버튼 │ │ +│ │ │ │ +│ │ [외부 DB 선택 시] │ │ +│ │ - 외부 커넥션 선택 드롭다운 │ │ +│ │ - SQL 쿼리 입력 │ │ +│ │ - [실행] 버튼 │ │ +│ │ │ │ +│ │ [REST API 선택 시] │ │ +│ │ - URL 입력 │ │ +│ │ - Method 선택 (GET/POST) │ │ +│ │ - Headers, Query Params 설정 │ │ +│ │ - [실행] 버튼 │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 2단계: 쿼리 결과 및 필드 매핑 ──────────────────────┐ │ +│ │ │ │ +│ │ 쿼리 결과 (5행): │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ +│ │ │ material_name │ quantity │ status │ │ │ +│ │ │ 철판 A │ 50 │ available │ ○ │ │ +│ │ │ 강관 파이프 │ 100 │ available │ ○ │ │ +│ │ │ 볼트 세트 │ 500 │ in_stock │ ○ │ │ +│ │ └────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ 필드 매핑: │ │ +│ │ 자재명: [material_name ▼] │ │ +│ │ 수량: [quantity ▼] │ │ +│ │ │ │ +│ │ 단위 입력: │ │ +│ │ 단위: [EA_____________] │ │ +│ │ (예: EA, BOX, KG, M, L 등) │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ 3단계: 배치 설정 ──────────────────────────────────┐ │ +│ │ │ │ +│ │ 색상: [🎨 #3b82f6] │ │ +│ │ │ │ +│ │ 크기: │ │ +│ │ 너비: [5] 높이: [5] 깊이: [5] │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ [← 목록으로] [저장] │ +└──────────────────────────────────────┘ +``` + +**참고:** + +- [← 목록으로] 버튼: 요소 목록 화면으로 돌아갑니다 +- [저장] 버튼: 데이터 바인딩 설정을 저장하고 요소 목록 화면으로 돌아갑니다 +- 저장하지 않고 나가면 요소는 "미설정" 상태로 남습니다 + +### 4.3 뷰어 모드 (Yard3DViewer) + +#### 4.3.1 설정된 요소 + +- 정상적으로 3D 박스 렌더링 +- 클릭 시 자재명, 수량 정보 표시 + +#### 4.3.2 미설정 요소 + +``` +┌─────────────────────┐ +│ │ +│ ⚠️ │ +│ │ +│ 설정되지 않은 │ +│ 요소입니다 │ +│ │ +└─────────────────────┘ +``` + +- 반투명 회색 박스로 표시 +- 클릭 시 "데이터 바인딩이 설정되지 않았습니다" 메시지 + +--- + +## 5. 구현 단계 + +### Phase 1: 데이터베이스 스키마 변경 + +- [ ] `yard_material_placement` 테이블 수정 +- [ ] 마이그레이션 스크립트 작성 +- [ ] 기존 데이터 호환성 처리 + +### Phase 2: 백엔드 API 수정 + +- [ ] `YardLayoutService.ts` 수정 + - `addMaterialPlacement`: 데이터 소스/바인딩 정보 저장 + - `updatePlacement`: 데이터 바인딩 업데이트 + - `getPlacementsByLayoutId`: 새 필드 포함하여 조회 +- [ ] 데이터 소스 실행 로직 추가 + - DB 쿼리 실행 + - 외부 DB 쿼리 실행 + - REST API 호출 + +### Phase 3: 프론트엔드 타입 정의 + +- [ ] `types.ts`에 새로운 인터페이스 추가 + - `YardElementDataSource` + - `YardElementDataBinding` + - `YardPlacement` 업데이트 + +### Phase 4: 요소 추가 및 관리 + +- [ ] `YardEditor.tsx` 수정 + - [+ 요소 추가] 버튼 구현 + - 빈 요소 생성 로직 (즉시 3D 캔버스에 배치) + - 요소 추가 시 자동으로 해당 요소 선택 + - 우측 패널 상태 관리 (요소 목록 ↔ 데이터 바인딩 설정) + - 요소 목록 UI + - 설정/미설정 상태 구분 표시 + +### Phase 5: 데이터 바인딩 패널 + +- [ ] `YardElementConfigPanel.tsx` 생성 (우측 패널 컴포넌트) + - [← 목록으로] 버튼으로 요소 목록으로 복귀 + - 1단계: 데이터 소스 선택 (DatabaseConfig, ExternalDbConfig, RestApiConfig 재사용) + - 2단계: 쿼리 결과 테이블 + 행 선택 + 필드 매핑 + - 자재명 필드 선택 (드롭다운) + - 수량 필드 선택 (드롭다운) + - 단위 직접 입력 (Input) + - 3단계: 배치 설정 (색상, 크기) + - [저장] 버튼으로 설정 저장 및 목록으로 복귀 + +### Phase 6: 3D 캔버스 렌더링 수정 + +- [ ] `Yard3DCanvas.tsx` 수정 + - 설정된 요소: 기존 렌더링 + - 미설정 요소: 회색 반투명 박스 + 경고 아이콘 + +### Phase 7: 뷰어 모드 수정 + +- [ ] `Yard3DViewer.tsx` 수정 + - 미설정 요소 감지 + - 미설정 요소 클릭 시 안내 메시지 + +### Phase 8: 임시 테이블 제거 + +- [ ] `temp_material_master` 테이블 삭제 +- [ ] 관련 API 및 UI 코드 정리 + +--- + +## 6. 데이터 구조 예시 + +### 6.1 데이터 소스 + 필드 매핑 사용 + +```json +{ + "id": 1, + "yard_layout_id": 1, + "material_code": null, + "material_name": "철판 A타입", + "quantity": 50, + "unit": "EA", + "data_source_type": "database", + "data_source_config": { + "type": "database", + "query": "SELECT material_name, quantity FROM inventory WHERE material_id = 'MAT-001'" + }, + "data_binding": { + "selectedRowIndex": 0, + "materialNameField": "material_name", + "quantityField": "quantity", + "unit": "EA" + }, + "position_x": 10, + "position_y": 0, + "position_z": 10, + "size_x": 5, + "size_y": 5, + "size_z": 5, + "color": "#ef4444" +} +``` + +### 6.2 미설정 요소 + +```json +{ + "id": 3, + "yard_layout_id": 1, + "material_code": null, + "material_name": null, + "quantity": null, + "unit": null, + "data_source_type": null, + "data_source_config": null, + "data_binding": null, + "position_x": 30, + "position_y": 0, + "position_z": 30, + "size_x": 5, + "size_y": 5, + "size_z": 5, + "color": "#9ca3af" +} +``` + +--- + +## 7. 장점 + +1. **유연성**: 다양한 데이터 소스 지원 (내부 DB, 외부 DB, REST API) +2. **실시간성**: 실제 시스템의 자재 데이터와 연동 가능 +3. **일관성**: 차트/리스트 위젯과 동일한 데이터 소스 선택 방식 +4. **사용자 경험**: 데이터 매핑 방식 선택 가능 (자동/수동) +5. **확장성**: 새로운 데이터 소스 타입 추가 용이 +6. **명확성**: 미설정 요소를 시각적으로 구분 + +--- + +## 8. 마이그레이션 전략 + +### 8.1 기존 데이터 처리 + +- 기존 `temp_material_master` 기반 배치 데이터를 수동 입력 모드로 전환 +- `external_material_id` → `data_binding.mode = 'manual'`로 변환 + +### 8.2 단계적 전환 + +1. 새 스키마 적용 (기존 컬럼 유지) +2. 새 UI/로직 구현 및 테스트 +3. 기존 데이터 마이그레이션 +4. 임시 테이블 및 구 코드 제거 + +--- + +## 9. 기술 스택 + +- **백엔드**: PostgreSQL JSONB, Node.js/TypeScript +- **프론트엔드**: React, TypeScript, Shadcn UI +- **3D 렌더링**: React Three Fiber, Three.js +- **데이터 소스**: 기존 `DatabaseConfig`, `ExternalDbConfig`, `RestApiConfig` 컴포넌트 재사용 + +--- + +## 10. 예상 개발 기간 + +- Phase 1-2 (DB/백엔드): 1일 +- Phase 3-4 (프론트엔드 구조): 1일 +- Phase 5 (데이터 바인딩 모달): 2일 +- Phase 6-7 (3D 렌더링/뷰어): 1일 +- Phase 8 (정리 및 테스트): 0.5일 + +**총 예상 기간: 약 5.5일** diff --git a/backend-node/data/todos/todos.json b/backend-node/data/todos/todos.json index 0637a088..653d5636 100644 --- a/backend-node/data/todos/todos.json +++ b/backend-node/data/todos/todos.json @@ -1 +1,54 @@ -[] \ No newline at end of file +[ + { + "id": "e5bb334c-d58a-4068-ad77-2607a41f4675", + "title": "ㅁㄴㅇㄹ", + "description": "ㅁㄴㅇㄹ", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "2025-10-20T18:17", + "createdAt": "2025-10-20T06:15:49.610Z", + "updatedAt": "2025-10-20T06:15:49.610Z", + "isUrgent": false, + "order": 0 + }, + { + "id": "334be17c-7776-47e8-89ec-4b57c4a34bcd", + "title": "연동되어주겠니?", + "description": "", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "", + "createdAt": "2025-10-20T06:20:06.343Z", + "updatedAt": "2025-10-20T06:20:06.343Z", + "isUrgent": false, + "order": 1 + }, + { + "id": "f85b81de-fcbd-4858-8973-247d9d6e70ed", + "title": "연동되어주겠니?11", + "description": "ㄴㅇㄹ", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "2025-10-20T17:22", + "createdAt": "2025-10-20T06:20:53.818Z", + "updatedAt": "2025-10-20T06:20:53.818Z", + "isUrgent": false, + "order": 2 + }, + { + "id": "58d2b26f-5197-4df1-b5d4-724a72ee1d05", + "title": "연동되어주려무니", + "description": "ㅁㄴㅇㄹ", + "priority": "normal", + "status": "pending", + "assignedTo": "", + "dueDate": "2025-10-21T15:21", + "createdAt": "2025-10-20T06:21:19.817Z", + "updatedAt": "2025-10-20T06:21:19.817Z", + "isUrgent": false, + "order": 3 + } +] \ No newline at end of file diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 37965d00..caa010b4 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -56,7 +56,7 @@ 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 workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -207,7 +207,7 @@ 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/work-history", workHistoryRoutes); // 작업 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/MaterialController.ts b/backend-node/src/controllers/MaterialController.ts deleted file mode 100644 index bcac72d4..00000000 --- a/backend-node/src/controllers/MaterialController.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Request, Response } from "express"; -import MaterialService from "../services/MaterialService"; - -export class MaterialController { - // 임시 자재 마스터 목록 조회 - async getTempMaterials(req: Request, res: Response) { - try { - const { search, category, page, limit } = req.query; - - const result = await MaterialService.getTempMaterials({ - search: search as string, - category: category as string, - page: page ? parseInt(page as string) : 1, - limit: limit ? parseInt(limit as string) : 20, - }); - - return res.json({ success: true, ...result }); - } catch (error: any) { - console.error("Error fetching temp materials:", error); - return res.status(500).json({ - success: false, - message: "자재 목록 조회 중 오류가 발생했습니다.", - error: error.message, - }); - } - } - - // 특정 자재 상세 조회 - async getTempMaterialByCode(req: Request, res: Response) { - try { - const { code } = req.params; - const material = await MaterialService.getTempMaterialByCode(code); - - if (!material) { - return res.status(404).json({ - success: false, - message: "자재를 찾을 수 없습니다.", - }); - } - - return res.json({ success: true, data: material }); - } catch (error: any) { - console.error("Error fetching temp material:", error); - return res.status(500).json({ - success: false, - message: "자재 조회 중 오류가 발생했습니다.", - error: error.message, - }); - } - } - - // 카테고리 목록 조회 - async getCategories(req: Request, res: Response) { - try { - const categories = await MaterialService.getCategories(); - return res.json({ success: true, data: categories }); - } catch (error: any) { - console.error("Error fetching categories:", error); - return res.status(500).json({ - success: false, - message: "카테고리 목록 조회 중 오류가 발생했습니다.", - error: error.message, - }); - } - } -} - -export default new MaterialController(); diff --git a/backend-node/src/controllers/YardLayoutController.ts b/backend-node/src/controllers/YardLayoutController.ts index 652f74a2..c6a58759 100644 --- a/backend-node/src/controllers/YardLayoutController.ts +++ b/backend-node/src/controllers/YardLayoutController.ts @@ -146,18 +146,14 @@ export class YardLayoutController { } } - // 야드에 자재 배치 추가 + // 야드에 자재 배치 추가 (빈 요소 또는 설정된 요소) 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: "자재 정보가 필요합니다.", - }); - } + // 데이터 바인딩 재설계 후 material_code와 external_material_id는 선택사항 + // 빈 요소를 추가할 수 있어야 함 const placement = await YardLayoutService.addMaterialPlacement( parseInt(id), diff --git a/backend-node/src/controllers/workHistoryController.ts b/backend-node/src/controllers/workHistoryController.ts new file mode 100644 index 00000000..8648a385 --- /dev/null +++ b/backend-node/src/controllers/workHistoryController.ts @@ -0,0 +1,199 @@ +/** + * 작업 이력 관리 컨트롤러 + */ + +import { Request, Response } from 'express'; +import * as workHistoryService from '../services/workHistoryService'; +import { CreateWorkHistoryDto, UpdateWorkHistoryDto, WorkHistoryFilters } from '../types/workHistory'; + +/** + * 작업 이력 목록 조회 + */ +export async function getWorkHistories(req: Request, res: Response): Promise { + try { + const filters: WorkHistoryFilters = { + work_type: req.query.work_type as any, + status: req.query.status as any, + vehicle_number: req.query.vehicle_number as string, + driver_name: req.query.driver_name as string, + start_date: req.query.start_date ? new Date(req.query.start_date as string) : undefined, + end_date: req.query.end_date ? new Date(req.query.end_date as string) : undefined, + search: req.query.search as string, + }; + + const histories = await workHistoryService.getWorkHistories(filters); + res.json({ + success: true, + data: histories, + }); + } catch (error) { + console.error('작업 이력 목록 조회 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 목록 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 단건 조회 + */ +export async function getWorkHistoryById(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + const history = await workHistoryService.getWorkHistoryById(id); + + if (!history) { + res.status(404).json({ + success: false, + message: '작업 이력을 찾을 수 없습니다', + }); + return; + } + + res.json({ + success: true, + data: history, + }); + } catch (error) { + console.error('작업 이력 조회 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 생성 + */ +export async function createWorkHistory(req: Request, res: Response): Promise { + try { + const data: CreateWorkHistoryDto = req.body; + const history = await workHistoryService.createWorkHistory(data); + + res.status(201).json({ + success: true, + data: history, + message: '작업 이력이 생성되었습니다', + }); + } catch (error) { + console.error('작업 이력 생성 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 생성에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 수정 + */ +export async function updateWorkHistory(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + const data: UpdateWorkHistoryDto = req.body; + const history = await workHistoryService.updateWorkHistory(id, data); + + res.json({ + success: true, + data: history, + message: '작업 이력이 수정되었습니다', + }); + } catch (error) { + console.error('작업 이력 수정 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 수정에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 삭제 + */ +export async function deleteWorkHistory(req: Request, res: Response): Promise { + try { + const id = parseInt(req.params.id); + await workHistoryService.deleteWorkHistory(id); + + res.json({ + success: true, + message: '작업 이력이 삭제되었습니다', + }); + } catch (error) { + console.error('작업 이력 삭제 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 삭제에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 작업 이력 통계 조회 + */ +export async function getWorkHistoryStats(req: Request, res: Response): Promise { + try { + const stats = await workHistoryService.getWorkHistoryStats(); + res.json({ + success: true, + data: stats, + }); + } catch (error) { + console.error('작업 이력 통계 조회 실패:', error); + res.status(500).json({ + success: false, + message: '작업 이력 통계 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 월별 추이 조회 + */ +export async function getMonthlyTrend(req: Request, res: Response): Promise { + try { + const months = parseInt(req.query.months as string) || 6; + const trend = await workHistoryService.getMonthlyTrend(months); + res.json({ + success: true, + data: trend, + }); + } catch (error) { + console.error('월별 추이 조회 실패:', error); + res.status(500).json({ + success: false, + message: '월별 추이 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * 주요 운송 경로 조회 + */ +export async function getTopRoutes(req: Request, res: Response): Promise { + try { + const limit = parseInt(req.query.limit as string) || 5; + const routes = await workHistoryService.getTopRoutes(limit); + res.json({ + success: true, + data: routes, + }); + } catch (error) { + console.error('주요 운송 경로 조회 실패:', error); + res.status(500).json({ + success: false, + message: '주요 운송 경로 조회에 실패했습니다', + error: error instanceof Error ? error.message : String(error), + }); + } +} + diff --git a/backend-node/src/routes/materialRoutes.ts b/backend-node/src/routes/materialRoutes.ts deleted file mode 100644 index a85e10f6..00000000 --- a/backend-node/src/routes/materialRoutes.ts +++ /dev/null @@ -1,15 +0,0 @@ -import express from "express"; -import MaterialController from "../controllers/MaterialController"; -import { authenticateToken } from "../middleware/authMiddleware"; - -const router = express.Router(); - -// 모든 라우트에 인증 미들웨어 적용 -router.use(authenticateToken); - -// 임시 자재 마스터 관리 -router.get("/temp", MaterialController.getTempMaterials); -router.get("/temp/categories", MaterialController.getCategories); -router.get("/temp/:code", MaterialController.getTempMaterialByCode); - -export default router; diff --git a/backend-node/src/routes/workHistoryRoutes.ts b/backend-node/src/routes/workHistoryRoutes.ts new file mode 100644 index 00000000..330d08db --- /dev/null +++ b/backend-node/src/routes/workHistoryRoutes.ts @@ -0,0 +1,35 @@ +/** + * 작업 이력 관리 라우트 + */ + +import express from 'express'; +import * as workHistoryController from '../controllers/workHistoryController'; + +const router = express.Router(); + +// 작업 이력 목록 조회 +router.get('/', workHistoryController.getWorkHistories); + +// 작업 이력 통계 조회 +router.get('/stats', workHistoryController.getWorkHistoryStats); + +// 월별 추이 조회 +router.get('/trend', workHistoryController.getMonthlyTrend); + +// 주요 운송 경로 조회 +router.get('/routes', workHistoryController.getTopRoutes); + +// 작업 이력 단건 조회 +router.get('/:id', workHistoryController.getWorkHistoryById); + +// 작업 이력 생성 +router.post('/', workHistoryController.createWorkHistory); + +// 작업 이력 수정 +router.put('/:id', workHistoryController.updateWorkHistory); + +// 작업 이력 삭제 +router.delete('/:id', workHistoryController.deleteWorkHistory); + +export default router; + diff --git a/backend-node/src/services/MaterialService.ts b/backend-node/src/services/MaterialService.ts deleted file mode 100644 index 0f316cdc..00000000 --- a/backend-node/src/services/MaterialService.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { getPool } from "../database/db"; - -export class MaterialService { - // 임시 자재 마스터 목록 조회 - async getTempMaterials(params: { - search?: string; - category?: string; - page?: number; - limit?: number; - }) { - const { search, category, page = 1, limit = 20 } = params; - const offset = (page - 1) * limit; - - let whereConditions: string[] = ["is_active = true"]; - const queryParams: any[] = []; - let paramIndex = 1; - - if (search) { - whereConditions.push( - `(material_code ILIKE $${paramIndex} OR material_name ILIKE $${paramIndex})` - ); - queryParams.push(`%${search}%`); - paramIndex++; - } - - if (category) { - whereConditions.push(`category = $${paramIndex}`); - queryParams.push(category); - paramIndex++; - } - - const whereClause = - whereConditions.length > 0 - ? `WHERE ${whereConditions.join(" AND ")}` - : ""; - - const pool = getPool(); - - // 전체 개수 조회 - const countQuery = `SELECT COUNT(*) as total FROM temp_material_master ${whereClause}`; - const countResult = await pool.query(countQuery, queryParams); - const total = parseInt(countResult.rows[0].total); - - // 데이터 조회 - const dataQuery = ` - SELECT - id, - material_code, - material_name, - category, - unit, - default_color, - description, - created_at - FROM temp_material_master - ${whereClause} - ORDER BY material_code ASC - LIMIT $${paramIndex} OFFSET $${paramIndex + 1} - `; - - queryParams.push(limit, offset); - const dataResult = await pool.query(dataQuery, queryParams); - - return { - data: dataResult.rows, - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit), - }, - }; - } - - // 특정 자재 상세 조회 - async getTempMaterialByCode(materialCode: string) { - const query = ` - SELECT - id, - material_code, - material_name, - category, - unit, - default_color, - description, - created_at - FROM temp_material_master - WHERE material_code = $1 AND is_active = true - `; - - const pool = getPool(); - const result = await pool.query(query, [materialCode]); - return result.rows[0] || null; - } - - // 카테고리 목록 조회 - async getCategories() { - const query = ` - SELECT DISTINCT category - FROM temp_material_master - WHERE is_active = true AND category IS NOT NULL - ORDER BY category ASC - `; - - const pool = getPool(); - const result = await pool.query(query); - return result.rows.map((row) => row.category); - } -} - -export default new MaterialService(); diff --git a/backend-node/src/services/YardLayoutService.ts b/backend-node/src/services/YardLayoutService.ts index 6d077915..609e3d1c 100644 --- a/backend-node/src/services/YardLayoutService.ts +++ b/backend-node/src/services/YardLayoutService.ts @@ -101,7 +101,6 @@ export class YardLayoutService { SELECT id, yard_layout_id, - external_material_id, material_code, material_name, quantity, @@ -113,6 +112,9 @@ export class YardLayoutService { size_y, size_z, color, + data_source_type, + data_source_config, + data_binding, memo, created_at, updated_at @@ -126,12 +128,11 @@ export class YardLayoutService { 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, @@ -143,60 +144,114 @@ export class YardLayoutService { size_y, size_z, color, + data_source_type, + data_source_config, + data_binding, memo - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING * `; const pool = getPool(); + + // NaN 방지를 위한 안전한 변환 함수 + const safeParseInt = ( + value: any, + defaultValue: number | null = null + ): number | null => { + if (!value && value !== 0) return defaultValue; + const parsed = parseInt(String(value), 10); + return isNaN(parsed) ? defaultValue : parsed; + }; + + const safeParseFloat = (value: any, defaultValue: number): number => { + if (!value && value !== 0) return defaultValue; + const parsed = parseFloat(String(value)); + return isNaN(parsed) ? defaultValue : parsed; + }; + 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.material_code || null, + data.material_name || null, + safeParseInt(data.quantity, null), + data.unit || null, + safeParseFloat(data.position_x, 0), + safeParseFloat(data.position_y, 0), + safeParseFloat(data.position_z, 0), + safeParseFloat(data.size_x, 5), + safeParseFloat(data.size_y, 5), + safeParseFloat(data.size_z, 5), + data.color || "#9ca3af", // 미설정 시 회색 + data.data_source_type || null, + data.data_source_config ? JSON.stringify(data.data_source_config) : null, + data.data_binding ? JSON.stringify(data.data_binding) : null, 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), + material_code = COALESCE($1, material_code), + material_name = COALESCE($2, material_name), + quantity = COALESCE($3, quantity), + unit = COALESCE($4, unit), + position_x = COALESCE($5, position_x), + position_y = COALESCE($6, position_y), + position_z = COALESCE($7, position_z), + size_x = COALESCE($8, size_x), + size_y = COALESCE($9, size_y), + size_z = COALESCE($10, size_z), + color = COALESCE($11, color), + data_source_type = COALESCE($12, data_source_type), + data_source_config = COALESCE($13, data_source_config), + data_binding = COALESCE($14, data_binding), + memo = COALESCE($15, memo), updated_at = CURRENT_TIMESTAMP - WHERE id = $9 + WHERE id = $16 RETURNING * `; const pool = getPool(); + + // NaN 방지를 위한 안전한 변환 함수 + const safeParseInt = (value: any): number | null => { + if (value === null || value === undefined) return null; + const parsed = parseInt(String(value), 10); + return isNaN(parsed) ? null : parsed; + }; + + const safeParseFloat = (value: any): number | null => { + if (value === null || value === undefined) return null; + const parsed = parseFloat(String(value)); + return isNaN(parsed) ? null : parsed; + }; + 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, + data.material_code !== undefined ? data.material_code : null, + data.material_name !== undefined ? data.material_name : null, + data.quantity !== undefined ? safeParseInt(data.quantity) : null, + data.unit !== undefined ? data.unit : null, + data.position_x !== undefined ? safeParseFloat(data.position_x) : null, + data.position_y !== undefined ? safeParseFloat(data.position_y) : null, + data.position_z !== undefined ? safeParseFloat(data.position_z) : null, + data.size_x !== undefined ? safeParseFloat(data.size_x) : null, + data.size_y !== undefined ? safeParseFloat(data.size_y) : null, + data.size_z !== undefined ? safeParseFloat(data.size_z) : null, + data.color !== undefined ? data.color : null, + data.data_source_type !== undefined ? data.data_source_type : null, + data.data_source_config !== undefined + ? JSON.stringify(data.data_source_config) + : null, + data.data_binding !== undefined + ? JSON.stringify(data.data_binding) + : null, + data.memo !== undefined ? data.memo : null, placementId, ]); @@ -230,8 +285,9 @@ export class YardLayoutService { size_x = $4, size_y = $5, size_z = $6, + color = $7, updated_at = CURRENT_TIMESTAMP - WHERE id = $7 AND yard_layout_id = $8 + WHERE id = $8 AND yard_layout_id = $9 RETURNING * `; @@ -242,6 +298,7 @@ export class YardLayoutService { placement.size_x, placement.size_y, placement.size_z, + placement.color, placement.id, layoutId, ]); @@ -299,14 +356,14 @@ export class YardLayoutService { await client.query( ` INSERT INTO yard_material_placement ( - yard_layout_id, external_material_id, material_code, material_name, + yard_layout_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) + size_x, size_y, size_z, color, + data_source_type, data_source_config, data_binding, memo + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) `, [ newLayout.id, - placement.external_material_id, placement.material_code, placement.material_name, placement.quantity, @@ -318,6 +375,9 @@ export class YardLayoutService { placement.size_y, placement.size_z, placement.color, + placement.data_source_type, + placement.data_source_config, + placement.data_binding, placement.memo, ] ); diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index 661ffae1..3de082d7 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -53,6 +53,8 @@ const ALLOWED_TABLES = [ "table_labels", "column_labels", "dynamic_form_data", + "work_history", // 작업 이력 테이블 + "delivery_status", // 배송 현황 테이블 ]; /** diff --git a/backend-node/src/services/workHistoryService.ts b/backend-node/src/services/workHistoryService.ts new file mode 100644 index 00000000..5ccceba9 --- /dev/null +++ b/backend-node/src/services/workHistoryService.ts @@ -0,0 +1,335 @@ +/** + * 작업 이력 관리 서비스 + */ + +import pool from '../database/db'; +import { + WorkHistory, + CreateWorkHistoryDto, + UpdateWorkHistoryDto, + WorkHistoryFilters, + WorkHistoryStats, + MonthlyTrend, + TopRoute, +} from '../types/workHistory'; + +/** + * 작업 이력 목록 조회 + */ +export async function getWorkHistories(filters?: WorkHistoryFilters): Promise { + try { + let query = ` + SELECT * FROM work_history + WHERE deleted_at IS NULL + `; + const params: (string | Date)[] = []; + let paramIndex = 1; + + // 필터 적용 + if (filters?.work_type) { + query += ` AND work_type = $${paramIndex}`; + params.push(filters.work_type); + paramIndex++; + } + + if (filters?.status) { + query += ` AND status = $${paramIndex}`; + params.push(filters.status); + paramIndex++; + } + + if (filters?.vehicle_number) { + query += ` AND vehicle_number LIKE $${paramIndex}`; + params.push(`%${filters.vehicle_number}%`); + paramIndex++; + } + + if (filters?.driver_name) { + query += ` AND driver_name LIKE $${paramIndex}`; + params.push(`%${filters.driver_name}%`); + paramIndex++; + } + + if (filters?.start_date) { + query += ` AND work_date >= $${paramIndex}`; + params.push(filters.start_date); + paramIndex++; + } + + if (filters?.end_date) { + query += ` AND work_date <= $${paramIndex}`; + params.push(filters.end_date); + paramIndex++; + } + + if (filters?.search) { + query += ` AND ( + work_number LIKE $${paramIndex} OR + vehicle_number LIKE $${paramIndex} OR + driver_name LIKE $${paramIndex} OR + cargo_name LIKE $${paramIndex} + )`; + params.push(`%${filters.search}%`); + paramIndex++; + } + + query += ` ORDER BY work_date DESC`; + + const result: any = await pool.query(query, params); + return result.rows; + } catch (error) { + console.error('작업 이력 조회 실패:', error); + throw error; + } +} + +/** + * 작업 이력 단건 조회 + */ +export async function getWorkHistoryById(id: number): Promise { + try { + const result: any = await pool.query( + 'SELECT * FROM work_history WHERE id = $1 AND deleted_at IS NULL', + [id] + ); + return result.rows[0] || null; + } catch (error) { + console.error('작업 이력 조회 실패:', error); + throw error; + } +} + +/** + * 작업 이력 생성 + */ +export async function createWorkHistory(data: CreateWorkHistoryDto): Promise { + try { + const result: any = await pool.query( + `INSERT INTO work_history ( + work_type, vehicle_number, driver_name, origin, destination, + cargo_name, cargo_weight, cargo_unit, distance, distance_unit, + status, scheduled_time, estimated_arrival, notes, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING *`, + [ + data.work_type, + data.vehicle_number, + data.driver_name, + data.origin, + data.destination, + data.cargo_name, + data.cargo_weight, + data.cargo_unit || 'ton', + data.distance, + data.distance_unit || 'km', + data.status || 'pending', + data.scheduled_time, + data.estimated_arrival, + data.notes, + data.created_by, + ] + ); + return result.rows[0]; + } catch (error) { + console.error('작업 이력 생성 실패:', error); + throw error; + } +} + +/** + * 작업 이력 수정 + */ +export async function updateWorkHistory(id: number, data: UpdateWorkHistoryDto): Promise { + try { + const fields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + Object.entries(data).forEach(([key, value]) => { + if (value !== undefined) { + fields.push(`${key} = $${paramIndex}`); + values.push(value); + paramIndex++; + } + }); + + if (fields.length === 0) { + throw new Error('수정할 데이터가 없습니다'); + } + + values.push(id); + const query = ` + UPDATE work_history + SET ${fields.join(', ')} + WHERE id = $${paramIndex} AND deleted_at IS NULL + RETURNING * + `; + + const result: any = await pool.query(query, values); + if (result.rows.length === 0) { + throw new Error('작업 이력을 찾을 수 없습니다'); + } + return result.rows[0]; + } catch (error) { + console.error('작업 이력 수정 실패:', error); + throw error; + } +} + +/** + * 작업 이력 삭제 (소프트 삭제) + */ +export async function deleteWorkHistory(id: number): Promise { + try { + const result: any = await pool.query( + 'UPDATE work_history SET deleted_at = CURRENT_TIMESTAMP WHERE id = $1 AND deleted_at IS NULL', + [id] + ); + if (result.rowCount === 0) { + throw new Error('작업 이력을 찾을 수 없습니다'); + } + } catch (error) { + console.error('작업 이력 삭제 실패:', error); + throw error; + } +} + +/** + * 작업 이력 통계 조회 + */ +export async function getWorkHistoryStats(): Promise { + try { + // 오늘 작업 통계 + const todayResult: any = await pool.query(` + SELECT + COUNT(*) as today_total, + COUNT(*) FILTER (WHERE status = 'completed') as today_completed + FROM work_history + WHERE DATE(work_date) = CURRENT_DATE AND deleted_at IS NULL + `); + + // 총 운송량 및 거리 + const totalResult: any = await pool.query(` + SELECT + COALESCE(SUM(cargo_weight), 0) as total_weight, + COALESCE(SUM(distance), 0) as total_distance + FROM work_history + WHERE deleted_at IS NULL AND status = 'completed' + `); + + // 정시 도착률 + const onTimeResult: any = await pool.query(` + SELECT + COUNT(*) FILTER (WHERE is_on_time = true) * 100.0 / NULLIF(COUNT(*), 0) as on_time_rate + FROM work_history + WHERE deleted_at IS NULL + AND status = 'completed' + AND is_on_time IS NOT NULL + `); + + // 작업 유형별 분포 + const typeResult: any = await pool.query(` + SELECT + work_type, + COUNT(*) as count + FROM work_history + WHERE deleted_at IS NULL + GROUP BY work_type + `); + + const typeDistribution = { + inbound: 0, + outbound: 0, + transfer: 0, + maintenance: 0, + }; + + typeResult.rows.forEach((row: any) => { + typeDistribution[row.work_type as keyof typeof typeDistribution] = parseInt(row.count); + }); + + return { + today_total: parseInt(todayResult.rows[0].today_total), + today_completed: parseInt(todayResult.rows[0].today_completed), + total_weight: parseFloat(totalResult.rows[0].total_weight), + total_distance: parseFloat(totalResult.rows[0].total_distance), + on_time_rate: parseFloat(onTimeResult.rows[0]?.on_time_rate || '0'), + type_distribution: typeDistribution, + }; + } catch (error) { + console.error('작업 이력 통계 조회 실패:', error); + throw error; + } +} + +/** + * 월별 추이 조회 + */ +export async function getMonthlyTrend(months: number = 6): Promise { + try { + const result: any = await pool.query( + ` + SELECT + TO_CHAR(work_date, 'YYYY-MM') as month, + COUNT(*) as total, + COUNT(*) FILTER (WHERE status = 'completed') as completed, + COALESCE(SUM(cargo_weight), 0) as weight, + COALESCE(SUM(distance), 0) as distance + FROM work_history + WHERE deleted_at IS NULL + AND work_date >= CURRENT_DATE - INTERVAL '${months} months' + GROUP BY TO_CHAR(work_date, 'YYYY-MM') + ORDER BY month DESC + `, + [] + ); + + return result.rows.map((row: any) => ({ + month: row.month, + total: parseInt(row.total), + completed: parseInt(row.completed), + weight: parseFloat(row.weight), + distance: parseFloat(row.distance), + })); + } catch (error) { + console.error('월별 추이 조회 실패:', error); + throw error; + } +} + +/** + * 주요 운송 경로 조회 + */ +export async function getTopRoutes(limit: number = 5): Promise { + try { + const result: any = await pool.query( + ` + SELECT + origin, + destination, + COUNT(*) as count, + COALESCE(SUM(cargo_weight), 0) as total_weight + FROM work_history + WHERE deleted_at IS NULL + AND origin IS NOT NULL + AND destination IS NOT NULL + AND work_type IN ('inbound', 'outbound', 'transfer') + GROUP BY origin, destination + ORDER BY count DESC + LIMIT $1 + `, + [limit] + ); + + return result.rows.map((row: any) => ({ + origin: row.origin, + destination: row.destination, + count: parseInt(row.count), + total_weight: parseFloat(row.total_weight), + })); + } catch (error) { + console.error('주요 운송 경로 조회 실패:', error); + throw error; + } +} + diff --git a/backend-node/src/types/workHistory.ts b/backend-node/src/types/workHistory.ts new file mode 100644 index 00000000..83c13fe2 --- /dev/null +++ b/backend-node/src/types/workHistory.ts @@ -0,0 +1,114 @@ +/** + * 작업 이력 관리 타입 정의 + */ + +export type WorkType = 'inbound' | 'outbound' | 'transfer' | 'maintenance'; +export type WorkStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export interface WorkHistory { + id: number; + work_number: string; + work_date: Date; + work_type: WorkType; + vehicle_number?: string; + driver_name?: string; + origin?: string; + destination?: string; + cargo_name?: string; + cargo_weight?: number; + cargo_unit?: string; + distance?: number; + distance_unit?: string; + status: WorkStatus; + scheduled_time?: Date; + start_time?: Date; + end_time?: Date; + estimated_arrival?: Date; + actual_arrival?: Date; + is_on_time?: boolean; + delay_reason?: string; + notes?: string; + created_by?: string; + created_at: Date; + updated_at: Date; + deleted_at?: Date; +} + +export interface CreateWorkHistoryDto { + work_type: WorkType; + vehicle_number?: string; + driver_name?: string; + origin?: string; + destination?: string; + cargo_name?: string; + cargo_weight?: number; + cargo_unit?: string; + distance?: number; + distance_unit?: string; + status?: WorkStatus; + scheduled_time?: Date; + estimated_arrival?: Date; + notes?: string; + created_by?: string; +} + +export interface UpdateWorkHistoryDto { + work_type?: WorkType; + vehicle_number?: string; + driver_name?: string; + origin?: string; + destination?: string; + cargo_name?: string; + cargo_weight?: number; + cargo_unit?: string; + distance?: number; + distance_unit?: string; + status?: WorkStatus; + scheduled_time?: Date; + start_time?: Date; + end_time?: Date; + estimated_arrival?: Date; + actual_arrival?: Date; + delay_reason?: string; + notes?: string; +} + +export interface WorkHistoryFilters { + work_type?: WorkType; + status?: WorkStatus; + vehicle_number?: string; + driver_name?: string; + start_date?: Date; + end_date?: Date; + search?: string; +} + +export interface WorkHistoryStats { + today_total: number; + today_completed: number; + total_weight: number; + total_distance: number; + on_time_rate: number; + type_distribution: { + inbound: number; + outbound: number; + transfer: number; + maintenance: number; + }; +} + +export interface MonthlyTrend { + month: string; + total: number; + completed: number; + weight: number; + distance: number; +} + +export interface TopRoute { + origin: string; + destination: string; + count: number; + total_weight: number; +} + diff --git a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx index f8af0d0f..7639abc6 100644 --- a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx +++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useState, useEffect, use } from "react"; -import { useRouter } from "next/navigation"; import { DashboardViewer } from "@/components/dashboard/DashboardViewer"; import { DashboardElement } from "@/components/admin/dashboard/types"; @@ -18,7 +17,6 @@ interface DashboardViewPageProps { * - 전체화면 모드 지원 */ export default function DashboardViewPage({ params }: DashboardViewPageProps) { - const router = useRouter(); const resolvedParams = use(params); const [dashboard, setDashboard] = useState<{ id: string; @@ -35,12 +33,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - // 대시보드 데이터 로딩 - useEffect(() => { - loadDashboard(); - }, [resolvedParams.dashboardId]); - - const loadDashboard = async () => { + const loadDashboard = React.useCallback(async () => { setIsLoading(true); setError(null); @@ -50,13 +43,16 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { try { const dashboardData = await dashboardApi.getDashboard(resolvedParams.dashboardId); - setDashboard(dashboardData); + setDashboard({ + ...dashboardData, + elements: dashboardData.elements || [], + }); } catch (apiError) { console.warn("API 호출 실패, 로컬 스토리지 확인:", apiError); // API 실패 시 로컬 스토리지에서 찾기 const savedDashboards = JSON.parse(localStorage.getItem("savedDashboards") || "[]"); - const savedDashboard = savedDashboards.find((d: any) => d.id === resolvedParams.dashboardId); + const savedDashboard = savedDashboards.find((d: { id: string }) => d.id === resolvedParams.dashboardId); if (savedDashboard) { setDashboard(savedDashboard); @@ -72,7 +68,12 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { } finally { setIsLoading(false); } - }; + }, [resolvedParams.dashboardId]); + + // 대시보드 데이터 로딩 + useEffect(() => { + loadDashboard(); + }, [loadDashboard]); // 로딩 상태 if (isLoading) { @@ -159,10 +160,11 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { */} {/* 대시보드 뷰어 */} - ); @@ -171,8 +173,33 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { /** * 샘플 대시보드 생성 함수 */ -function generateSampleDashboard(dashboardId: string) { - const dashboards: Record = { +function generateSampleDashboard(dashboardId: string): { + id: string; + title: string; + description?: string; + elements: DashboardElement[]; + settings?: { + backgroundColor?: string; + resolution?: string; + }; + createdAt: string; + updatedAt: string; +} { + const dashboards: Record< + string, + { + id: string; + title: string; + description?: string; + elements: DashboardElement[]; + settings?: { + backgroundColor?: string; + resolution?: string; + }; + createdAt: string; + updatedAt: string; + } + > = { "sales-overview": { id: "sales-overview", title: "📊 매출 현황 대시보드", diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 6b54daae..db58207e 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, QueryResult } from "./types"; import { ChartRenderer } from "./charts/ChartRenderer"; -import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils"; +import { GRID_CONFIG } from "./gridUtils"; // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { @@ -112,10 +112,23 @@ const YardManagement3DWidget = dynamic(() => import("./widgets/YardManagement3DW loading: () =>
로딩 중...
, }); +// 작업 이력 위젯 +const WorkHistoryWidget = dynamic(() => import("@/components/dashboard/widgets/WorkHistoryWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +// 커스텀 통계 카드 위젯 +const CustomStatsWidget = dynamic(() => import("@/components/dashboard/widgets/CustomStatsWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + interface CanvasElementProps { element: DashboardElement; isSelected: boolean; cellSize: number; + subGridSize: number; canvasWidth?: number; onUpdate: (id: string, updates: Partial) => void; onRemove: (id: string) => void; @@ -133,6 +146,7 @@ export function CanvasElement({ element, isSelected, cellSize, + subGridSize, canvasWidth = 1560, onUpdate, onRemove, @@ -163,7 +177,7 @@ export function CanvasElement({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { // 모달이나 다이얼로그가 열려있으면 드래그 무시 - if (document.querySelector('[role="dialog"]')) { + if (document.querySelector('[role="dialog"]') || document.querySelector('[role="alertdialog"]')) { return; } @@ -198,7 +212,7 @@ export function CanvasElement({ const handleResizeMouseDown = useCallback( (e: React.MouseEvent, handle: string) => { // 모달이나 다이얼로그가 열려있으면 리사이즈 무시 - if (document.querySelector('[role="dialog"]')) { + if (document.querySelector('[role="dialog"]') || document.querySelector('[role="alertdialog"]')) { return; } @@ -233,7 +247,6 @@ export function CanvasElement({ rawX = Math.min(rawX, maxX); // 드래그 중 실시간 스냅 (마그네틱 스냅) - const subGridSize = Math.floor(cellSize / 3); const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; // 큰 그리드에 끌리는 거리 (px) @@ -291,7 +304,6 @@ export function CanvasElement({ newWidth = Math.min(newWidth, maxWidth); // 리사이즈 중 실시간 스냅 (마그네틱 스냅) - const subGridSize = Math.floor(cellSize / 3); const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; @@ -336,6 +348,7 @@ export function CanvasElement({ element.subtype, canvasWidth, cellSize, + subGridSize, ], ); @@ -726,10 +739,21 @@ export function CanvasElement({ isEditMode={true} config={element.yardConfig} onConfigChange={(newConfig) => { + console.log("🏗️ 야드 설정 업데이트:", { elementId: element.id, newConfig }); onUpdate(element.id, { yardConfig: newConfig }); }} /> + ) : element.type === "widget" && element.subtype === "work-history" ? ( + // 작업 이력 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "transport-stats" ? ( + // 커스텀 통계 카드 위젯 렌더링 +
+ +
) : element.type === "widget" && element.subtype === "todo" ? ( // To-Do 위젯 렌더링
diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 45d2cf3c..3170880a 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -156,8 +156,7 @@ export const DashboardCanvas = forwardRef( const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0); // 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드) - const subGridSize = Math.floor(cellSize / 3); - const gridSize = cellSize + 5; // GAP 포함한 실제 그리드 크기 + const gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; // X 좌표 스냅 @@ -196,6 +195,9 @@ export const DashboardCanvas = forwardRef( // 동적 그리드 크기 계산 const cellWithGap = cellSize + GRID_CONFIG.GAP; const gridSize = `${cellWithGap}px ${cellWithGap}px`; + + // 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용) + const subGridSize = gridConfig.SUB_GRID_SIZE; // 12개 컬럼 구분선 위치 계산 const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap); @@ -208,12 +210,12 @@ export const DashboardCanvas = forwardRef( backgroundColor, height: `${canvasHeight}px`, minHeight: `${canvasHeight}px`, - // 세밀한 그리드 배경 + // 서브그리드 배경 (세밀한 점선) backgroundImage: ` - linear-gradient(rgba(59, 130, 246, 0.08) 1px, transparent 1px), - linear-gradient(90deg, rgba(59, 130, 246, 0.08) 1px, transparent 1px) + linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px) `, - backgroundSize: gridSize, + backgroundSize: `${subGridSize}px ${subGridSize}px`, backgroundPosition: "0 0", backgroundRepeat: "repeat", }} @@ -229,8 +231,9 @@ export const DashboardCanvas = forwardRef( className="pointer-events-none absolute top-0 h-full" style={{ left: `${x}px`, - width: "2px", - zIndex: 1, + width: "1px", + backgroundColor: i === 0 || i === GRID_CONFIG.COLUMNS ? "rgba(59, 130, 246, 0.3)" : "rgba(59, 130, 246, 0.15)", + zIndex: 0, }} /> ))} @@ -248,6 +251,7 @@ export const DashboardCanvas = forwardRef( element={element} isSelected={selectedElement === element.id} cellSize={cellSize} + subGridSize={subGridSize} canvasWidth={canvasWidth} onUpdate={handleUpdateWithCollisionDetection} onRemove={onRemoveElement} diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 27115ee1..f3f0b17b 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -6,6 +6,7 @@ import { DashboardCanvas } from "./DashboardCanvas"; import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigModal } from "./ElementConfigModal"; import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; +import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal"; import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize } from "./gridUtils"; @@ -140,18 +141,38 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const { dashboardApi } = await import("@/lib/api/dashboard"); const dashboard = await dashboardApi.getDashboard(id); + console.log("📊 대시보드 로드:", { + id: dashboard.id, + title: dashboard.title, + settings: dashboard.settings, + settingsType: typeof dashboard.settings, + }); + // 대시보드 정보 설정 setDashboardId(dashboard.id); setDashboardTitle(dashboard.title); // 저장된 설정 복원 const settings = (dashboard as { settings?: { resolution?: Resolution; backgroundColor?: string } }).settings; + console.log("🎨 설정 복원:", { + settings, + resolution: settings?.resolution, + backgroundColor: settings?.backgroundColor, + currentResolution: resolution, + }); + if (settings?.resolution) { setResolution(settings.resolution); + console.log("✅ Resolution 설정됨:", settings.resolution); + } else { + console.log("⚠️ Resolution 없음, 기본값 유지:", resolution); } if (settings?.backgroundColor) { setCanvasBackgroundColor(settings.backgroundColor); + console.log("✅ BackgroundColor 설정됨:", settings.backgroundColor); + } else { + console.log("⚠️ BackgroundColor 없음, 기본값 유지:", canvasBackgroundColor); } // 요소들 설정 @@ -332,21 +353,31 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D try { const { dashboardApi } = await import("@/lib/api/dashboard"); - const elementsData = elements.map((el) => ({ - id: el.id, - type: el.type, - subtype: el.subtype, - position: el.position, - size: el.size, - title: el.title, - customTitle: el.customTitle, - showHeader: el.showHeader, - content: el.content, - dataSource: el.dataSource, - chartConfig: el.chartConfig, - listConfig: el.listConfig, - yardConfig: el.yardConfig, - })); + const elementsData = elements.map((el) => { + // 야드 위젯인 경우 설정 로그 출력 + if (el.subtype === "yard-management-3d") { + console.log("💾 야드 위젯 저장:", { + id: el.id, + yardConfig: el.yardConfig, + hasLayoutId: !!el.yardConfig?.layoutId, + }); + } + return { + id: el.id, + type: el.type, + subtype: el.subtype, + position: el.position, + size: el.size, + title: el.title, + customTitle: el.customTitle, + showHeader: el.showHeader, + content: el.content, + dataSource: el.dataSource, + chartConfig: el.chartConfig, + listConfig: el.listConfig, + yardConfig: el.yardConfig, + }; + }); let savedDashboard; @@ -495,6 +526,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D onClose={closeConfigModal} onSave={saveListWidgetConfig} /> + ) : configModalElement.type === "widget" && configModalElement.subtype === "yard-management-3d" ? ( + ) : ( setTitle(e.target.value)} + onKeyDown={(e) => { + // 모든 키보드 이벤트를 input 필드 내부에서만 처리 + e.stopPropagation(); + }} placeholder="예: 생산 현황 대시보드" className="w-full" /> @@ -195,6 +199,10 @@ export function DashboardSaveModal({ id="description" value={description} onChange={(e) => setDescription(e.target.value)} + onKeyDown={(e) => { + // 모든 키보드 이벤트를 textarea 내부에서만 처리 + e.stopPropagation(); + }} placeholder="대시보드에 대한 간단한 설명을 입력하세요" rows={3} className="w-full resize-none" diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index 2f50a874..4a2c239f 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -219,6 +219,18 @@ export function DashboardSidebar() { subtype="list" onDragStart={handleDragStart} /> + +
)} diff --git a/frontend/components/admin/dashboard/DashboardTopMenu.tsx b/frontend/components/admin/dashboard/DashboardTopMenu.tsx index 35062400..4b9c1fea 100644 --- a/frontend/components/admin/dashboard/DashboardTopMenu.tsx +++ b/frontend/components/admin/dashboard/DashboardTopMenu.tsx @@ -182,6 +182,7 @@ export function DashboardTopMenu({ 데이터 위젯 리스트 위젯 야드 관리 3D + 커스텀 통계 카드 {/* 지도 */} 커스텀 지도 카드 {/* 커스텀 목록 카드 */} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index ad4de687..6aba88db 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -36,6 +36,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) const isSimpleWidget = + element.subtype === "todo" || // To-Do 위젯 + element.subtype === "booking-alert" || // 예약 알림 위젯 + element.subtype === "maintenance" || // 정비 일정 위젯 + element.subtype === "document" || // 문서 위젯 + element.subtype === "risk-alert" || // 리스크 알림 위젯 element.subtype === "vehicle-status" || element.subtype === "vehicle-list" || element.subtype === "status-summary" || // 커스텀 상태 카드 @@ -45,7 +50,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element element.subtype === "delivery-today-stats" || element.subtype === "cargo-list" || element.subtype === "customer-issues" || - element.subtype === "driver-management"; + element.subtype === "driver-management" || + element.subtype === "work-history" || // 작업 이력 위젯 (쿼리 필요) + element.subtype === "transport-stats"; // 커스텀 통계 카드 위젯 (쿼리 필요) + + // 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능) + const isSelfContainedWidget = + element.subtype === "weather" || // 날씨 위젯 (외부 API) + element.subtype === "exchange" || // 환율 위젯 (외부 API) + element.subtype === "calculator"; // 계산기 위젯 (자체 기능) // 지도 위젯 (위도/경도 매핑 필요) const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary"; @@ -59,6 +72,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element setQueryResult(null); setCurrentStep(1); setCustomTitle(element.customTitle || ""); + setShowHeader(element.showHeader !== false); // showHeader 초기화 } }, [isOpen, element]); @@ -135,8 +149,12 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 모달이 열려있지 않으면 렌더링하지 않음 if (!isOpen) return null; - // 시계, 달력, To-Do 위젯은 헤더 설정만 가능 - const isHeaderOnlyWidget = element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "todo"); + // 시계, 달력, 날씨, 환율, 계산기 위젯은 헤더 설정만 가능 + const isHeaderOnlyWidget = + element.type === "widget" && + (element.subtype === "clock" || + element.subtype === "calendar" || + isSelfContainedWidget); // 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음 if (element.type === "widget" && element.subtype === "driver-management") { @@ -154,11 +172,15 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // customTitle이 변경되었는지 확인 const isTitleChanged = customTitle.trim() !== (element.customTitle || ""); + + // showHeader가 변경되었는지 확인 + const isHeaderChanged = showHeader !== (element.showHeader !== false); const canSave = isTitleChanged || // 제목만 변경해도 저장 가능 + isHeaderChanged || // 헤더 표시 여부만 변경해도 저장 가능 (isSimpleWidget - ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 + ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 (차트 설정 불필요) currentStep === 2 && queryResult && queryResult.rows.length > 0 : isMapWidget ? // 지도 위젯: 위도/경도 매핑 필요 @@ -184,7 +206,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
{/* 모달 헤더 */} @@ -336,7 +358,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element 저장 ) : currentStep === 1 ? ( - // 1단계: 다음 버튼 + // 1단계: 다음 버튼 (차트 위젯, 간단한 위젯 모두)
); } + diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx index e83c9c9b..1335b243 100644 --- a/frontend/components/admin/dashboard/QueryEditor.tsx +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -208,6 +208,10 @@ ORDER BY 하위부서수 DESC`,