diff --git a/backend-node/.env.example b/backend-node/.env.example new file mode 100644 index 00000000..fdba2895 --- /dev/null +++ b/backend-node/.env.example @@ -0,0 +1,12 @@ + +# ==================== 운영/작업 지원 위젯 데이터 소스 설정 ==================== +# 옵션: file | database | memory +# - file: 파일 기반 (빠른 개발/테스트) +# - database: PostgreSQL DB (실제 운영) +# - memory: 메모리 목 데이터 (테스트) + +TODO_DATA_SOURCE=file +BOOKING_DATA_SOURCE=file +MAINTENANCE_DATA_SOURCE=memory +DOCUMENT_DATA_SOURCE=memory + diff --git a/backend-node/.env.shared b/backend-node/.env.shared new file mode 100644 index 00000000..3b546ed9 --- /dev/null +++ b/backend-node/.env.shared @@ -0,0 +1,44 @@ +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 🔑 공유 API 키 (팀 전체 사용) +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# +# ⚠️ 주의: 이 파일은 Git에 커밋됩니다! +# 팀원들이 동일한 API 키를 사용합니다. +# +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +# 한국은행 환율 API 키 +# 발급: https://www.bok.or.kr/portal/openapi/OpenApiGuide.do +BOK_API_KEY=OXIGPQXH68NUKVKL5KT9 + +# 기상청 API Hub 키 +# 발급: https://apihub.kma.go.kr/ +KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA + +# ITS 국가교통정보센터 API 키 +# 발급: https://www.its.go.kr/ +ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 + +# 한국도로공사 OpenOASIS API 키 +# 발급: https://data.ex.co.kr/ (OpenOASIS 신청) +EXWAY_API_KEY=7820214492 + +# ExchangeRate API 키 (백업용, 선택사항) +# 발급: https://www.exchangerate-api.com/ +# EXCHANGERATE_API_KEY=your_exchangerate_api_key_here + +# Kakao API 키 (Geocoding용, 선택사항) +# 발급: https://developers.kakao.com/ +# KAKAO_API_KEY=your_kakao_api_key_here + +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 📝 사용 방법 +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# +# 1. 이 파일을 복사하여 .env 파일 생성: +# $ cp .env.shared .env +# +# 2. 그대로 사용하면 됩니다! +# (팀 전체가 동일한 키 사용) +# +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/backend-node/API_연동_가이드.md b/backend-node/API_연동_가이드.md new file mode 100644 index 00000000..0af08e43 --- /dev/null +++ b/backend-node/API_연동_가이드.md @@ -0,0 +1,174 @@ + +# 🔌 API 연동 가이드 + +## 📊 현재 상태 + +### ✅ 작동 중인 API + +1. **기상청 특보 API** (완벽 작동!) + - API 키: `ogdXr2e9T4iHV69nvV-IwA` + - 상태: ✅ 14건 실시간 특보 수신 중 + - 제공 데이터: 대설/강풍/한파/태풍/폭염 특보 + +2. **한국은행 환율 API** (완벽 작동!) + - API 키: `OXIGPQXH68NUKVKL5KT9` + - 상태: ✅ 환율 위젯 작동 중 + +### ⚠️ 더미 데이터 사용 중 + +3. **교통사고 정보** + - 한국도로공사 API: ❌ 서버 호출 차단 + - 현재 상태: 더미 데이터 (2건) + +4. **도로공사 정보** + - 한국도로공사 API: ❌ 서버 호출 차단 + - 현재 상태: 더미 데이터 (2건) + +--- + +## 🚀 실시간 교통정보 연동하기 + +### 📌 국토교통부 ITS API (추천!) + +#### 1단계: API 신청 +1. https://www.data.go.kr/ 접속 +2. 검색: **"ITS 돌발정보"** 또는 **"실시간 교통정보"** +3. **활용신청** 클릭 +4. **승인 대기 (1~2일)** + +#### 2단계: API 키 추가 +승인 완료되면 `.env` 파일에 추가: + +```env +# 국토교통부 ITS API 키 +ITS_API_KEY=발급받은_API_키 +``` + +#### 3단계: 서버 재시작 +```bash +docker restart pms-backend-mac +``` + +#### 4단계: 확인 +- 로그에서 `✅ 국토교통부 ITS 교통사고 API 응답 수신 완료` 확인 +- 더미 데이터 대신 실제 데이터가 표시됨! + +--- + +## 🔍 한국도로공사 API 문제 + +### 발급된 키 +``` +EXWAY_API_KEY=7820214492 +``` + +### 문제 상황 +- ❌ 서버/백엔드에서 호출 시: `Request Blocked` (400) +- ❌ curl 명령어: `Request Blocked` +- ❌ 모든 엔드포인트 차단됨 + +### 가능한 원인 +1. **브라우저에서만 접근 허용** + - Referer 헤더 검증 + - User-Agent 검증 + +2. **IP 화이트리스트** + - 특정 IP에서만 접근 가능 + - 서버 IP 등록 필요 + +3. **API 키 활성화 대기** + - 발급 후 승인 대기 중 + - 몇 시간~1일 소요 + +### 해결 방법 +1. 한국도로공사 담당자 문의 (054-811-4533) +2. 국토교통부 ITS API 사용 (더 안정적) + +--- + +## 📝 코드 구조 + +### 다중 API 폴백 시스템 +```typescript +// 1순위: 국토교통부 ITS API +if (process.env.ITS_API_KEY) { + try { + // ITS API 호출 + return itsData; + } catch { + console.log('2순위 API로 전환'); + } +} + +// 2순위: 한국도로공사 API +try { + // 한국도로공사 API 호출 + return exwayData; +} catch { + console.log('더미 데이터 사용'); +} + +// 3순위: 더미 데이터 +return dummyData; +``` + +### 파일 위치 +- 서비스: `backend-node/src/services/riskAlertService.ts` +- 컨트롤러: `backend-node/src/controllers/riskAlertController.ts` +- 라우트: `backend-node/src/routes/riskAlertRoutes.ts` + +--- + +## 💡 현재 대시보드 위젯 데이터 + +### 리스크/알림 위젯 +``` +✅ 날씨특보: 14건 (실제 기상청 데이터) +⚠️ 교통사고: 2건 (더미 데이터) +⚠️ 도로공사: 2건 (더미 데이터) +───────────────────────── +총 18건의 알림 +``` + +### 개선 후 (ITS API 연동 시) +``` +✅ 날씨특보: 14건 (실제 기상청 데이터) +✅ 교통사고: N건 (실제 ITS 데이터) +✅ 도로공사: N건 (실제 ITS 데이터) +───────────────────────── +총 N건의 알림 (모두 실시간!) +``` + +--- + +## 🎯 다음 단계 + +### 단기 (지금) +- [x] 기상청 특보 API 연동 완료 +- [x] 한국은행 환율 API 연동 완료 +- [x] 다중 API 폴백 시스템 구축 +- [ ] 국토교통부 ITS API 신청 + +### 장기 (향후) +- [ ] 서울시 TOPIS API 추가 (서울시 교통정보) +- [ ] 경찰청 교통사고 정보 API (승인 필요) +- [ ] 기상청 단기예보 API 추가 + +--- + +## 📞 문의 + +### 한국도로공사 +- 전화: 054-811-4533 (컨텐츠 문의) +- 전화: 070-8656-8771 (시스템 장애) + +### 공공데이터포털 +- 웹사이트: https://www.data.go.kr/ +- 고객센터: 1661-0423 + +--- + +**작성일**: 2025-10-14 +**작성자**: AI Assistant +**상태**: ✅ 기상청 특보 작동 중, ITS API 연동 준비 완료 + diff --git a/backend-node/API_키_정리.md b/backend-node/API_키_정리.md new file mode 100644 index 00000000..04d8f245 --- /dev/null +++ b/backend-node/API_키_정리.md @@ -0,0 +1,140 @@ + +# 🔑 API 키 현황 및 연동 상태 + +## ✅ 완벽 작동 중 + +### 1. 기상청 API Hub +- **API 키**: `ogdXr2e9T4iHV69nvV-IwA` +- **상태**: ✅ 14건 실시간 특보 수신 중 +- **제공 데이터**: 대설/강풍/한파/태풍/폭염 특보 +- **코드 위치**: `backend-node/src/services/riskAlertService.ts` + +### 2. 한국은행 환율 API +- **API 키**: `OXIGPQXH68NUKVKL5KT9` +- **상태**: ✅ 환율 위젯 작동 중 +- **제공 데이터**: USD/EUR/JPY/CNY 환율 + +--- + +## ⚠️ 연동 대기 중 + +### 3. 한국도로공사 OpenOASIS API +- **API 키**: `7820214492` +- **상태**: ❌ 엔드포인트 URL 불명 +- **문제**: + - 발급 이메일에 사용법 없음 + - 매뉴얼에 상세 정보 없음 + - 테스트한 URL 모두 실패 + +**해결 방법**: +``` +📞 한국도로공사 고객센터 문의 + +컨텐츠 문의: 054-811-4533 +시스템 장애: 070-8656-8771 + +문의 내용: +"OpenOASIS API 인증키(7820214492)를 발급받았는데 + 사용 방법과 엔드포인트 URL을 알려주세요. + - 돌발상황정보 API + - 교통사고 정보 + - 도로공사 정보" +``` + +### 4. 국토교통부 ITS API +- **API 키**: `d6b9befec3114d648284674b8fddcc32` +- **상태**: ❌ 엔드포인트 URL 불명 +- **승인 API**: + - 교통소통정보 + - 돌발상황정보 + - CCTV 화상자료 + - 교통예측정보 + - 차량검지정보 + - 도로전광표지(VMS) + - 주의운전구간 + - 가변형 속도제한표지(VSL) + - 위험물질 운송차량 사고정보 + +**해결 방법**: +``` +📞 ITS 국가교통정보센터 문의 + +전화: 1577-6782 +이메일: its@ex.co.kr + +문의 내용: +"ITS API 인증키(d6b9befec3114d648284674b8fddcc32)를 + 발급받았는데 매뉴얼에 엔드포인트 URL이 없습니다. + 돌발상황정보 API의 정확한 URL과 파라미터를 + 알려주세요." +``` + +--- + +## 🔧 백엔드 연동 준비 완료 + +### 파일 위치 +- **서비스**: `backend-node/src/services/riskAlertService.ts` +- **컨트롤러**: `backend-node/src/controllers/riskAlertController.ts` +- **라우트**: `backend-node/src/routes/riskAlertRoutes.ts` + +### 다중 API 폴백 시스템 +```typescript +1순위: 국토교통부 ITS API (process.env.ITS_API_KEY) +2순위: 한국도로공사 API (process.env.EXWAY_API_KEY) +3순위: 더미 데이터 (현실적인 예시) +``` + +### 연동 방법 +```bash +# .env 파일에 추가 +ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 +EXWAY_API_KEY=7820214492 + +# 백엔드 재시작 +docker restart pms-backend-mac + +# 로그 확인 +docker logs pms-backend-mac --tail 50 +``` + +--- + +## 📊 현재 리스크/알림 시스템 + +``` +✅ 기상특보: 14건 (실시간 기상청 데이터) +⚠️ 교통사고: 2건 (더미 데이터) +⚠️ 도로공사: 2건 (더미 데이터) +──────────────────────────── +총 18건의 알림 +``` + +--- + +## 🚀 다음 단계 + +### 단기 (지금) +- [x] 기상청 특보 API 연동 완료 +- [x] 한국은행 환율 API 연동 완료 +- [x] ITS/한국도로공사 API 키 발급 완료 +- [x] 다중 API 폴백 시스템 구축 +- [ ] **API 엔드포인트 URL 확인 (고객센터 문의)** + +### 중기 (API URL 확인 후) +- [ ] ITS API 연동 (즉시 가능) +- [ ] 한국도로공사 API 연동 (즉시 가능) +- [ ] 실시간 교통사고 데이터 표시 +- [ ] 실시간 도로공사 데이터 표시 + +### 장기 (추가 기능) +- [ ] 서울시 TOPIS API 추가 +- [ ] CCTV 화상 자료 연동 +- [ ] 도로전광표지(VMS) 정보 +- [ ] 교통예측정보 + +--- + +**작성일**: 2025-10-14 +**상태**: 기상청 특보 작동 중, 교통정보 API URL 확인 필요 + diff --git a/backend-node/README_API_SETUP.md b/backend-node/README_API_SETUP.md new file mode 100644 index 00000000..6f4a930d --- /dev/null +++ b/backend-node/README_API_SETUP.md @@ -0,0 +1,87 @@ +# 🔑 API 키 설정 가이드 + +## 빠른 시작 (신규 팀원용) + +### 1. API 키 파일 복사 +```bash +cd backend-node +cp .env.shared .env +``` + +### 2. 끝! +- `.env.shared` 파일에 **팀 공유 API 키**가 이미 들어있습니다 +- 그대로 복사해서 사용하면 됩니다 +- 추가 발급 필요 없음! + +--- + +## 📋 포함된 API 키 + +### ✅ 한국은행 환율 API +- 용도: 환율 정보 조회 +- 키: `OXIGPQXH68NUKVKL5KT9` + +### ✅ 기상청 API Hub +- 용도: 날씨특보, 기상정보 +- 키: `ogdXr2e9T4iHV69nvV-IwA` + +### ✅ ITS 국가교통정보센터 +- 용도: 교통사고, 도로공사 정보 +- 키: `d6b9befec3114d648284674b8fddcc32` + +### ✅ 한국도로공사 OpenOASIS +- 용도: 고속도로 교통정보 +- 키: `7820214492` + +--- + +## ⚠️ 주의사항 + +### Git 관리 +```bash +✅ .env.shared → Git에 커밋됨 (팀 공유용) +❌ .env → Git에 커밋 안 됨 (개인 설정) +``` + +### 보안 +- **팀 내부 프로젝트**이므로 키 공유가 안전합니다 +- 외부 공개 프로젝트라면 각자 발급받아야 합니다 + +--- + +## 🚀 서버 시작 + +```bash +# 1. API 키 설정 (최초 1회만) +cp .env.shared .env + +# 2. 서버 시작 +npm run dev + +# 또는 Docker +docker-compose up -d +``` + +--- + +## 💡 트러블슈팅 + +### `.env` 파일이 없다는 오류 +```bash +# 해결: .env.shared를 복사 +cp .env.shared .env +``` + +### API 호출이 실패함 +```bash +# 1. .env 파일 확인 +cat .env + +# 2. API 키가 제대로 복사되었는지 확인 +# 3. 서버 재시작 +npm run dev +``` + +--- + +**팀원 여러분, `.env.shared`를 복사해서 사용하세요!** 👍 diff --git a/backend-node/data/bookings/bookings.json b/backend-node/data/bookings/bookings.json new file mode 100644 index 00000000..d15aeef6 --- /dev/null +++ b/backend-node/data/bookings/bookings.json @@ -0,0 +1,35 @@ +[ + { + "id": "773568c7-0fc8-403d-ace2-01a11fae7189", + "customerName": "김철수", + "customerPhone": "010-1234-5678", + "pickupLocation": "서울시 강남구 역삼동 123", + "dropoffLocation": "경기도 성남시 분당구 정자동 456", + "scheduledTime": "2025-10-14T10:03:32.556Z", + "vehicleType": "truck", + "cargoType": "전자제품", + "weight": 500, + "status": "accepted", + "priority": "urgent", + "createdAt": "2025-10-14T08:03:32.556Z", + "updatedAt": "2025-10-14T08:06:45.073Z", + "estimatedCost": 150000, + "acceptedAt": "2025-10-14T08:06:45.073Z" + }, + { + "id": "0751b297-18df-42c0-871c-85cded1f6dae", + "customerName": "이영희", + "customerPhone": "010-9876-5432", + "pickupLocation": "서울시 송파구 잠실동 789", + "dropoffLocation": "인천시 남동구 구월동 321", + "scheduledTime": "2025-10-14T12:03:32.556Z", + "vehicleType": "van", + "cargoType": "가구", + "weight": 300, + "status": "pending", + "priority": "normal", + "createdAt": "2025-10-14T07:53:32.556Z", + "updatedAt": "2025-10-14T07:53:32.556Z", + "estimatedCost": 80000 + } +] \ No newline at end of file diff --git a/backend-node/data/todos/todos.json b/backend-node/data/todos/todos.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/backend-node/data/todos/todos.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 7a96aaa2..46d2fea5 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -27,6 +27,7 @@ "multer": "^1.4.5-lts.1", "mysql2": "^3.15.0", "node-cron": "^4.2.1", + "node-fetch": "^2.7.0", "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", @@ -48,6 +49,7 @@ "@types/multer": "^1.4.13", "@types/node": "^20.10.5", "@types/node-cron": "^3.0.11", + "@types/node-fetch": "^2.6.13", "@types/nodemailer": "^6.4.20", "@types/oracledb": "^6.9.1", "@types/pg": "^8.15.5", @@ -3380,6 +3382,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, "node_modules/@types/nodemailer": { "version": "6.4.20", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.20.tgz", @@ -8116,6 +8129,26 @@ "node": ">=6.0.0" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -9861,6 +9894,12 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -10237,6 +10276,22 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 1f96f8e5..a6744ac6 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -41,6 +41,7 @@ "multer": "^1.4.5-lts.1", "mysql2": "^3.15.0", "node-cron": "^4.2.1", + "node-fetch": "^2.7.0", "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", @@ -62,6 +63,7 @@ "@types/multer": "^1.4.13", "@types/node": "^20.10.5", "@types/node-cron": "^3.0.11", + "@types/node-fetch": "^2.6.13", "@types/nodemailer": "^6.4.20", "@types/oracledb": "^6.9.1", "@types/pg": "^8.15.5", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 0fcf27d1..ae10a6fe 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -50,6 +50,11 @@ import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes"; import dashboardRoutes from "./routes/dashboardRoutes"; import reportRoutes from "./routes/reportRoutes"; import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API +import deliveryRoutes from "./routes/deliveryRoutes"; // 배송/화물 관리 +import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관리 +import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 +import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 +import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -194,6 +199,11 @@ app.use("/api/dataflow", dataflowExecutionRoutes); app.use("/api/dashboards", dashboardRoutes); app.use("/api/admin/reports", reportRoutes); app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API +app.use("/api/delivery", deliveryRoutes); // 배송/화물 관리 +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/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); @@ -228,6 +238,16 @@ app.listen(PORT, HOST, async () => { } catch (error) { logger.error(`❌ 배치 스케줄러 초기화 실패:`, error); } + + // 리스크/알림 자동 갱신 시작 + try { + const { RiskAlertCacheService } = await import('./services/riskAlertCacheService'); + const cacheService = RiskAlertCacheService.getInstance(); + cacheService.startAutoRefresh(); + logger.info(`⏰ 리스크/알림 자동 갱신이 시작되었습니다. (10분 간격)`); + } catch (error) { + logger.error(`❌ 리스크/알림 자동 갱신 시작 실패:`, error); + } }); export default app; diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index d35c102b..cf8f3cc2 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -1,8 +1,12 @@ -import { Response } from 'express'; -import { AuthenticatedRequest } from '../middleware/authMiddleware'; -import { DashboardService } from '../services/DashboardService'; -import { CreateDashboardRequest, UpdateDashboardRequest, DashboardListQuery } from '../types/dashboard'; -import { PostgreSQLService } from '../database/PostgreSQLService'; +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import { DashboardService } from "../services/DashboardService"; +import { + CreateDashboardRequest, + UpdateDashboardRequest, + DashboardListQuery, +} from "../types/dashboard"; +import { PostgreSQLService } from "../database/PostgreSQLService"; /** * 대시보드 컨트롤러 @@ -10,80 +14,91 @@ import { PostgreSQLService } from '../database/PostgreSQLService'; * - 요청 검증 및 응답 포맷팅 */ export class DashboardController { - /** * 대시보드 생성 * POST /api/dashboards */ - async createDashboard(req: AuthenticatedRequest, res: Response): Promise { + async createDashboard( + req: AuthenticatedRequest, + res: Response + ): Promise { try { const userId = req.user?.userId; if (!userId) { res.status(401).json({ success: false, - message: '인증이 필요합니다.' + message: "인증이 필요합니다.", }); return; } - - const { title, description, elements, isPublic = false, tags, category }: CreateDashboardRequest = req.body; - + + const { + title, + description, + elements, + isPublic = false, + tags, + category, + }: CreateDashboardRequest = req.body; + // 유효성 검증 if (!title || title.trim().length === 0) { res.status(400).json({ success: false, - message: '대시보드 제목이 필요합니다.' + message: "대시보드 제목이 필요합니다.", }); return; } - + if (!elements || !Array.isArray(elements)) { res.status(400).json({ success: false, - message: '대시보드 요소 데이터가 필요합니다.' + message: "대시보드 요소 데이터가 필요합니다.", }); return; } - + // 제목 길이 체크 if (title.length > 200) { res.status(400).json({ success: false, - message: '제목은 200자를 초과할 수 없습니다.' + message: "제목은 200자를 초과할 수 없습니다.", }); return; } - + // 설명 길이 체크 if (description && description.length > 1000) { res.status(400).json({ success: false, - message: '설명은 1000자를 초과할 수 없습니다.' + message: "설명은 1000자를 초과할 수 없습니다.", }); return; } - + const dashboardData: CreateDashboardRequest = { title: title.trim(), description: description?.trim(), isPublic, - elements, - tags, - category - }; - - // console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length }); - - const savedDashboard = await DashboardService.createDashboard(dashboardData, userId); - - // console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title }); - + elements, + tags, + category, + }; + + // console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length }); + + const savedDashboard = await DashboardService.createDashboard( + dashboardData, + userId + ); + + // console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title }); + res.status(201).json({ success: true, data: savedDashboard, - message: '대시보드가 성공적으로 생성되었습니다.' + message: "대시보드가 성공적으로 생성되었습니다.", }); - } catch (error: any) { // console.error('Dashboard creation error:', { // message: error?.message, @@ -92,12 +107,13 @@ export class DashboardController { // }); res.status(500).json({ success: false, - message: error?.message || '대시보드 생성 중 오류가 발생했습니다.', - error: process.env.NODE_ENV === 'development' ? error?.message : undefined + message: error?.message || "대시보드 생성 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" ? error?.message : undefined, }); } } - + /** * 대시보드 목록 조회 * GET /api/dashboards @@ -105,43 +121,50 @@ export class DashboardController { async getDashboards(req: AuthenticatedRequest, res: Response): Promise { try { const userId = req.user?.userId; - + const query: DashboardListQuery = { page: parseInt(req.query.page as string) || 1, limit: Math.min(parseInt(req.query.limit as string) || 20, 100), // 최대 100개 search: req.query.search as string, category: req.query.category as string, - isPublic: req.query.isPublic === 'true' ? true : req.query.isPublic === 'false' ? false : undefined, - createdBy: req.query.createdBy as string + isPublic: + req.query.isPublic === "true" + ? true + : req.query.isPublic === "false" + ? false + : undefined, + createdBy: req.query.createdBy as string, }; - + // 페이지 번호 유효성 검증 if (query.page! < 1) { res.status(400).json({ success: false, - message: '페이지 번호는 1 이상이어야 합니다.' + message: "페이지 번호는 1 이상이어야 합니다.", }); return; } - + const result = await DashboardService.getDashboards(query, userId); - + res.json({ success: true, data: result.dashboards, - pagination: result.pagination + pagination: result.pagination, }); - } catch (error) { // console.error('Dashboard list error:', error); res.status(500).json({ success: false, - message: '대시보드 목록 조회 중 오류가 발생했습니다.', - error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + message: "대시보드 목록 조회 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, }); } } - + /** * 대시보드 상세 조회 * GET /api/dashboards/:id @@ -150,222 +173,250 @@ export class DashboardController { try { const { id } = req.params; const userId = req.user?.userId; - + if (!id) { res.status(400).json({ success: false, - message: '대시보드 ID가 필요합니다.' + message: "대시보드 ID가 필요합니다.", }); return; } - + const dashboard = await DashboardService.getDashboardById(id, userId); - + if (!dashboard) { res.status(404).json({ success: false, - message: '대시보드를 찾을 수 없거나 접근 권한이 없습니다.' + message: "대시보드를 찾을 수 없거나 접근 권한이 없습니다.", }); return; } - + // 조회수 증가 (본인이 만든 대시보드가 아닌 경우에만) if (userId && dashboard.createdBy !== userId) { await DashboardService.incrementViewCount(id); } - + res.json({ success: true, - data: dashboard + data: dashboard, }); - } catch (error) { // console.error('Dashboard get error:', error); res.status(500).json({ success: false, - message: '대시보드 조회 중 오류가 발생했습니다.', - error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + message: "대시보드 조회 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, }); } } - + /** * 대시보드 수정 * PUT /api/dashboards/:id */ - async updateDashboard(req: AuthenticatedRequest, res: Response): Promise { + async updateDashboard( + req: AuthenticatedRequest, + res: Response + ): Promise { try { const { id } = req.params; const userId = req.user?.userId; - + if (!userId) { res.status(401).json({ success: false, - message: '인증이 필요합니다.' + message: "인증이 필요합니다.", }); return; } - + if (!id) { res.status(400).json({ success: false, - message: '대시보드 ID가 필요합니다.' + message: "대시보드 ID가 필요합니다.", }); return; } - + const updateData: UpdateDashboardRequest = req.body; - + // 유효성 검증 if (updateData.title !== undefined) { - if (typeof updateData.title !== 'string' || updateData.title.trim().length === 0) { + if ( + typeof updateData.title !== "string" || + updateData.title.trim().length === 0 + ) { res.status(400).json({ success: false, - message: '올바른 제목을 입력해주세요.' + message: "올바른 제목을 입력해주세요.", }); return; } if (updateData.title.length > 200) { res.status(400).json({ success: false, - message: '제목은 200자를 초과할 수 없습니다.' + message: "제목은 200자를 초과할 수 없습니다.", }); return; } updateData.title = updateData.title.trim(); } - - if (updateData.description !== undefined && updateData.description && updateData.description.length > 1000) { + + if ( + updateData.description !== undefined && + updateData.description && + updateData.description.length > 1000 + ) { res.status(400).json({ success: false, - message: '설명은 1000자를 초과할 수 없습니다.' + message: "설명은 1000자를 초과할 수 없습니다.", }); return; } - - const updatedDashboard = await DashboardService.updateDashboard(id, updateData, userId); - + + const updatedDashboard = await DashboardService.updateDashboard( + id, + updateData, + userId + ); + if (!updatedDashboard) { res.status(404).json({ success: false, - message: '대시보드를 찾을 수 없거나 수정 권한이 없습니다.' + message: "대시보드를 찾을 수 없거나 수정 권한이 없습니다.", }); return; } - + res.json({ success: true, data: updatedDashboard, - message: '대시보드가 성공적으로 수정되었습니다.' + message: "대시보드가 성공적으로 수정되었습니다.", }); - } catch (error) { // console.error('Dashboard update error:', error); - - if ((error as Error).message.includes('권한이 없습니다')) { + + if ((error as Error).message.includes("권한이 없습니다")) { res.status(403).json({ success: false, - message: (error as Error).message + message: (error as Error).message, }); return; } - + res.status(500).json({ success: false, - message: '대시보드 수정 중 오류가 발생했습니다.', - error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + message: "대시보드 수정 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, }); } } - + /** * 대시보드 삭제 * DELETE /api/dashboards/:id */ - async deleteDashboard(req: AuthenticatedRequest, res: Response): Promise { + async deleteDashboard( + req: AuthenticatedRequest, + res: Response + ): Promise { try { const { id } = req.params; const userId = req.user?.userId; - + if (!userId) { res.status(401).json({ success: false, - message: '인증이 필요합니다.' + message: "인증이 필요합니다.", }); return; } - + if (!id) { res.status(400).json({ success: false, - message: '대시보드 ID가 필요합니다.' + message: "대시보드 ID가 필요합니다.", }); return; } - + const deleted = await DashboardService.deleteDashboard(id, userId); - + if (!deleted) { res.status(404).json({ success: false, - message: '대시보드를 찾을 수 없거나 삭제 권한이 없습니다.' + message: "대시보드를 찾을 수 없거나 삭제 권한이 없습니다.", }); return; } - + res.json({ success: true, - message: '대시보드가 성공적으로 삭제되었습니다.' + message: "대시보드가 성공적으로 삭제되었습니다.", }); - } catch (error) { // console.error('Dashboard delete error:', error); res.status(500).json({ success: false, - message: '대시보드 삭제 중 오류가 발생했습니다.', - error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + message: "대시보드 삭제 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, }); } } - + /** * 내 대시보드 목록 조회 * GET /api/dashboards/my */ - async getMyDashboards(req: AuthenticatedRequest, res: Response): Promise { + async getMyDashboards( + req: AuthenticatedRequest, + res: Response + ): Promise { try { const userId = req.user?.userId; - + if (!userId) { res.status(401).json({ success: false, - message: '인증이 필요합니다.' + message: "인증이 필요합니다.", }); return; } - + const query: DashboardListQuery = { page: parseInt(req.query.page as string) || 1, limit: Math.min(parseInt(req.query.limit as string) || 20, 100), search: req.query.search as string, category: req.query.category as string, - createdBy: userId // 본인이 만든 대시보드만 + createdBy: userId, // 본인이 만든 대시보드만 }; - + const result = await DashboardService.getDashboards(query, userId); - + res.json({ success: true, data: result.dashboards, - pagination: result.pagination + pagination: result.pagination, }); - } catch (error) { // console.error('My dashboards error:', error); res.status(500).json({ success: false, - message: '내 대시보드 목록 조회 중 오류가 발생했습니다.', - error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined + message: "내 대시보드 목록 조회 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, }); } } @@ -387,31 +438,31 @@ export class DashboardController { // } const { query } = req.body; - + // 유효성 검증 - if (!query || typeof query !== 'string' || query.trim().length === 0) { + if (!query || typeof query !== "string" || query.trim().length === 0) { res.status(400).json({ success: false, - message: '쿼리가 필요합니다.' + message: "쿼리가 필요합니다.", }); return; } // SQL 인젝션 방지를 위한 기본적인 검증 const trimmedQuery = query.trim().toLowerCase(); - if (!trimmedQuery.startsWith('select')) { + if (!trimmedQuery.startsWith("select")) { res.status(400).json({ success: false, - message: 'SELECT 쿼리만 허용됩니다.' + message: "SELECT 쿼리만 허용됩니다.", }); return; } // 쿼리 실행 const result = await PostgreSQLService.query(query.trim()); - + // 결과 변환 - const columns = result.fields?.map(field => field.name) || []; + const columns = result.fields?.map((field) => field.name) || []; const rows = result.rows || []; res.status(200).json({ @@ -419,18 +470,81 @@ export class DashboardController { data: { columns, rows, - rowCount: rows.length + rowCount: rows.length, }, - message: '쿼리가 성공적으로 실행되었습니다.' + message: "쿼리가 성공적으로 실행되었습니다.", }); - } catch (error) { // console.error('Query execution error:', error); res.status(500).json({ success: false, - message: '쿼리 실행 중 오류가 발생했습니다.', - error: process.env.NODE_ENV === 'development' ? (error as Error).message : '쿼리 실행 오류' + message: "쿼리 실행 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : "쿼리 실행 오류", }); } } -} \ No newline at end of file + + /** + * 외부 API 프록시 (CORS 우회용) + * POST /api/dashboards/fetch-external-api + */ + async fetchExternalApi( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { url, method = "GET", headers = {}, queryParams = {} } = req.body; + + if (!url || typeof url !== "string") { + res.status(400).json({ + success: false, + message: "URL이 필요합니다.", + }); + return; + } + + // 쿼리 파라미터 추가 + const urlObj = new URL(url); + Object.entries(queryParams).forEach(([key, value]) => { + if (key && value) { + urlObj.searchParams.append(key, String(value)); + } + }); + + // 외부 API 호출 + const fetch = (await import("node-fetch")).default; + const response = await fetch(urlObj.toString(), { + method: method.toUpperCase(), + headers: { + "Content-Type": "application/json", + ...headers, + }, + }); + + if (!response.ok) { + throw new Error( + `외부 API 오류: ${response.status} ${response.statusText}` + ); + } + + const data = await response.json(); + + res.status(200).json({ + success: true, + data, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: "외부 API 호출 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : "외부 API 호출 오류", + }); + } + } +} diff --git a/backend-node/src/controllers/bookingController.ts b/backend-node/src/controllers/bookingController.ts new file mode 100644 index 00000000..b4a1a0bd --- /dev/null +++ b/backend-node/src/controllers/bookingController.ts @@ -0,0 +1,80 @@ +import { Request, Response } from "express"; +import { BookingService } from "../services/bookingService"; +import { logger } from "../utils/logger"; + +const bookingService = BookingService.getInstance(); + +/** + * 모든 예약 조회 + */ +export const getBookings = async (req: Request, res: Response): Promise => { + try { + const { status, priority } = req.query; + + const result = await bookingService.getAllBookings({ + status: status as string, + priority: priority as string, + }); + + res.status(200).json({ + success: true, + data: result.bookings, + newCount: result.newCount, + }); + } catch (error) { + logger.error("❌ 예약 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "예약 목록 조회에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** + * 예약 수락 + */ +export const acceptBooking = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const booking = await bookingService.acceptBooking(id); + + res.status(200).json({ + success: true, + data: booking, + message: "예약이 수락되었습니다.", + }); + } catch (error) { + logger.error("❌ 예약 수락 실패:", error); + res.status(500).json({ + success: false, + message: "예약 수락에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** + * 예약 거절 + */ +export const rejectBooking = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const { reason } = req.body; + const booking = await bookingService.rejectBooking(id, reason); + + res.status(200).json({ + success: true, + data: booking, + message: "예약이 거절되었습니다.", + }); + } catch (error) { + logger.error("❌ 예약 거절 실패:", error); + res.status(500).json({ + success: false, + message: "예약 거절에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + diff --git a/backend-node/src/controllers/deliveryController.ts b/backend-node/src/controllers/deliveryController.ts new file mode 100644 index 00000000..1ce50dcc --- /dev/null +++ b/backend-node/src/controllers/deliveryController.ts @@ -0,0 +1,116 @@ +/** + * 배송/화물 관리 컨트롤러 + */ + +import { Request, Response } from 'express'; +import * as deliveryService from '../services/deliveryService'; + +/** + * GET /api/delivery/status + * 배송 현황 조회 + */ +export async function getDeliveryStatus(req: Request, res: Response): Promise { + try { + const data = await deliveryService.getDeliveryStatus(); + res.json({ + success: true, + data, + }); + } catch (error) { + console.error('배송 현황 조회 실패:', error); + res.status(500).json({ + success: false, + message: '배송 현황 조회에 실패했습니다.', + }); + } +} + +/** + * GET /api/delivery/delayed + * 지연 배송 목록 조회 + */ +export async function getDelayedDeliveries(req: Request, res: Response): Promise { + try { + const deliveries = await deliveryService.getDelayedDeliveries(); + res.json({ + success: true, + data: deliveries, + }); + } catch (error) { + console.error('지연 배송 조회 실패:', error); + res.status(500).json({ + success: false, + message: '지연 배송 조회에 실패했습니다.', + }); + } +} + +/** + * GET /api/delivery/issues + * 고객 이슈 목록 조회 + */ +export async function getCustomerIssues(req: Request, res: Response): Promise { + try { + const { status } = req.query; + const issues = await deliveryService.getCustomerIssues(status as string); + res.json({ + success: true, + data: issues, + }); + } catch (error) { + console.error('고객 이슈 조회 실패:', error); + res.status(500).json({ + success: false, + message: '고객 이슈 조회에 실패했습니다.', + }); + } +} + +/** + * PUT /api/delivery/:id/status + * 배송 상태 업데이트 + */ +export async function updateDeliveryStatus(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const { status, delayReason } = req.body; + + await deliveryService.updateDeliveryStatus(id, status, delayReason); + + res.json({ + success: true, + message: '배송 상태가 업데이트되었습니다.', + }); + } catch (error) { + console.error('배송 상태 업데이트 실패:', error); + res.status(500).json({ + success: false, + message: '배송 상태 업데이트에 실패했습니다.', + }); + } +} + +/** + * PUT /api/delivery/issues/:id/status + * 고객 이슈 상태 업데이트 + */ +export async function updateIssueStatus(req: Request, res: Response): Promise { + try { + const { id } = req.params; + const { status } = req.body; + + await deliveryService.updateIssueStatus(id, status); + + res.json({ + success: true, + message: '이슈 상태가 업데이트되었습니다.', + }); + } catch (error) { + console.error('이슈 상태 업데이트 실패:', error); + res.status(500).json({ + success: false, + message: '이슈 상태 업데이트에 실패했습니다.', + }); + } +} + diff --git a/backend-node/src/controllers/mapDataController.ts b/backend-node/src/controllers/mapDataController.ts new file mode 100644 index 00000000..c5354a24 --- /dev/null +++ b/backend-node/src/controllers/mapDataController.ts @@ -0,0 +1,137 @@ +import { Request, Response } from "express"; +import { MapDataService } from "../services/mapDataService"; +import { logger } from "../utils/logger"; + +/** + * 지도 데이터 조회 컨트롤러 + * 외부 DB 연결에서 위도/경도 데이터를 가져와 지도에 표시할 수 있도록 변환 + */ +export class MapDataController { + private mapDataService: MapDataService; + + constructor() { + this.mapDataService = new MapDataService(); + } + + /** + * 외부 DB에서 지도 데이터 조회 + */ + getMapData = async (req: Request, res: Response): Promise => { + try { + const { connectionId } = req.params; + const { + tableName, + latColumn, + lngColumn, + labelColumn, + statusColumn, + additionalColumns, + whereClause, + } = req.query; + + logger.info("🗺️ 지도 데이터 조회 요청:", { + connectionId, + tableName, + latColumn, + lngColumn, + }); + + // 필수 파라미터 검증 + if (!tableName || !latColumn || !lngColumn) { + res.status(400).json({ + success: false, + message: "tableName, latColumn, lngColumn은 필수입니다.", + }); + return; + } + + const markers = await this.mapDataService.getMapData({ + connectionId: parseInt(connectionId as string), + tableName: tableName as string, + latColumn: latColumn as string, + lngColumn: lngColumn as string, + labelColumn: labelColumn as string, + statusColumn: statusColumn as string, + additionalColumns: additionalColumns + ? (additionalColumns as string).split(",") + : [], + whereClause: whereClause as string, + }); + + res.json({ + success: true, + data: { + markers, + count: markers.length, + }, + }); + } catch (error: any) { + logger.error("❌ 지도 데이터 조회 오류:", error); + res.status(500).json({ + success: false, + message: "지도 데이터 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + }; + + /** + * 내부 DB에서 지도 데이터 조회 + */ + getInternalMapData = async (req: Request, res: Response): Promise => { + try { + const { + tableName, + latColumn, + lngColumn, + labelColumn, + statusColumn, + additionalColumns, + whereClause, + } = req.query; + + logger.info("🗺️ 내부 DB 지도 데이터 조회 요청:", { + tableName, + latColumn, + lngColumn, + }); + + // 필수 파라미터 검증 + if (!tableName || !latColumn || !lngColumn) { + res.status(400).json({ + success: false, + message: "tableName, latColumn, lngColumn은 필수입니다.", + }); + return; + } + + const markers = await this.mapDataService.getInternalMapData({ + tableName: tableName as string, + latColumn: latColumn as string, + lngColumn: lngColumn as string, + labelColumn: labelColumn as string, + statusColumn: statusColumn as string, + additionalColumns: additionalColumns + ? (additionalColumns as string).split(",") + : [], + whereClause: whereClause as string, + }); + + res.json({ + success: true, + data: { + markers, + count: markers.length, + }, + }); + } catch (error: any) { + logger.error("❌ 내부 DB 지도 데이터 조회 오류:", error); + res.status(500).json({ + success: false, + message: "지도 데이터 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } + }; +} + diff --git a/backend-node/src/controllers/riskAlertController.ts b/backend-node/src/controllers/riskAlertController.ts new file mode 100644 index 00000000..629e30f2 --- /dev/null +++ b/backend-node/src/controllers/riskAlertController.ts @@ -0,0 +1,124 @@ +/** + * 리스크/알림 컨트롤러 + */ + +import { Request, Response } from 'express'; +import { RiskAlertService } from '../services/riskAlertService'; +import { RiskAlertCacheService } from '../services/riskAlertCacheService'; + +const riskAlertService = new RiskAlertService(); +const cacheService = RiskAlertCacheService.getInstance(); + +export class RiskAlertController { + /** + * 전체 알림 조회 (캐시된 데이터 - 빠름!) + * GET /api/risk-alerts + */ + async getAllAlerts(req: Request, res: Response): Promise { + try { + const { alerts, lastUpdated } = cacheService.getCachedAlerts(); + + res.json({ + success: true, + data: alerts, + count: alerts.length, + lastUpdated: lastUpdated, + cached: true, + }); + } catch (error: any) { + console.error('❌ 전체 알림 조회 오류:', error.message); + res.status(500).json({ + success: false, + message: '알림 조회 중 오류가 발생했습니다.', + error: error.message, + }); + } + } + + /** + * 전체 알림 강제 갱신 (실시간 조회) + * POST /api/risk-alerts/refresh + */ + async refreshAlerts(req: Request, res: Response): Promise { + try { + const alerts = await cacheService.forceRefresh(); + + res.json({ + success: true, + data: alerts, + count: alerts.length, + message: '알림이 갱신되었습니다.', + }); + } catch (error: any) { + console.error('❌ 알림 갱신 오류:', error.message); + res.status(500).json({ + success: false, + message: '알림 갱신 중 오류가 발생했습니다.', + error: error.message, + }); + } + } + + /** + * 날씨 특보 조회 + * GET /api/risk-alerts/weather + */ + async getWeatherAlerts(req: Request, res: Response): Promise { + try { + const alerts = await riskAlertService.getWeatherAlerts(); + + // 프론트엔드 직접 호출용: alerts 배열만 반환 + res.json(alerts); + } catch (error: any) { + console.error('❌ 날씨 특보 조회 오류:', error.message); + res.status(500).json([]); + } + } + + /** + * 교통사고 조회 + * GET /api/risk-alerts/accidents + */ + async getAccidentAlerts(req: Request, res: Response): Promise { + try { + const alerts = await riskAlertService.getAccidentAlerts(); + + res.json({ + success: true, + data: alerts, + count: alerts.length, + }); + } catch (error: any) { + console.error('❌ 교통사고 조회 오류:', error.message); + res.status(500).json({ + success: false, + message: '교통사고 조회 중 오류가 발생했습니다.', + error: error.message, + }); + } + } + + /** + * 도로공사 조회 + * GET /api/risk-alerts/roadworks + */ + async getRoadworkAlerts(req: Request, res: Response): Promise { + try { + const alerts = await riskAlertService.getRoadworkAlerts(); + + res.json({ + success: true, + data: alerts, + count: alerts.length, + }); + } catch (error: any) { + console.error('❌ 도로공사 조회 오류:', error.message); + res.status(500).json({ + success: false, + message: '도로공사 조회 중 오류가 발생했습니다.', + error: error.message, + }); + } + } +} + diff --git a/backend-node/src/controllers/todoController.ts b/backend-node/src/controllers/todoController.ts new file mode 100644 index 00000000..4dc88113 --- /dev/null +++ b/backend-node/src/controllers/todoController.ts @@ -0,0 +1,132 @@ +import { Request, Response } from "express"; +import { TodoService } from "../services/todoService"; +import { logger } from "../utils/logger"; + +const todoService = TodoService.getInstance(); + +/** + * 모든 To-Do 항목 조회 + */ +export const getTodos = async (req: Request, res: Response): Promise => { + try { + const { status, priority, assignedTo } = req.query; + + const result = await todoService.getAllTodos({ + status: status as string, + priority: priority as string, + assignedTo: assignedTo as string, + }); + + res.status(200).json({ + success: true, + data: result.todos, + stats: result.stats, + }); + } catch (error) { + logger.error("❌ To-Do 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: "To-Do 목록 조회에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** + * To-Do 항목 생성 + */ +export const createTodo = async (req: Request, res: Response): Promise => { + try { + const newTodo = await todoService.createTodo(req.body); + + res.status(201).json({ + success: true, + data: newTodo, + message: "To-Do가 생성되었습니다.", + }); + } catch (error) { + logger.error("❌ To-Do 생성 실패:", error); + res.status(500).json({ + success: false, + message: "To-Do 생성에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** + * To-Do 항목 수정 + */ +export const updateTodo = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const updatedTodo = await todoService.updateTodo(id, req.body); + + res.status(200).json({ + success: true, + data: updatedTodo, + message: "To-Do가 수정되었습니다.", + }); + } catch (error) { + logger.error("❌ To-Do 수정 실패:", error); + res.status(500).json({ + success: false, + message: "To-Do 수정에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** + * To-Do 항목 삭제 + */ +export const deleteTodo = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + await todoService.deleteTodo(id); + + res.status(200).json({ + success: true, + message: "To-Do가 삭제되었습니다.", + }); + } catch (error) { + logger.error("❌ To-Do 삭제 실패:", error); + res.status(500).json({ + success: false, + message: "To-Do 삭제에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + +/** + * To-Do 항목 순서 변경 + */ +export const reorderTodos = async (req: Request, res: Response): Promise => { + try { + const { todoIds } = req.body; + + if (!Array.isArray(todoIds)) { + res.status(400).json({ + success: false, + message: "todoIds는 배열이어야 합니다.", + }); + return; + } + + await todoService.reorderTodos(todoIds); + + res.status(200).json({ + success: true, + message: "To-Do 순서가 변경되었습니다.", + }); + } catch (error) { + logger.error("❌ To-Do 순서 변경 실패:", error); + res.status(500).json({ + success: false, + message: "To-Do 순서 변경에 실패했습니다.", + error: error instanceof Error ? error.message : String(error), + }); + } +}; + diff --git a/backend-node/src/routes/bookingRoutes.ts b/backend-node/src/routes/bookingRoutes.ts new file mode 100644 index 00000000..d931ab75 --- /dev/null +++ b/backend-node/src/routes/bookingRoutes.ts @@ -0,0 +1,20 @@ +import { Router } from "express"; +import * as bookingController from "../controllers/bookingController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// 예약 목록 조회 +router.get("/", bookingController.getBookings); + +// 예약 수락 +router.post("/:id/accept", bookingController.acceptBooking); + +// 예약 거절 +router.post("/:id/reject", bookingController.rejectBooking); + +export default router; + diff --git a/backend-node/src/routes/dashboardRoutes.ts b/backend-node/src/routes/dashboardRoutes.ts index e6b5714d..7ed7d634 100644 --- a/backend-node/src/routes/dashboardRoutes.ts +++ b/backend-node/src/routes/dashboardRoutes.ts @@ -1,37 +1,61 @@ -import { Router } from 'express'; -import { DashboardController } from '../controllers/DashboardController'; -import { authenticateToken } from '../middleware/authMiddleware'; +import { Router } from "express"; +import { DashboardController } from "../controllers/DashboardController"; +import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); const dashboardController = new DashboardController(); /** * 대시보드 API 라우트 - * + * * 모든 엔드포인트는 인증이 필요하지만, * 공개 대시보드 조회는 인증 없이도 가능 */ // 공개 대시보드 목록 조회 (인증 불필요) -router.get('/public', dashboardController.getDashboards.bind(dashboardController)); +router.get( + "/public", + dashboardController.getDashboards.bind(dashboardController) +); // 공개 대시보드 상세 조회 (인증 불필요) -router.get('/public/:id', dashboardController.getDashboard.bind(dashboardController)); +router.get( + "/public/:id", + dashboardController.getDashboard.bind(dashboardController) +); // 쿼리 실행 (인증 불필요 - 개발용) -router.post('/execute-query', dashboardController.executeQuery.bind(dashboardController)); +router.post( + "/execute-query", + dashboardController.executeQuery.bind(dashboardController) +); + +// 외부 API 프록시 (CORS 우회) +router.post( + "/fetch-external-api", + dashboardController.fetchExternalApi.bind(dashboardController) +); // 인증이 필요한 라우트들 router.use(authenticateToken); // 내 대시보드 목록 조회 -router.get('/my', dashboardController.getMyDashboards.bind(dashboardController)); +router.get( + "/my", + dashboardController.getMyDashboards.bind(dashboardController) +); // 대시보드 CRUD -router.post('/', dashboardController.createDashboard.bind(dashboardController)); -router.get('/', dashboardController.getDashboards.bind(dashboardController)); -router.get('/:id', dashboardController.getDashboard.bind(dashboardController)); -router.put('/:id', dashboardController.updateDashboard.bind(dashboardController)); -router.delete('/:id', dashboardController.deleteDashboard.bind(dashboardController)); +router.post("/", dashboardController.createDashboard.bind(dashboardController)); +router.get("/", dashboardController.getDashboards.bind(dashboardController)); +router.get("/:id", dashboardController.getDashboard.bind(dashboardController)); +router.put( + "/:id", + dashboardController.updateDashboard.bind(dashboardController) +); +router.delete( + "/:id", + dashboardController.deleteDashboard.bind(dashboardController) +); export default router; diff --git a/backend-node/src/routes/deliveryRoutes.ts b/backend-node/src/routes/deliveryRoutes.ts new file mode 100644 index 00000000..a8940bd0 --- /dev/null +++ b/backend-node/src/routes/deliveryRoutes.ts @@ -0,0 +1,46 @@ +/** + * 배송/화물 관리 라우트 + */ + +import express from 'express'; +import * as deliveryController from '../controllers/deliveryController'; +import { authenticateToken } from '../middleware/authMiddleware'; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * GET /api/delivery/status + * 배송 현황 조회 (배송 목록 + 이슈 + 오늘 통계) + */ +router.get('/status', deliveryController.getDeliveryStatus); + +/** + * GET /api/delivery/delayed + * 지연 배송 목록 조회 + */ +router.get('/delayed', deliveryController.getDelayedDeliveries); + +/** + * GET /api/delivery/issues + * 고객 이슈 목록 조회 + * Query: status (optional) + */ +router.get('/issues', deliveryController.getCustomerIssues); + +/** + * PUT /api/delivery/:id/status + * 배송 상태 업데이트 + */ +router.put('/:id/status', deliveryController.updateDeliveryStatus); + +/** + * PUT /api/delivery/issues/:id/status + * 고객 이슈 상태 업데이트 + */ +router.put('/issues/:id/status', deliveryController.updateIssueStatus); + +export default router; + diff --git a/backend-node/src/routes/mapDataRoutes.ts b/backend-node/src/routes/mapDataRoutes.ts new file mode 100644 index 00000000..43767311 --- /dev/null +++ b/backend-node/src/routes/mapDataRoutes.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; +import { MapDataController } from "../controllers/mapDataController"; + +const router = Router(); +const mapDataController = new MapDataController(); + +/** + * 지도 데이터 라우트 + */ + +// 외부 DB 지도 데이터 조회 +router.get("/external/:connectionId", mapDataController.getMapData); + +// 내부 DB 지도 데이터 조회 +router.get("/internal", mapDataController.getInternalMapData); + +export default router; + diff --git a/backend-node/src/routes/riskAlertRoutes.ts b/backend-node/src/routes/riskAlertRoutes.ts new file mode 100644 index 00000000..2037a554 --- /dev/null +++ b/backend-node/src/routes/riskAlertRoutes.ts @@ -0,0 +1,28 @@ +/** + * 리스크/알림 라우터 + */ + +import { Router } from 'express'; +import { RiskAlertController } from '../controllers/riskAlertController'; +import { authenticateToken } from '../middleware/authMiddleware'; + +const router = Router(); +const riskAlertController = new RiskAlertController(); + +// 전체 알림 조회 (캐시된 데이터) +router.get('/', authenticateToken, (req, res) => riskAlertController.getAllAlerts(req, res)); + +// 알림 강제 갱신 +router.post('/refresh', authenticateToken, (req, res) => riskAlertController.refreshAlerts(req, res)); + +// 날씨 특보 조회 +router.get('/weather', authenticateToken, (req, res) => riskAlertController.getWeatherAlerts(req, res)); + +// 교통사고 조회 +router.get('/accidents', authenticateToken, (req, res) => riskAlertController.getAccidentAlerts(req, res)); + +// 도로공사 조회 +router.get('/roadworks', authenticateToken, (req, res) => riskAlertController.getRoadworkAlerts(req, res)); + +export default router; + diff --git a/backend-node/src/routes/todoRoutes.ts b/backend-node/src/routes/todoRoutes.ts new file mode 100644 index 00000000..d18c905b --- /dev/null +++ b/backend-node/src/routes/todoRoutes.ts @@ -0,0 +1,26 @@ +import { Router } from "express"; +import * as todoController from "../controllers/todoController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +// To-Do 목록 조회 +router.get("/", todoController.getTodos); + +// To-Do 생성 +router.post("/", todoController.createTodo); + +// To-Do 수정 +router.put("/:id", todoController.updateTodo); + +// To-Do 삭제 +router.delete("/:id", todoController.deleteTodo); + +// To-Do 순서 변경 +router.post("/reorder", todoController.reorderTodos); + +export default router; + diff --git a/backend-node/src/services/bookingService.ts b/backend-node/src/services/bookingService.ts new file mode 100644 index 00000000..79935414 --- /dev/null +++ b/backend-node/src/services/bookingService.ts @@ -0,0 +1,334 @@ +import * as fs from "fs"; +import * as path from "path"; +import { v4 as uuidv4 } from "uuid"; +import { logger } from "../utils/logger"; +import { query } from "../database/db"; + +const BOOKING_DIR = path.join(__dirname, "../../data/bookings"); +const BOOKING_FILE = path.join(BOOKING_DIR, "bookings.json"); + +// 환경 변수로 데이터 소스 선택 +const DATA_SOURCE = process.env.BOOKING_DATA_SOURCE || "file"; + +export interface BookingRequest { + id: string; + customerName: string; + customerPhone: string; + pickupLocation: string; + dropoffLocation: string; + scheduledTime: string; + vehicleType: "truck" | "van" | "car"; + cargoType?: string; + weight?: number; + status: "pending" | "accepted" | "rejected" | "completed"; + priority: "normal" | "urgent"; + createdAt: string; + updatedAt: string; + acceptedAt?: string; + rejectedAt?: string; + completedAt?: string; + notes?: string; + estimatedCost?: number; +} + +/** + * 예약 요청 관리 서비스 (File/DB 하이브리드) + */ +export class BookingService { + private static instance: BookingService; + + private constructor() { + if (DATA_SOURCE === "file") { + this.ensureDataDirectory(); + this.generateMockData(); + } + logger.info(`📋 예약 요청 데이터 소스: ${DATA_SOURCE.toUpperCase()}`); + } + + public static getInstance(): BookingService { + if (!BookingService.instance) { + BookingService.instance = new BookingService(); + } + return BookingService.instance; + } + + private ensureDataDirectory(): void { + if (!fs.existsSync(BOOKING_DIR)) { + fs.mkdirSync(BOOKING_DIR, { recursive: true }); + logger.info(`📁 예약 데이터 디렉토리 생성: ${BOOKING_DIR}`); + } + if (!fs.existsSync(BOOKING_FILE)) { + fs.writeFileSync(BOOKING_FILE, JSON.stringify([], null, 2)); + logger.info(`📄 예약 파일 생성: ${BOOKING_FILE}`); + } + } + + private generateMockData(): void { + const bookings = this.loadBookingsFromFile(); + if (bookings.length > 0) return; + + const mockBookings: BookingRequest[] = [ + { + id: uuidv4(), + customerName: "김철수", + customerPhone: "010-1234-5678", + pickupLocation: "서울시 강남구 역삼동 123", + dropoffLocation: "경기도 성남시 분당구 정자동 456", + scheduledTime: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), + vehicleType: "truck", + cargoType: "전자제품", + weight: 500, + status: "pending", + priority: "urgent", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + estimatedCost: 150000, + }, + { + id: uuidv4(), + customerName: "이영희", + customerPhone: "010-9876-5432", + pickupLocation: "서울시 송파구 잠실동 789", + dropoffLocation: "인천시 남동구 구월동 321", + scheduledTime: new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(), + vehicleType: "van", + cargoType: "가구", + weight: 300, + status: "pending", + priority: "normal", + createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + estimatedCost: 80000, + }, + ]; + + this.saveBookingsToFile(mockBookings); + logger.info(`✅ 예약 목 데이터 생성: ${mockBookings.length}개`); + } + + public async getAllBookings(filter?: { + status?: string; + priority?: string; + }): Promise<{ bookings: BookingRequest[]; newCount: number }> { + try { + const bookings = DATA_SOURCE === "database" + ? await this.loadBookingsFromDB(filter) + : this.loadBookingsFromFile(filter); + + bookings.sort((a, b) => { + if (a.priority !== b.priority) return a.priority === "urgent" ? -1 : 1; + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + const newCount = bookings.filter( + (b) => b.status === "pending" && new Date(b.createdAt) > fiveMinutesAgo + ).length; + + return { bookings, newCount }; + } catch (error) { + logger.error("❌ 예약 목록 조회 오류:", error); + throw error; + } + } + + public async acceptBooking(id: string): Promise { + try { + if (DATA_SOURCE === "database") { + return await this.acceptBookingDB(id); + } else { + return this.acceptBookingFile(id); + } + } catch (error) { + logger.error("❌ 예약 수락 오류:", error); + throw error; + } + } + + public async rejectBooking(id: string, reason?: string): Promise { + try { + if (DATA_SOURCE === "database") { + return await this.rejectBookingDB(id, reason); + } else { + return this.rejectBookingFile(id, reason); + } + } catch (error) { + logger.error("❌ 예약 거절 오류:", error); + throw error; + } + } + + // ==================== DATABASE 메서드 ==================== + + private async loadBookingsFromDB(filter?: { + status?: string; + priority?: string; + }): Promise { + let sql = ` + SELECT + id, customer_name as "customerName", customer_phone as "customerPhone", + pickup_location as "pickupLocation", dropoff_location as "dropoffLocation", + scheduled_time as "scheduledTime", vehicle_type as "vehicleType", + cargo_type as "cargoType", weight, status, priority, + created_at as "createdAt", updated_at as "updatedAt", + accepted_at as "acceptedAt", rejected_at as "rejectedAt", + completed_at as "completedAt", notes, estimated_cost as "estimatedCost" + FROM booking_requests + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (filter?.status) { + sql += ` AND status = $${paramIndex++}`; + params.push(filter.status); + } + if (filter?.priority) { + sql += ` AND priority = $${paramIndex++}`; + params.push(filter.priority); + } + + const rows = await query(sql, params); + return rows.map((row: any) => ({ + ...row, + scheduledTime: new Date(row.scheduledTime).toISOString(), + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + acceptedAt: row.acceptedAt ? new Date(row.acceptedAt).toISOString() : undefined, + rejectedAt: row.rejectedAt ? new Date(row.rejectedAt).toISOString() : undefined, + completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + })); + } + + private async acceptBookingDB(id: string): Promise { + const rows = await query( + `UPDATE booking_requests + SET status = 'accepted', accepted_at = NOW(), updated_at = NOW() + WHERE id = $1 + RETURNING + id, customer_name as "customerName", customer_phone as "customerPhone", + pickup_location as "pickupLocation", dropoff_location as "dropoffLocation", + scheduled_time as "scheduledTime", vehicle_type as "vehicleType", + cargo_type as "cargoType", weight, status, priority, + created_at as "createdAt", updated_at as "updatedAt", + accepted_at as "acceptedAt", notes, estimated_cost as "estimatedCost"`, + [id] + ); + + if (rows.length === 0) { + throw new Error(`예약을 찾을 수 없습니다: ${id}`); + } + + const row = rows[0]; + logger.info(`✅ 예약 수락: ${id} - ${row.customerName}`); + return { + ...row, + scheduledTime: new Date(row.scheduledTime).toISOString(), + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + acceptedAt: new Date(row.acceptedAt).toISOString(), + }; + } + + private async rejectBookingDB(id: string, reason?: string): Promise { + const rows = await query( + `UPDATE booking_requests + SET status = 'rejected', rejected_at = NOW(), updated_at = NOW(), rejection_reason = $2 + WHERE id = $1 + RETURNING + id, customer_name as "customerName", customer_phone as "customerPhone", + pickup_location as "pickupLocation", dropoff_location as "dropoffLocation", + scheduled_time as "scheduledTime", vehicle_type as "vehicleType", + cargo_type as "cargoType", weight, status, priority, + created_at as "createdAt", updated_at as "updatedAt", + rejected_at as "rejectedAt", notes, estimated_cost as "estimatedCost"`, + [id, reason] + ); + + if (rows.length === 0) { + throw new Error(`예약을 찾을 수 없습니다: ${id}`); + } + + const row = rows[0]; + logger.info(`✅ 예약 거절: ${id} - ${row.customerName}`); + return { + ...row, + scheduledTime: new Date(row.scheduledTime).toISOString(), + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + rejectedAt: new Date(row.rejectedAt).toISOString(), + }; + } + + // ==================== FILE 메서드 ==================== + + private loadBookingsFromFile(filter?: { + status?: string; + priority?: string; + }): BookingRequest[] { + try { + const data = fs.readFileSync(BOOKING_FILE, "utf-8"); + let bookings: BookingRequest[] = JSON.parse(data); + + if (filter?.status) { + bookings = bookings.filter((b) => b.status === filter.status); + } + if (filter?.priority) { + bookings = bookings.filter((b) => b.priority === filter.priority); + } + + return bookings; + } catch (error) { + logger.error("❌ 예약 파일 로드 오류:", error); + return []; + } + } + + private saveBookingsToFile(bookings: BookingRequest[]): void { + try { + fs.writeFileSync(BOOKING_FILE, JSON.stringify(bookings, null, 2)); + } catch (error) { + logger.error("❌ 예약 파일 저장 오류:", error); + throw error; + } + } + + private acceptBookingFile(id: string): BookingRequest { + const bookings = this.loadBookingsFromFile(); + const booking = bookings.find((b) => b.id === id); + + if (!booking) { + throw new Error(`예약을 찾을 수 없습니다: ${id}`); + } + + booking.status = "accepted"; + booking.acceptedAt = new Date().toISOString(); + booking.updatedAt = new Date().toISOString(); + + this.saveBookingsToFile(bookings); + logger.info(`✅ 예약 수락: ${id} - ${booking.customerName}`); + + return booking; + } + + private rejectBookingFile(id: string, reason?: string): BookingRequest { + const bookings = this.loadBookingsFromFile(); + const booking = bookings.find((b) => b.id === id); + + if (!booking) { + throw new Error(`예약을 찾을 수 없습니다: ${id}`); + } + + booking.status = "rejected"; + booking.rejectedAt = new Date().toISOString(); + booking.updatedAt = new Date().toISOString(); + if (reason) { + booking.notes = reason; + } + + this.saveBookingsToFile(bookings); + logger.info(`✅ 예약 거절: ${id} - ${booking.customerName}`); + + return booking; + } +} diff --git a/backend-node/src/services/deliveryService.ts b/backend-node/src/services/deliveryService.ts new file mode 100644 index 00000000..6cbf7416 --- /dev/null +++ b/backend-node/src/services/deliveryService.ts @@ -0,0 +1,186 @@ +/** + * 배송/화물 관리 서비스 + * + * 실제 데이터베이스 연동 시 필요한 메서드들을 미리 정의 + */ + +import pool from '../database/db'; + +export interface DeliveryItem { + id: string; + trackingNumber: string; + customer: string; + origin: string; + destination: string; + status: 'in_transit' | 'delivered' | 'delayed' | 'pickup_waiting'; + estimatedDelivery: string; + delayReason?: string; + priority: 'high' | 'normal' | 'low'; +} + +export interface CustomerIssue { + id: string; + customer: string; + trackingNumber: string; + issueType: 'damage' | 'delay' | 'missing' | 'other'; + description: string; + status: 'open' | 'in_progress' | 'resolved'; + reportedAt: string; +} + +export interface TodayStats { + shipped: number; + delivered: number; +} + +export interface DeliveryStatusResponse { + deliveries: DeliveryItem[]; + issues: CustomerIssue[]; + todayStats: TodayStats; +} + +/** + * 배송 현황 조회 + * + * TODO: 실제 DB 연동 시 구현 필요 + * - 테이블명: deliveries (배송 정보) + * - 테이블명: customer_issues (고객 이슈) + * + * 예상 쿼리: + * SELECT * FROM deliveries WHERE DATE(created_at) = CURRENT_DATE + * SELECT * FROM customer_issues WHERE status != 'resolved' ORDER BY reported_at DESC + */ +export async function getDeliveryStatus(): Promise { + try { + // TODO: 실제 DB 쿼리로 교체 + // const deliveriesResult = await pool.query( + // `SELECT + // id, tracking_number as "trackingNumber", customer, origin, destination, + // status, estimated_delivery as "estimatedDelivery", delay_reason as "delayReason", + // priority + // FROM deliveries + // WHERE deleted_at IS NULL + // ORDER BY created_at DESC` + // ); + + // const issuesResult = await pool.query( + // `SELECT + // id, customer, tracking_number as "trackingNumber", issue_type as "issueType", + // description, status, reported_at as "reportedAt" + // FROM customer_issues + // WHERE deleted_at IS NULL + // ORDER BY reported_at DESC` + // ); + + // const statsResult = await pool.query( + // `SELECT + // COUNT(*) FILTER (WHERE status = 'in_transit') as shipped, + // COUNT(*) FILTER (WHERE status = 'delivered') as delivered + // FROM deliveries + // WHERE DATE(created_at) = CURRENT_DATE + // AND deleted_at IS NULL` + // ); + + // 임시 응답 (개발용) + return { + deliveries: [], + issues: [], + todayStats: { + shipped: 0, + delivered: 0, + }, + }; + } catch (error) { + console.error('배송 현황 조회 실패:', error); + throw error; + } +} + +/** + * 지연 배송 목록 조회 + */ +export async function getDelayedDeliveries(): Promise { + try { + // TODO: 실제 DB 쿼리로 교체 + // const result = await pool.query( + // `SELECT * FROM deliveries + // WHERE status = 'delayed' + // AND deleted_at IS NULL + // ORDER BY estimated_delivery ASC` + // ); + + return []; + } catch (error) { + console.error('지연 배송 조회 실패:', error); + throw error; + } +} + +/** + * 고객 이슈 목록 조회 + */ +export async function getCustomerIssues(status?: string): Promise { + try { + // TODO: 실제 DB 쿼리로 교체 + // const query = status + // ? `SELECT * FROM customer_issues WHERE status = $1 AND deleted_at IS NULL ORDER BY reported_at DESC` + // : `SELECT * FROM customer_issues WHERE deleted_at IS NULL ORDER BY reported_at DESC`; + + // const result = status + // ? await pool.query(query, [status]) + // : await pool.query(query); + + return []; + } catch (error) { + console.error('고객 이슈 조회 실패:', error); + throw error; + } +} + +/** + * 배송 정보 업데이트 + */ +export async function updateDeliveryStatus( + id: string, + status: DeliveryItem['status'], + delayReason?: string +): Promise { + try { + // TODO: 실제 DB 쿼리로 교체 + // await pool.query( + // `UPDATE deliveries + // SET status = $1, delay_reason = $2, updated_at = NOW() + // WHERE id = $3`, + // [status, delayReason, id] + // ); + + console.log(`배송 상태 업데이트: ${id} -> ${status}`); + } catch (error) { + console.error('배송 상태 업데이트 실패:', error); + throw error; + } +} + +/** + * 고객 이슈 상태 업데이트 + */ +export async function updateIssueStatus( + id: string, + status: CustomerIssue['status'] +): Promise { + try { + // TODO: 실제 DB 쿼리로 교체 + // await pool.query( + // `UPDATE customer_issues + // SET status = $1, updated_at = NOW() + // WHERE id = $2`, + // [status, id] + // ); + + console.log(`이슈 상태 업데이트: ${id} -> ${status}`); + } catch (error) { + console.error('이슈 상태 업데이트 실패:', error); + throw error; + } +} + diff --git a/backend-node/src/services/documentService.ts b/backend-node/src/services/documentService.ts new file mode 100644 index 00000000..4c75ae22 --- /dev/null +++ b/backend-node/src/services/documentService.ts @@ -0,0 +1,282 @@ +import { logger } from "../utils/logger"; +import { query } from "../database/db"; + +// 환경 변수로 데이터 소스 선택 +const DATA_SOURCE = process.env.DOCUMENT_DATA_SOURCE || "memory"; + +export interface Document { + id: string; + name: string; + category: "계약서" | "보험" | "세금계산서" | "기타"; + fileSize: number; + filePath: string; + mimeType?: string; + uploadDate: string; + description?: string; + uploadedBy?: string; + relatedEntityType?: string; + relatedEntityId?: string; + tags?: string[]; + isArchived: boolean; + archivedAt?: string; +} + +// 메모리 목 데이터 +const mockDocuments: Document[] = [ + { + id: "doc-1", + name: "2025년 1월 세금계산서.pdf", + category: "세금계산서", + fileSize: 1258291, + filePath: "/uploads/documents/tax-invoice-202501.pdf", + uploadDate: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(), + description: "1월 매출 세금계산서", + uploadedBy: "admin", + isArchived: false, + }, + { + id: "doc-2", + name: "차량보험증권_서울12가3456.pdf", + category: "보험", + fileSize: 876544, + filePath: "/uploads/documents/insurance-vehicle-1.pdf", + uploadDate: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(), + description: "1톤 트럭 종합보험", + uploadedBy: "admin", + isArchived: false, + }, + { + id: "doc-3", + name: "운송계약서_ABC물류.pdf", + category: "계약서", + fileSize: 2457600, + filePath: "/uploads/documents/contract-abc-logistics.pdf", + uploadDate: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toISOString(), + description: "ABC물류 연간 운송 계약", + uploadedBy: "admin", + isArchived: false, + }, + { + id: "doc-4", + name: "2024년 12월 세금계산서.pdf", + category: "세금계산서", + fileSize: 1124353, + filePath: "/uploads/documents/tax-invoice-202412.pdf", + uploadDate: new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString(), + uploadedBy: "admin", + isArchived: false, + }, + { + id: "doc-5", + name: "화물배상책임보험증권.pdf", + category: "보험", + fileSize: 720384, + filePath: "/uploads/documents/cargo-insurance.pdf", + uploadDate: new Date(Date.now() - 50 * 24 * 60 * 60 * 1000).toISOString(), + description: "화물 배상책임보험", + uploadedBy: "admin", + isArchived: false, + }, + { + id: "doc-6", + name: "차고지 임대계약서.pdf", + category: "계약서", + fileSize: 1843200, + filePath: "/uploads/documents/garage-lease-contract.pdf", + uploadDate: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(), + uploadedBy: "admin", + isArchived: false, + }, +]; + +/** + * 문서 관리 서비스 (Memory/DB 하이브리드) + */ +export class DocumentService { + private static instance: DocumentService; + + private constructor() { + logger.info(`📂 문서 관리 데이터 소스: ${DATA_SOURCE.toUpperCase()}`); + } + + public static getInstance(): DocumentService { + if (!DocumentService.instance) { + DocumentService.instance = new DocumentService(); + } + return DocumentService.instance; + } + + public async getAllDocuments(filter?: { + category?: string; + searchTerm?: string; + uploadedBy?: string; + }): Promise { + try { + const documents = DATA_SOURCE === "database" + ? await this.loadDocumentsFromDB(filter) + : this.loadDocumentsFromMemory(filter); + + // 최신순 정렬 + documents.sort((a, b) => + new Date(b.uploadDate).getTime() - new Date(a.uploadDate).getTime() + ); + + return documents; + } catch (error) { + logger.error("❌ 문서 목록 조회 오류:", error); + throw error; + } + } + + public async getDocumentById(id: string): Promise { + try { + if (DATA_SOURCE === "database") { + return await this.getDocumentByIdDB(id); + } else { + return this.getDocumentByIdMemory(id); + } + } catch (error) { + logger.error("❌ 문서 조회 오류:", error); + throw error; + } + } + + public async getStatistics(): Promise<{ + total: number; + byCategory: Record; + totalSize: number; + }> { + try { + const documents = await this.getAllDocuments(); + + const byCategory: Record = { + "계약서": 0, + "보험": 0, + "세금계산서": 0, + "기타": 0, + }; + + documents.forEach((doc) => { + byCategory[doc.category] = (byCategory[doc.category] || 0) + 1; + }); + + const totalSize = documents.reduce((sum, doc) => sum + doc.fileSize, 0); + + return { + total: documents.length, + byCategory, + totalSize, + }; + } catch (error) { + logger.error("❌ 문서 통계 조회 오류:", error); + throw error; + } + } + + // ==================== DATABASE 메서드 ==================== + + private async loadDocumentsFromDB(filter?: { + category?: string; + searchTerm?: string; + uploadedBy?: string; + }): Promise { + let sql = ` + SELECT + id, name, category, file_size as "fileSize", file_path as "filePath", + mime_type as "mimeType", upload_date as "uploadDate", + description, uploaded_by as "uploadedBy", + related_entity_type as "relatedEntityType", + related_entity_id as "relatedEntityId", + tags, is_archived as "isArchived", archived_at as "archivedAt" + FROM document_files + WHERE is_archived = false + `; + const params: any[] = []; + let paramIndex = 1; + + if (filter?.category) { + sql += ` AND category = $${paramIndex++}`; + params.push(filter.category); + } + if (filter?.searchTerm) { + sql += ` AND (name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`; + params.push(`%${filter.searchTerm}%`); + paramIndex++; + } + if (filter?.uploadedBy) { + sql += ` AND uploaded_by = $${paramIndex++}`; + params.push(filter.uploadedBy); + } + + const rows = await query(sql, params); + return rows.map((row: any) => ({ + ...row, + uploadDate: new Date(row.uploadDate).toISOString(), + archivedAt: row.archivedAt ? new Date(row.archivedAt).toISOString() : undefined, + })); + } + + private async getDocumentByIdDB(id: string): Promise { + const rows = await query( + `SELECT + id, name, category, file_size as "fileSize", file_path as "filePath", + mime_type as "mimeType", upload_date as "uploadDate", + description, uploaded_by as "uploadedBy", + related_entity_type as "relatedEntityType", + related_entity_id as "relatedEntityId", + tags, is_archived as "isArchived", archived_at as "archivedAt" + FROM document_files + WHERE id = $1`, + [id] + ); + + if (rows.length === 0) { + throw new Error(`문서를 찾을 수 없습니다: ${id}`); + } + + const row = rows[0]; + return { + ...row, + uploadDate: new Date(row.uploadDate).toISOString(), + archivedAt: row.archivedAt ? new Date(row.archivedAt).toISOString() : undefined, + }; + } + + // ==================== MEMORY 메서드 ==================== + + private loadDocumentsFromMemory(filter?: { + category?: string; + searchTerm?: string; + uploadedBy?: string; + }): Document[] { + let documents = mockDocuments.filter((d) => !d.isArchived); + + if (filter?.category) { + documents = documents.filter((d) => d.category === filter.category); + } + if (filter?.searchTerm) { + const term = filter.searchTerm.toLowerCase(); + documents = documents.filter( + (d) => + d.name.toLowerCase().includes(term) || + d.description?.toLowerCase().includes(term) + ); + } + if (filter?.uploadedBy) { + documents = documents.filter((d) => d.uploadedBy === filter.uploadedBy); + } + + return documents; + } + + private getDocumentByIdMemory(id: string): Document { + const document = mockDocuments.find((d) => d.id === id); + + if (!document) { + throw new Error(`문서를 찾을 수 없습니다: ${id}`); + } + + return document; + } +} + diff --git a/backend-node/src/services/maintenanceService.ts b/backend-node/src/services/maintenanceService.ts new file mode 100644 index 00000000..53f568e9 --- /dev/null +++ b/backend-node/src/services/maintenanceService.ts @@ -0,0 +1,267 @@ +import { logger } from "../utils/logger"; +import { query } from "../database/db"; + +// 환경 변수로 데이터 소스 선택 +const DATA_SOURCE = process.env.MAINTENANCE_DATA_SOURCE || "memory"; + +export interface MaintenanceSchedule { + id: string; + vehicleNumber: string; + vehicleType: string; + maintenanceType: "정기점검" | "수리" | "타이어교체" | "오일교환" | "기타"; + scheduledDate: string; + status: "scheduled" | "in_progress" | "completed" | "overdue"; + notes?: string; + estimatedCost?: number; + actualCost?: number; + createdAt: string; + updatedAt: string; + startedAt?: string; + completedAt?: string; + mechanicName?: string; + location?: string; +} + +// 메모리 목 데이터 +const mockSchedules: MaintenanceSchedule[] = [ + { + id: "maint-1", + vehicleNumber: "서울12가3456", + vehicleType: "1톤 트럭", + maintenanceType: "정기점검", + scheduledDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(), + status: "scheduled", + notes: "6개월 정기점검", + estimatedCost: 300000, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + location: "본사 정비소", + }, + { + id: "maint-2", + vehicleNumber: "경기34나5678", + vehicleType: "2.5톤 트럭", + maintenanceType: "오일교환", + scheduledDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString(), + status: "scheduled", + estimatedCost: 150000, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + location: "본사 정비소", + }, + { + id: "maint-3", + vehicleNumber: "인천56다7890", + vehicleType: "라보", + maintenanceType: "타이어교체", + scheduledDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), + status: "overdue", + notes: "긴급", + estimatedCost: 400000, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + location: "외부 정비소", + }, + { + id: "maint-4", + vehicleNumber: "부산78라1234", + vehicleType: "1톤 트럭", + maintenanceType: "수리", + scheduledDate: new Date().toISOString(), + status: "in_progress", + notes: "엔진 점검 중", + estimatedCost: 800000, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + startedAt: new Date().toISOString(), + location: "본사 정비소", + }, +]; + +/** + * 정비 일정 관리 서비스 (Memory/DB 하이브리드) + */ +export class MaintenanceService { + private static instance: MaintenanceService; + + private constructor() { + logger.info(`🔧 정비 일정 데이터 소스: ${DATA_SOURCE.toUpperCase()}`); + } + + public static getInstance(): MaintenanceService { + if (!MaintenanceService.instance) { + MaintenanceService.instance = new MaintenanceService(); + } + return MaintenanceService.instance; + } + + public async getAllSchedules(filter?: { + status?: string; + vehicleNumber?: string; + }): Promise { + try { + const schedules = DATA_SOURCE === "database" + ? await this.loadSchedulesFromDB(filter) + : this.loadSchedulesFromMemory(filter); + + // 자동으로 overdue 상태 업데이트 + const now = new Date(); + schedules.forEach((s) => { + if (s.status === "scheduled" && new Date(s.scheduledDate) < now) { + s.status = "overdue"; + } + }); + + // 정렬: 지연 > 진행중 > 예정 > 완료 + schedules.sort((a, b) => { + const statusOrder = { overdue: 0, in_progress: 1, scheduled: 2, completed: 3 }; + if (a.status !== b.status) { + return statusOrder[a.status] - statusOrder[b.status]; + } + return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); + }); + + return schedules; + } catch (error) { + logger.error("❌ 정비 일정 조회 오류:", error); + throw error; + } + } + + public async updateScheduleStatus( + id: string, + status: MaintenanceSchedule["status"] + ): Promise { + try { + if (DATA_SOURCE === "database") { + return await this.updateScheduleStatusDB(id, status); + } else { + return this.updateScheduleStatusMemory(id, status); + } + } catch (error) { + logger.error("❌ 정비 상태 업데이트 오류:", error); + throw error; + } + } + + // ==================== DATABASE 메서드 ==================== + + private async loadSchedulesFromDB(filter?: { + status?: string; + vehicleNumber?: string; + }): Promise { + let sql = ` + SELECT + id, vehicle_number as "vehicleNumber", vehicle_type as "vehicleType", + maintenance_type as "maintenanceType", scheduled_date as "scheduledDate", + status, notes, estimated_cost as "estimatedCost", actual_cost as "actualCost", + created_at as "createdAt", updated_at as "updatedAt", + started_at as "startedAt", completed_at as "completedAt", + mechanic_name as "mechanicName", location + FROM maintenance_schedules + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (filter?.status) { + sql += ` AND status = $${paramIndex++}`; + params.push(filter.status); + } + if (filter?.vehicleNumber) { + sql += ` AND vehicle_number = $${paramIndex++}`; + params.push(filter.vehicleNumber); + } + + const rows = await query(sql, params); + return rows.map((row: any) => ({ + ...row, + scheduledDate: new Date(row.scheduledDate).toISOString(), + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + startedAt: row.startedAt ? new Date(row.startedAt).toISOString() : undefined, + completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + })); + } + + private async updateScheduleStatusDB( + id: string, + status: MaintenanceSchedule["status"] + ): Promise { + let additionalSet = ""; + if (status === "in_progress") { + additionalSet = ", started_at = NOW()"; + } else if (status === "completed") { + additionalSet = ", completed_at = NOW()"; + } + + const rows = await query( + `UPDATE maintenance_schedules + SET status = $1, updated_at = NOW() ${additionalSet} + WHERE id = $2 + RETURNING + id, vehicle_number as "vehicleNumber", vehicle_type as "vehicleType", + maintenance_type as "maintenanceType", scheduled_date as "scheduledDate", + status, notes, estimated_cost as "estimatedCost", + created_at as "createdAt", updated_at as "updatedAt", + started_at as "startedAt", completed_at as "completedAt", + mechanic_name as "mechanicName", location`, + [status, id] + ); + + if (rows.length === 0) { + throw new Error(`정비 일정을 찾을 수 없습니다: ${id}`); + } + + const row = rows[0]; + return { + ...row, + scheduledDate: new Date(row.scheduledDate).toISOString(), + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + startedAt: row.startedAt ? new Date(row.startedAt).toISOString() : undefined, + completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + }; + } + + // ==================== MEMORY 메서드 ==================== + + private loadSchedulesFromMemory(filter?: { + status?: string; + vehicleNumber?: string; + }): MaintenanceSchedule[] { + let schedules = [...mockSchedules]; + + if (filter?.status) { + schedules = schedules.filter((s) => s.status === filter.status); + } + if (filter?.vehicleNumber) { + schedules = schedules.filter((s) => s.vehicleNumber === filter.vehicleNumber); + } + + return schedules; + } + + private updateScheduleStatusMemory( + id: string, + status: MaintenanceSchedule["status"] + ): MaintenanceSchedule { + const schedule = mockSchedules.find((s) => s.id === id); + + if (!schedule) { + throw new Error(`정비 일정을 찾을 수 없습니다: ${id}`); + } + + schedule.status = status; + schedule.updatedAt = new Date().toISOString(); + + if (status === "in_progress") { + schedule.startedAt = new Date().toISOString(); + } else if (status === "completed") { + schedule.completedAt = new Date().toISOString(); + } + + return schedule; + } +} + diff --git a/backend-node/src/services/mapDataService.ts b/backend-node/src/services/mapDataService.ts new file mode 100644 index 00000000..2fb6c2af --- /dev/null +++ b/backend-node/src/services/mapDataService.ts @@ -0,0 +1,229 @@ +import { logger } from "../utils/logger"; +import { query } from "../database/db"; +import { ExternalDbConnectionService } from "./externalDbConnectionService"; + +interface MapDataQuery { + connectionId?: number; + tableName: string; + latColumn: string; + lngColumn: string; + labelColumn?: string; + statusColumn?: string; + additionalColumns?: string[]; + whereClause?: string; +} + +export interface MapMarker { + id: string | number; + latitude: number; + longitude: number; + label?: string; + status?: string; + additionalInfo?: Record; +} + +/** + * 지도 데이터 서비스 + * 외부/내부 DB에서 위도/경도 데이터를 조회하여 지도 마커로 변환 + */ +export class MapDataService { + constructor() { + // ExternalDbConnectionService는 static 메서드를 사용 + } + + /** + * 외부 DB에서 지도 데이터 조회 + */ + async getMapData(params: MapDataQuery): Promise { + try { + logger.info("🗺️ 외부 DB 지도 데이터 조회 시작:", params); + + // SELECT할 컬럼 목록 구성 + const selectColumns = [ + params.latColumn, + params.lngColumn, + params.labelColumn, + params.statusColumn, + ...(params.additionalColumns || []), + ].filter(Boolean); + + // 중복 제거 + const uniqueColumns = Array.from(new Set(selectColumns)); + + // SQL 쿼리 구성 + let sql = `SELECT ${uniqueColumns.map((col) => `"${col}"`).join(", ")} FROM "${params.tableName}"`; + + if (params.whereClause) { + sql += ` WHERE ${params.whereClause}`; + } + + logger.info("📝 실행할 SQL:", sql); + + // 외부 DB 쿼리 실행 (static 메서드 사용) + const result = await ExternalDbConnectionService.executeQuery( + params.connectionId!, + sql + ); + + if (!result.success || !result.data) { + throw new Error("외부 DB 쿼리 실패"); + } + + // 데이터를 MapMarker 형식으로 변환 + const markers = this.convertToMarkers( + result.data, + params.latColumn, + params.lngColumn, + params.labelColumn, + params.statusColumn, + params.additionalColumns + ); + + logger.info(`✅ ${markers.length}개의 마커 데이터 변환 완료`); + + return markers; + } catch (error) { + logger.error("❌ 외부 DB 지도 데이터 조회 오류:", error); + throw error; + } + } + + /** + * 내부 DB에서 지도 데이터 조회 + */ + async getInternalMapData( + params: Omit + ): Promise { + try { + logger.info("🗺️ 내부 DB 지도 데이터 조회 시작:", params); + + // SELECT할 컬럼 목록 구성 + const selectColumns = [ + params.latColumn, + params.lngColumn, + params.labelColumn, + params.statusColumn, + ...(params.additionalColumns || []), + ].filter(Boolean); + + // 중복 제거 + const uniqueColumns = Array.from(new Set(selectColumns)); + + // SQL 쿼리 구성 + let sql = `SELECT ${uniqueColumns.map((col) => `"${col}"`).join(", ")} FROM "${params.tableName}"`; + + if (params.whereClause) { + sql += ` WHERE ${params.whereClause}`; + } + + logger.info("📝 실행할 SQL:", sql); + + // 내부 DB 쿼리 실행 + const rows = await query(sql); + + // 데이터를 MapMarker 형식으로 변환 + const markers = this.convertToMarkers( + rows, + params.latColumn, + params.lngColumn, + params.labelColumn, + params.statusColumn, + params.additionalColumns + ); + + logger.info(`✅ ${markers.length}개의 마커 데이터 변환 완료`); + + return markers; + } catch (error) { + logger.error("❌ 내부 DB 지도 데이터 조회 오류:", error); + throw error; + } + } + + /** + * DB 결과를 MapMarker 배열로 변환 + */ + private convertToMarkers( + data: any[], + latColumn: string, + lngColumn: string, + labelColumn?: string, + statusColumn?: string, + additionalColumns?: string[] + ): MapMarker[] { + const markers: MapMarker[] = []; + + for (let i = 0; i < data.length; i++) { + const row = data[i]; + + // 위도/경도 추출 (다양한 컬럼명 지원) + const lat = this.extractCoordinate(row, latColumn); + const lng = this.extractCoordinate(row, lngColumn); + + // 유효한 좌표인지 확인 + if (lat === null || lng === null || isNaN(lat) || isNaN(lng)) { + logger.warn(`⚠️ 유효하지 않은 좌표 스킵: row ${i}`, { lat, lng }); + continue; + } + + // 위도 범위 체크 (-90 ~ 90) + if (lat < -90 || lat > 90) { + logger.warn(`⚠️ 위도 범위 초과: ${lat}`); + continue; + } + + // 경도 범위 체크 (-180 ~ 180) + if (lng < -180 || lng > 180) { + logger.warn(`⚠️ 경도 범위 초과: ${lng}`); + continue; + } + + // 추가 정보 수집 + const additionalInfo: Record = {}; + if (additionalColumns) { + for (const col of additionalColumns) { + if (col && row[col] !== undefined) { + additionalInfo[col] = row[col]; + } + } + } + + // 마커 생성 + markers.push({ + id: row.id || row.ID || `marker-${i}`, + latitude: lat, + longitude: lng, + label: labelColumn ? row[labelColumn] : undefined, + status: statusColumn ? row[statusColumn] : undefined, + additionalInfo: Object.keys(additionalInfo).length > 0 ? additionalInfo : undefined, + }); + } + + return markers; + } + + /** + * 다양한 형식의 좌표 추출 + */ + private extractCoordinate(row: any, columnName: string): number | null { + const value = row[columnName]; + + if (value === null || value === undefined) { + return null; + } + + // 이미 숫자인 경우 + if (typeof value === "number") { + return value; + } + + // 문자열인 경우 파싱 + if (typeof value === "string") { + const parsed = parseFloat(value); + return isNaN(parsed) ? null : parsed; + } + + return null; + } +} + diff --git a/backend-node/src/services/riskAlertCacheService.ts b/backend-node/src/services/riskAlertCacheService.ts new file mode 100644 index 00000000..cc4de181 --- /dev/null +++ b/backend-node/src/services/riskAlertCacheService.ts @@ -0,0 +1,100 @@ +/** + * 리스크/알림 캐시 서비스 + * - 10분마다 자동 갱신 + * - 메모리 캐시로 빠른 응답 + */ + +import { RiskAlertService, Alert } from './riskAlertService'; + +export class RiskAlertCacheService { + private static instance: RiskAlertCacheService; + private riskAlertService: RiskAlertService; + + // 메모리 캐시 + private cachedAlerts: Alert[] = []; + private lastUpdated: Date | null = null; + private updateInterval: NodeJS.Timeout | null = null; + + private constructor() { + this.riskAlertService = new RiskAlertService(); + } + + /** + * 싱글톤 인스턴스 + */ + public static getInstance(): RiskAlertCacheService { + if (!RiskAlertCacheService.instance) { + RiskAlertCacheService.instance = new RiskAlertCacheService(); + } + return RiskAlertCacheService.instance; + } + + /** + * 자동 갱신 시작 (10분 간격) + */ + public startAutoRefresh(): void { + console.log('🔄 리스크/알림 자동 갱신 시작 (10분 간격)'); + + // 즉시 첫 갱신 + this.refreshCache(); + + // 10분마다 갱신 (600,000ms) + this.updateInterval = setInterval(() => { + this.refreshCache(); + }, 10 * 60 * 1000); + } + + /** + * 자동 갱신 중지 + */ + public stopAutoRefresh(): void { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + console.log('⏸️ 리스크/알림 자동 갱신 중지'); + } + } + + /** + * 캐시 갱신 + */ + private async refreshCache(): Promise { + try { + console.log('🔄 리스크/알림 캐시 갱신 중...'); + const startTime = Date.now(); + + const alerts = await this.riskAlertService.getAllAlerts(); + + this.cachedAlerts = alerts; + this.lastUpdated = new Date(); + + const duration = Date.now() - startTime; + console.log(`✅ 리스크/알림 캐시 갱신 완료! (${duration}ms)`); + console.log(` - 총 ${alerts.length}건의 알림`); + console.log(` - 기상특보: ${alerts.filter(a => a.type === 'weather').length}건`); + console.log(` - 교통사고: ${alerts.filter(a => a.type === 'accident').length}건`); + console.log(` - 도로공사: ${alerts.filter(a => a.type === 'construction').length}건`); + } catch (error: any) { + console.error('❌ 리스크/알림 캐시 갱신 실패:', error.message); + } + } + + /** + * 캐시된 알림 조회 (빠름!) + */ + public getCachedAlerts(): { alerts: Alert[]; lastUpdated: Date | null } { + return { + alerts: this.cachedAlerts, + lastUpdated: this.lastUpdated, + }; + } + + /** + * 수동 갱신 (필요 시) + */ + public async forceRefresh(): Promise { + await this.refreshCache(); + return this.cachedAlerts; + } +} + diff --git a/backend-node/src/services/riskAlertService.ts b/backend-node/src/services/riskAlertService.ts new file mode 100644 index 00000000..d911de94 --- /dev/null +++ b/backend-node/src/services/riskAlertService.ts @@ -0,0 +1,548 @@ +/** + * 리스크/알림 서비스 + * - 기상청 특보 API + * - 국토교통부 교통사고/도로공사 API 연동 + */ + +import axios from 'axios'; + +export interface Alert { + id: string; + type: 'accident' | 'weather' | 'construction'; + severity: 'high' | 'medium' | 'low'; + title: string; + location: string; + description: string; + timestamp: string; +} + +export class RiskAlertService { + /** + * 기상청 특보 정보 조회 (기상청 API 허브 - 현재 발효 중인 특보 API) + */ + async getWeatherAlerts(): Promise { + try { + const apiKey = process.env.KMA_API_KEY; + + if (!apiKey) { + console.log('⚠️ 기상청 API 키가 없습니다. 테스트 데이터를 반환합니다.'); + return this.generateDummyWeatherAlerts(); + } + + const alerts: Alert[] = []; + + // 기상청 특보 현황 조회 API (실제 발효 중인 특보) + try { + const warningUrl = 'https://apihub.kma.go.kr/api/typ01/url/wrn_now_data.php'; + const warningResponse = await axios.get(warningUrl, { + params: { + fe: 'f', // 발표 중인 특보 + tm: '', // 현재 시각 + disp: 0, + authKey: apiKey, + }, + timeout: 10000, + responseType: 'arraybuffer', // 인코딩 문제 해결 + }); + + console.log('✅ 기상청 특보 현황 API 응답 수신 완료'); + + // 텍스트 응답 파싱 (EUC-KR 인코딩) + const iconv = require('iconv-lite'); + const responseText = iconv.decode(Buffer.from(warningResponse.data), 'EUC-KR'); + + if (typeof responseText === 'string' && responseText.includes('#START7777')) { + const lines = responseText.split('\n'); + + for (const line of lines) { + // 주석 및 헤더 라인 무시 + if (line.startsWith('#') || line.trim() === '' || line.includes('7777END')) { + continue; + } + + // 데이터 라인 파싱 + const fields = line.split(',').map((f) => f.trim()); + if (fields.length >= 7) { + const regUpKo = fields[1]; // 상위 특보 지역명 + const regKo = fields[3]; // 특보 지역명 + const tmFc = fields[4]; // 발표 시각 + const wrnType = fields[6]; // 특보 종류 + const wrnLevel = fields[7]; // 특보 수준 (주의보/경보) + + // 특보 종류별 매핑 + const warningMap: Record = { + '풍랑': { title: '풍랑주의보', severity: 'medium' }, + '강풍': { title: '강풍주의보', severity: 'medium' }, + '대설': { title: '대설특보', severity: 'high' }, + '폭설': { title: '대설특보', severity: 'high' }, + '태풍': { title: '태풍특보', severity: 'high' }, + '호우': { title: '호우특보', severity: 'high' }, + '한파': { title: '한파특보', severity: 'high' }, + '폭염': { title: '폭염특보', severity: 'high' }, + '건조': { title: '건조특보', severity: 'low' }, + '해일': { title: '해일특보', severity: 'high' }, + '너울': { title: '너울주의보', severity: 'low' }, + }; + + const warningInfo = warningMap[wrnType]; + if (warningInfo) { + // 경보는 심각도 높이기 + const severity = wrnLevel.includes('경보') ? 'high' : warningInfo.severity; + const title = wrnLevel.includes('경보') + ? wrnType + '경보' + : warningInfo.title; + + alerts.push({ + id: `warning-${Date.now()}-${alerts.length}`, + type: 'weather' as const, + severity: severity, + title: title, + location: regKo || regUpKo || '전국', + description: `${wrnLevel} 발표 - ${regUpKo} ${regKo}`, + timestamp: this.parseKmaTime(tmFc), + }); + } + } + } + } + + console.log(`✅ 총 ${alerts.length}건의 기상특보 감지`); + } catch (warningError: any) { + console.error('❌ 기상청 특보 API 오류:', warningError.message); + return this.generateDummyWeatherAlerts(); + } + + // 특보가 없으면 빈 배열 반환 (0건) + if (alerts.length === 0) { + console.log('ℹ️ 현재 발효 중인 기상특보 없음 (0건)'); + } + + return alerts; + } catch (error: any) { + console.error('❌ 기상청 특보 API 오류:', error.message); + // API 오류 시 더미 데이터 반환 + return this.generateDummyWeatherAlerts(); + } + } + + /** + * 교통사고 정보 조회 (국토교통부 ITS API 우선, 실패 시 한국도로공사) + */ + async getAccidentAlerts(): Promise { + // 1순위: 국토교통부 ITS API (실시간 돌발정보) + const itsApiKey = process.env.ITS_API_KEY; + if (itsApiKey) { + try { + const url = `https://openapi.its.go.kr:9443/eventInfo`; + + const response = await axios.get(url, { + params: { + apiKey: itsApiKey, + type: 'all', + eventType: 'acc', // 교통사고 + minX: 124, // 전국 범위 + maxX: 132, + minY: 33, + maxY: 43, + getType: 'json', + }, + timeout: 10000, + }); + + console.log('✅ 국토교통부 ITS 교통사고 API 응답 수신 완료'); + + const alerts: Alert[] = []; + + if (response.data?.header?.resultCode === 0 && response.data?.body?.items) { + const items = Array.isArray(response.data.body.items) ? response.data.body.items : [response.data.body.items]; + + items.forEach((item: any, index: number) => { + // ITS API 필드: eventType(교통사고), roadName, message, startDate, lanesBlocked + const lanesCount = (item.lanesBlocked || '').match(/\d+/)?.[0] || 0; + const severity = Number(lanesCount) >= 2 ? 'high' : Number(lanesCount) === 1 ? 'medium' : 'low'; + + alerts.push({ + id: `accident-its-${Date.now()}-${index}`, + type: 'accident' as const, + severity: severity as 'high' | 'medium' | 'low', + title: `[${item.roadName || '고속도로'}] 교통사고`, + location: `${item.roadName || ''} ${item.roadDrcType || ''}`.trim() || '정보 없음', + description: item.message || `${item.eventDetailType || '사고 발생'} - ${item.lanesBlocked || '차로 통제'}`, + timestamp: this.parseITSTime(item.startDate || ''), + }); + }); + } + + if (alerts.length === 0) { + console.log('ℹ️ 현재 교통사고 없음 (0건)'); + } else { + console.log(`✅ 총 ${alerts.length}건의 교통사고 감지 (ITS)`); + } + + return alerts; + } catch (error: any) { + console.error('❌ 국토교통부 ITS API 오류:', error.message); + console.log('ℹ️ 2순위 API로 전환합니다.'); + } + } + + // 2순위: 한국도로공사 API (현재 차단됨) + const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492'; + try { + const url = 'https://data.ex.co.kr/openapi/business/trafficFcst'; + + const response = await axios.get(url, { + params: { + key: exwayApiKey, + type: 'json', + }, + timeout: 10000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://data.ex.co.kr/', + }, + }); + + console.log('✅ 한국도로공사 교통예보 API 응답 수신 완료'); + + const alerts: Alert[] = []; + + if (response.data?.list) { + const items = Array.isArray(response.data.list) ? response.data.list : [response.data.list]; + + items.forEach((item: any, index: number) => { + const contentType = item.conzoneCd || item.contentType || ''; + + if (contentType === '00' || item.content?.includes('사고')) { + const severity = contentType === '31' ? 'high' : contentType === '30' ? 'medium' : 'low'; + + alerts.push({ + id: `accident-exway-${Date.now()}-${index}`, + type: 'accident' as const, + severity: severity as 'high' | 'medium' | 'low', + title: '교통사고', + location: item.routeName || item.location || '고속도로', + description: item.content || item.message || '교통사고 발생', + timestamp: new Date(item.regDate || Date.now()).toISOString(), + }); + } + }); + } + + if (alerts.length > 0) { + console.log(`✅ 총 ${alerts.length}건의 교통사고 감지 (한국도로공사)`); + return alerts; + } + } catch (error: any) { + console.error('❌ 한국도로공사 API 오류:', error.message); + } + + // 모든 API 실패 시 더미 데이터 + console.log('ℹ️ 모든 교통사고 API 실패. 더미 데이터를 반환합니다.'); + return this.generateDummyAccidentAlerts(); + } + + /** + * 도로공사 정보 조회 (국토교통부 ITS API 우선, 실패 시 한국도로공사) + */ + async getRoadworkAlerts(): Promise { + // 1순위: 국토교통부 ITS API (실시간 돌발정보 - 공사) + const itsApiKey = process.env.ITS_API_KEY; + if (itsApiKey) { + try { + const url = `https://openapi.its.go.kr:9443/eventInfo`; + + const response = await axios.get(url, { + params: { + apiKey: itsApiKey, + type: 'all', + eventType: 'all', // 전체 조회 후 필터링 + minX: 124, + maxX: 132, + minY: 33, + maxY: 43, + getType: 'json', + }, + timeout: 10000, + }); + + console.log('✅ 국토교통부 ITS 도로공사 API 응답 수신 완료'); + + const alerts: Alert[] = []; + + if (response.data?.header?.resultCode === 0 && response.data?.body?.items) { + const items = Array.isArray(response.data.body.items) ? response.data.body.items : [response.data.body.items]; + + items.forEach((item: any, index: number) => { + // 공사/작업만 필터링 + if (item.eventType === '공사' || item.eventDetailType === '작업') { + const lanesCount = (item.lanesBlocked || '').match(/\d+/)?.[0] || 0; + const severity = Number(lanesCount) >= 2 ? 'high' : 'medium'; + + alerts.push({ + id: `construction-its-${Date.now()}-${index}`, + type: 'construction' as const, + severity: severity as 'high' | 'medium' | 'low', + title: `[${item.roadName || '고속도로'}] 도로 공사`, + location: `${item.roadName || ''} ${item.roadDrcType || ''}`.trim() || '정보 없음', + description: item.message || `${item.eventDetailType || '작업'} - ${item.lanesBlocked || '차로 통제'}`, + timestamp: this.parseITSTime(item.startDate || ''), + }); + } + }); + } + + if (alerts.length === 0) { + console.log('ℹ️ 현재 도로공사 없음 (0건)'); + } else { + console.log(`✅ 총 ${alerts.length}건의 도로공사 감지 (ITS)`); + } + + return alerts; + } catch (error: any) { + console.error('❌ 국토교통부 ITS API 오류:', error.message); + console.log('ℹ️ 2순위 API로 전환합니다.'); + } + } + + // 2순위: 한국도로공사 API + const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492'; + try { + const url = 'https://data.ex.co.kr/openapi/business/trafficFcst'; + + const response = await axios.get(url, { + params: { + key: exwayApiKey, + type: 'json', + }, + timeout: 10000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://data.ex.co.kr/', + }, + }); + + console.log('✅ 한국도로공사 교통예보 API 응답 수신 완료 (도로공사)'); + + const alerts: Alert[] = []; + + if (response.data?.list) { + const items = Array.isArray(response.data.list) ? response.data.list : [response.data.list]; + + items.forEach((item: any, index: number) => { + const contentType = item.conzoneCd || item.contentType || ''; + + if (contentType === '03' || item.content?.includes('작업') || item.content?.includes('공사')) { + const severity = contentType === '31' ? 'high' : contentType === '30' ? 'medium' : 'low'; + + alerts.push({ + id: `construction-exway-${Date.now()}-${index}`, + type: 'construction' as const, + severity: severity as 'high' | 'medium' | 'low', + title: '도로 공사', + location: item.routeName || item.location || '고속도로', + description: item.content || item.message || '도로 공사 진행 중', + timestamp: new Date(item.regDate || Date.now()).toISOString(), + }); + } + }); + } + + if (alerts.length > 0) { + console.log(`✅ 총 ${alerts.length}건의 도로공사 감지 (한국도로공사)`); + return alerts; + } + } catch (error: any) { + console.error('❌ 한국도로공사 API 오류:', error.message); + } + + // 모든 API 실패 시 더미 데이터 + console.log('ℹ️ 모든 도로공사 API 실패. 더미 데이터를 반환합니다.'); + return this.generateDummyRoadworkAlerts(); + } + + /** + * 전체 알림 조회 (통합) + */ + async getAllAlerts(): Promise { + try { + const [weatherAlerts, accidentAlerts, roadworkAlerts] = await Promise.all([ + this.getWeatherAlerts(), + this.getAccidentAlerts(), + this.getRoadworkAlerts(), + ]); + + // 모든 알림 합치기 + const allAlerts = [...weatherAlerts, ...accidentAlerts, ...roadworkAlerts]; + + // 시간 순으로 정렬 (최신순) + allAlerts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + return allAlerts; + } catch (error: any) { + console.error('❌ 전체 알림 조회 오류:', error.message); + throw error; + } + } + + /** + * 기상청 시간 형식 파싱 (YYYYMMDDHHmm -> ISO) + */ + private parseKmaTime(tmFc: string): string { + try { + if (!tmFc || tmFc.length !== 12) { + return new Date().toISOString(); + } + + const year = tmFc.substring(0, 4); + const month = tmFc.substring(4, 6); + const day = tmFc.substring(6, 8); + const hour = tmFc.substring(8, 10); + const minute = tmFc.substring(10, 12); + + return new Date(`${year}-${month}-${day}T${hour}:${minute}:00+09:00`).toISOString(); + } catch (error) { + return new Date().toISOString(); + } + } + + /** + * ITS API 시간 형식 파싱 (YYYYMMDDHHmmss -> ISO) + */ + private parseITSTime(dateStr: string): string { + try { + if (!dateStr || dateStr.length !== 14) { + return new Date().toISOString(); + } + + const year = dateStr.substring(0, 4); + const month = dateStr.substring(4, 6); + const day = dateStr.substring(6, 8); + const hour = dateStr.substring(8, 10); + const minute = dateStr.substring(10, 12); + const second = dateStr.substring(12, 14); + + return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}+09:00`).toISOString(); + } catch (error) { + return new Date().toISOString(); + } + } + + /** + * 기상 특보 심각도 판단 + */ + private getWeatherSeverity(wrnLv: string): 'high' | 'medium' | 'low' { + if (wrnLv.includes('경보') || wrnLv.includes('특보')) { + return 'high'; + } + if (wrnLv.includes('주의보')) { + return 'medium'; + } + return 'low'; + } + + /** + * 기상 특보 제목 생성 + */ + private getWeatherTitle(wrnLv: string): string { + if (wrnLv.includes('대설')) return '대설특보'; + if (wrnLv.includes('태풍')) return '태풍특보'; + if (wrnLv.includes('강풍')) return '강풍특보'; + if (wrnLv.includes('호우')) return '호우특보'; + if (wrnLv.includes('한파')) return '한파특보'; + if (wrnLv.includes('폭염')) return '폭염특보'; + return '기상특보'; + } + + /** + * 교통사고 심각도 판단 + */ + private getAccidentSeverity(accInfo: string): 'high' | 'medium' | 'low' { + if (accInfo.includes('중대') || accInfo.includes('다중') || accInfo.includes('추돌')) { + return 'high'; + } + if (accInfo.includes('접촉') || accInfo.includes('경상')) { + return 'medium'; + } + return 'low'; + } + + /** + * 테스트용 날씨 특보 더미 데이터 + */ + private generateDummyWeatherAlerts(): Alert[] { + return [ + { + id: `weather-${Date.now()}-1`, + type: 'weather', + severity: 'high', + title: '대설특보', + location: '강원 영동지역', + description: '시간당 2cm 이상 폭설. 차량 운행 주의', + timestamp: new Date(Date.now() - 30 * 60000).toISOString(), + }, + { + id: `weather-${Date.now()}-2`, + type: 'weather', + severity: 'medium', + title: '강풍특보', + location: '남해안 전 지역', + description: '순간 풍속 20m/s 이상. 고속도로 주행 주의', + timestamp: new Date(Date.now() - 90 * 60000).toISOString(), + }, + ]; + } + + /** + * 테스트용 교통사고 더미 데이터 + */ + private generateDummyAccidentAlerts(): Alert[] { + return [ + { + id: `accident-${Date.now()}-1`, + type: 'accident', + severity: 'high', + title: '교통사고 발생', + location: '경부고속도로 서울방향 189km', + description: '3중 추돌사고로 2차로 통제 중. 우회 권장', + timestamp: new Date(Date.now() - 10 * 60000).toISOString(), + }, + { + id: `accident-${Date.now()}-2`, + type: 'accident', + severity: 'medium', + title: '사고 다발 지역', + location: '영동고속도로 강릉방향 160km', + description: '안개로 인한 가시거리 50m 이하. 서행 운전', + timestamp: new Date(Date.now() - 60 * 60000).toISOString(), + }, + ]; + } + + /** + * 테스트용 도로공사 더미 데이터 + */ + private generateDummyRoadworkAlerts(): Alert[] { + return [ + { + id: `construction-${Date.now()}-1`, + type: 'construction', + severity: 'medium', + title: '도로 공사', + location: '서울외곽순환 목동IC~화곡IC', + description: '야간 공사로 1차로 통제 (22:00~06:00)', + timestamp: new Date(Date.now() - 45 * 60000).toISOString(), + }, + { + id: `construction-${Date.now()}-2`, + type: 'construction', + severity: 'low', + title: '도로 통제', + location: '중부내륙고속도로 김천JC~현풍IC', + description: '도로 유지보수 작업. 차량 속도 제한 60km/h', + timestamp: new Date(Date.now() - 120 * 60000).toISOString(), + }, + ]; + } +} + diff --git a/backend-node/src/services/todoService.ts b/backend-node/src/services/todoService.ts new file mode 100644 index 00000000..1347c665 --- /dev/null +++ b/backend-node/src/services/todoService.ts @@ -0,0 +1,449 @@ +import * as fs from "fs"; +import * as path from "path"; +import { v4 as uuidv4 } from "uuid"; +import { logger } from "../utils/logger"; +import { query } from "../database/db"; + +const TODO_DIR = path.join(__dirname, "../../data/todos"); +const TODO_FILE = path.join(TODO_DIR, "todos.json"); + +// 환경 변수로 데이터 소스 선택 (file | database) +const DATA_SOURCE = process.env.TODO_DATA_SOURCE || "file"; + +export interface TodoItem { + id: string; + title: string; + description?: string; + priority: "urgent" | "high" | "normal" | "low"; + status: "pending" | "in_progress" | "completed"; + assignedTo?: string; + dueDate?: string; + createdAt: string; + updatedAt: string; + completedAt?: string; + isUrgent: boolean; + order: number; +} + +export interface TodoListResponse { + todos: TodoItem[]; + stats: { + total: number; + pending: number; + inProgress: number; + completed: number; + urgent: number; + overdue: number; + }; +} + +/** + * To-Do 리스트 관리 서비스 (File/DB 하이브리드) + */ +export class TodoService { + private static instance: TodoService; + + private constructor() { + if (DATA_SOURCE === "file") { + this.ensureDataDirectory(); + } + logger.info(`📋 To-Do 데이터 소스: ${DATA_SOURCE.toUpperCase()}`); + } + + public static getInstance(): TodoService { + if (!TodoService.instance) { + TodoService.instance = new TodoService(); + } + return TodoService.instance; + } + + /** + * 데이터 디렉토리 생성 (파일 모드) + */ + private ensureDataDirectory(): void { + if (!fs.existsSync(TODO_DIR)) { + fs.mkdirSync(TODO_DIR, { recursive: true }); + logger.info(`📁 To-Do 데이터 디렉토리 생성: ${TODO_DIR}`); + } + if (!fs.existsSync(TODO_FILE)) { + fs.writeFileSync(TODO_FILE, JSON.stringify([], null, 2)); + logger.info(`📄 To-Do 파일 생성: ${TODO_FILE}`); + } + } + + /** + * 모든 To-Do 항목 조회 + */ + public async getAllTodos(filter?: { + status?: string; + priority?: string; + assignedTo?: string; + }): Promise { + try { + const todos = DATA_SOURCE === "database" + ? await this.loadTodosFromDB(filter) + : this.loadTodosFromFile(filter); + + // 정렬: 긴급 > 우선순위 > 순서 + todos.sort((a, b) => { + if (a.isUrgent !== b.isUrgent) return a.isUrgent ? -1 : 1; + const priorityOrder = { urgent: 0, high: 1, normal: 2, low: 3 }; + if (a.priority !== b.priority) return priorityOrder[a.priority] - priorityOrder[b.priority]; + return a.order - b.order; + }); + + const stats = this.calculateStats(todos); + + return { todos, stats }; + } catch (error) { + logger.error("❌ To-Do 목록 조회 오류:", error); + throw error; + } + } + + /** + * To-Do 항목 생성 + */ + public async createTodo(todoData: Partial): Promise { + try { + const newTodo: TodoItem = { + id: uuidv4(), + title: todoData.title || "", + description: todoData.description, + priority: todoData.priority || "normal", + status: "pending", + assignedTo: todoData.assignedTo, + dueDate: todoData.dueDate, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + isUrgent: todoData.isUrgent || false, + order: 0, // DB에서 자동 계산 + }; + + if (DATA_SOURCE === "database") { + await this.createTodoDB(newTodo); + } else { + const todos = this.loadTodosFromFile(); + newTodo.order = todos.length > 0 ? Math.max(...todos.map((t) => t.order)) + 1 : 0; + todos.push(newTodo); + this.saveTodosToFile(todos); + } + + logger.info(`✅ To-Do 생성: ${newTodo.id} - ${newTodo.title}`); + return newTodo; + } catch (error) { + logger.error("❌ To-Do 생성 오류:", error); + throw error; + } + } + + /** + * To-Do 항목 수정 + */ + public async updateTodo(id: string, updates: Partial): Promise { + try { + if (DATA_SOURCE === "database") { + return await this.updateTodoDB(id, updates); + } else { + return this.updateTodoFile(id, updates); + } + } catch (error) { + logger.error("❌ To-Do 수정 오류:", error); + throw error; + } + } + + /** + * To-Do 항목 삭제 + */ + public async deleteTodo(id: string): Promise { + try { + if (DATA_SOURCE === "database") { + await this.deleteTodoDB(id); + } else { + this.deleteTodoFile(id); + } + logger.info(`✅ To-Do 삭제: ${id}`); + } catch (error) { + logger.error("❌ To-Do 삭제 오류:", error); + throw error; + } + } + + /** + * To-Do 항목 순서 변경 + */ + public async reorderTodos(todoIds: string[]): Promise { + try { + if (DATA_SOURCE === "database") { + await this.reorderTodosDB(todoIds); + } else { + this.reorderTodosFile(todoIds); + } + logger.info(`✅ To-Do 순서 변경: ${todoIds.length}개 항목`); + } catch (error) { + logger.error("❌ To-Do 순서 변경 오류:", error); + throw error; + } + } + + // ==================== DATABASE 메서드 ==================== + + private async loadTodosFromDB(filter?: { + status?: string; + priority?: string; + assignedTo?: string; + }): Promise { + let sql = ` + SELECT + id, title, description, priority, status, + assigned_to as "assignedTo", + due_date as "dueDate", + created_at as "createdAt", + updated_at as "updatedAt", + completed_at as "completedAt", + is_urgent as "isUrgent", + display_order as "order" + FROM todo_items + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + if (filter?.status) { + sql += ` AND status = $${paramIndex++}`; + params.push(filter.status); + } + if (filter?.priority) { + sql += ` AND priority = $${paramIndex++}`; + params.push(filter.priority); + } + if (filter?.assignedTo) { + sql += ` AND assigned_to = $${paramIndex++}`; + params.push(filter.assignedTo); + } + + sql += ` ORDER BY display_order ASC`; + + const rows = await query(sql, params); + return rows.map((row: any) => ({ + ...row, + dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + })); + } + + private async createTodoDB(todo: TodoItem): Promise { + // 현재 최대 order 값 조회 + const maxOrderRows = await query( + "SELECT COALESCE(MAX(display_order), -1) + 1 as next_order FROM todo_items" + ); + const nextOrder = maxOrderRows[0].next_order; + + await query( + `INSERT INTO todo_items ( + id, title, description, priority, status, assigned_to, due_date, + created_at, updated_at, is_urgent, display_order + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [ + todo.id, + todo.title, + todo.description, + todo.priority, + todo.status, + todo.assignedTo, + todo.dueDate ? new Date(todo.dueDate) : null, + new Date(todo.createdAt), + new Date(todo.updatedAt), + todo.isUrgent, + nextOrder, + ] + ); + } + + private async updateTodoDB(id: string, updates: Partial): Promise { + const setClauses: string[] = ["updated_at = NOW()"]; + const params: any[] = []; + let paramIndex = 1; + + if (updates.title !== undefined) { + setClauses.push(`title = $${paramIndex++}`); + params.push(updates.title); + } + if (updates.description !== undefined) { + setClauses.push(`description = $${paramIndex++}`); + params.push(updates.description); + } + if (updates.priority !== undefined) { + setClauses.push(`priority = $${paramIndex++}`); + params.push(updates.priority); + } + if (updates.status !== undefined) { + setClauses.push(`status = $${paramIndex++}`); + params.push(updates.status); + if (updates.status === "completed") { + setClauses.push(`completed_at = NOW()`); + } + } + if (updates.assignedTo !== undefined) { + setClauses.push(`assigned_to = $${paramIndex++}`); + params.push(updates.assignedTo); + } + if (updates.dueDate !== undefined) { + setClauses.push(`due_date = $${paramIndex++}`); + params.push(updates.dueDate ? new Date(updates.dueDate) : null); + } + if (updates.isUrgent !== undefined) { + setClauses.push(`is_urgent = $${paramIndex++}`); + params.push(updates.isUrgent); + } + + params.push(id); + const sql = ` + UPDATE todo_items + SET ${setClauses.join(", ")} + WHERE id = $${paramIndex} + RETURNING + id, title, description, priority, status, + assigned_to as "assignedTo", + due_date as "dueDate", + created_at as "createdAt", + updated_at as "updatedAt", + completed_at as "completedAt", + is_urgent as "isUrgent", + display_order as "order" + `; + + const rows = await query(sql, params); + if (rows.length === 0) { + throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); + } + + const row = rows[0]; + return { + ...row, + dueDate: row.dueDate ? new Date(row.dueDate).toISOString() : undefined, + createdAt: new Date(row.createdAt).toISOString(), + updatedAt: new Date(row.updatedAt).toISOString(), + completedAt: row.completedAt ? new Date(row.completedAt).toISOString() : undefined, + }; + } + + private async deleteTodoDB(id: string): Promise { + const rows = await query("DELETE FROM todo_items WHERE id = $1 RETURNING id", [id]); + if (rows.length === 0) { + throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); + } + } + + private async reorderTodosDB(todoIds: string[]): Promise { + for (let i = 0; i < todoIds.length; i++) { + await query( + "UPDATE todo_items SET display_order = $1, updated_at = NOW() WHERE id = $2", + [i, todoIds[i]] + ); + } + } + + // ==================== FILE 메서드 ==================== + + private loadTodosFromFile(filter?: { + status?: string; + priority?: string; + assignedTo?: string; + }): TodoItem[] { + try { + const data = fs.readFileSync(TODO_FILE, "utf-8"); + let todos: TodoItem[] = JSON.parse(data); + + if (filter?.status) { + todos = todos.filter((t) => t.status === filter.status); + } + if (filter?.priority) { + todos = todos.filter((t) => t.priority === filter.priority); + } + if (filter?.assignedTo) { + todos = todos.filter((t) => t.assignedTo === filter.assignedTo); + } + + return todos; + } catch (error) { + logger.error("❌ To-Do 파일 로드 오류:", error); + return []; + } + } + + private saveTodosToFile(todos: TodoItem[]): void { + try { + fs.writeFileSync(TODO_FILE, JSON.stringify(todos, null, 2)); + } catch (error) { + logger.error("❌ To-Do 파일 저장 오류:", error); + throw error; + } + } + + private updateTodoFile(id: string, updates: Partial): TodoItem { + const todos = this.loadTodosFromFile(); + const index = todos.findIndex((t) => t.id === id); + + if (index === -1) { + throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); + } + + const updatedTodo: TodoItem = { + ...todos[index], + ...updates, + updatedAt: new Date().toISOString(), + }; + + if (updates.status === "completed" && todos[index].status !== "completed") { + updatedTodo.completedAt = new Date().toISOString(); + } + + todos[index] = updatedTodo; + this.saveTodosToFile(todos); + + return updatedTodo; + } + + private deleteTodoFile(id: string): void { + const todos = this.loadTodosFromFile(); + const filteredTodos = todos.filter((t) => t.id !== id); + + if (todos.length === filteredTodos.length) { + throw new Error(`To-Do 항목을 찾을 수 없습니다: ${id}`); + } + + this.saveTodosToFile(filteredTodos); + } + + private reorderTodosFile(todoIds: string[]): void { + const todos = this.loadTodosFromFile(); + + todoIds.forEach((id, index) => { + const todo = todos.find((t) => t.id === id); + if (todo) { + todo.order = index; + todo.updatedAt = new Date().toISOString(); + } + }); + + this.saveTodosToFile(todos); + } + + // ==================== 공통 메서드 ==================== + + private calculateStats(todos: TodoItem[]): TodoListResponse["stats"] { + const now = new Date(); + return { + total: todos.length, + pending: todos.filter((t) => t.status === "pending").length, + inProgress: todos.filter((t) => t.status === "in_progress").length, + completed: todos.filter((t) => t.status === "completed").length, + urgent: todos.filter((t) => t.isUrgent).length, + overdue: todos.filter((t) => t.dueDate && new Date(t.dueDate) < now && t.status !== "completed").length, + }; + } +} diff --git a/docs/리스크알림_API키_발급가이드.md b/docs/리스크알림_API키_발급가이드.md new file mode 100644 index 00000000..e2a33761 --- /dev/null +++ b/docs/리스크알림_API키_발급가이드.md @@ -0,0 +1,293 @@ +# 리스크/알림 위젯 API 키 발급 가이드 🚨 + +## 📌 개요 + +리스크/알림 위젯은 **공공데이터포털 API**를 사용합니다: + +1. ✅ **기상청 API** (날씨 특보) - **이미 설정됨!** +2. 🔧 **국토교통부 도로교통 API** (교통사고, 도로공사) - **신규 발급 필요** + +--- + +## 🔑 1. 기상청 특보 API (이미 설정됨 ✅) + +현재 `.env`에 설정된 키: +```bash +KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA +``` + +**사용 API:** +- 기상특보조회서비스 (기상청) +- URL: https://www.data.go.kr/data/15000415/openapi.do + +**제공 정보:** +- ☁️ 대설특보 +- 🌀 태풍특보 +- 💨 강풍특보 +- 🌊 호우특보 + +--- + +## 🚗 2. 국토교통부 도로교통 API (신규 발급) + +### 2️⃣-1. 공공데이터포털 회원가입 + +``` +👉 https://www.data.go.kr +``` + +1. 우측 상단 **회원가입** 클릭 +2. 이메일 입력 및 인증 +3. 약관 동의 후 가입 완료 + +--- + +### 2️⃣-2. API 활용신청 + +#### A. 실시간 교통사고 정보 + +``` +👉 https://www.data.go.kr/data/15098913/openapi.do +``` + +**"실시간 교통사고 정보제공 서비스"** 페이지에서: + +1. **활용신청** 버튼 클릭 +2. 활용 목적: `기타` +3. 상세 기능 설명: `물류 대시보드 리스크 알림` +4. 신청 완료 + +#### B. 도로공사 및 통제 정보 + +``` +👉 https://www.data.go.kr/data/15071004/openapi.do +``` + +**"도로공사 및 통제정보 제공 서비스"** 페이지에서: + +1. **활용신청** 버튼 클릭 +2. 활용 목적: `기타` +3. 상세 기능 설명: `물류 대시보드 리스크 알림` +4. 신청 완료 + +⚠️ **승인까지 약 2-3시간 소요** (즉시 승인되는 경우도 있음) + +--- + +### 2️⃣-3. 인증키 확인 + +``` +👉 https://www.data.go.kr/mypage/myPageOpenAPI.do +``` + +**마이페이지 > 오픈API > 인증키**에서: + +1. **일반 인증키(Encoding)** 복사 +2. 긴 문자열 전체를 복사하세요! + +**예시:** +``` +aBc1234dEf5678gHi9012jKl3456mNo7890pQr1234sTu5678vWx9012yZa3456bCd7890== +``` + +--- + +## ⚙️ 환경 변수 설정 + +### .env 파일 수정 + +```bash +cd /Users/leeheejin/ERP-node/backend-node +nano .env +``` + +### 다음 내용 **추가**: + +```bash +# 기상청 API Hub 키 (기존 - 예특보 활용신청 완료 시 사용) +KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA + +# 국토교통부 도로교통 API 키 (활용신청 완료 시 추가) +MOLIT_TRAFFIC_API_KEY=여기에_발급받은_교통사고_API_인증키_붙여넣기 +MOLIT_ROADWORK_API_KEY=여기에_발급받은_도로공사_API_인증키_붙여넣기 +``` + +⚠️ **주의사항:** +- API 활용신청이 **승인되기 전**에는 더미 데이터를 사용합니다 +- **승인 후** API 키만 추가하면 **자동으로 실제 데이터로 전환**됩니다 +- 승인 여부는 각 포털의 마이페이지에서 확인 가능합니다 + +### 저장 및 종료 +- `Ctrl + O` (저장) +- `Enter` (확인) +- `Ctrl + X` (종료) + +--- + +## 🔄 백엔드 재시작 + +```bash +docker restart pms-backend-mac +``` + +--- + +## 📊 사용 가능한 API 정보 + +### 1️⃣ 기상청 특보 (KMA_API_KEY) + +**엔드포인트:** +``` +GET /api/risk-alerts/weather +``` + +**응답 예시:** +```json +{ + "success": true, + "data": [ + { + "id": "weather-001", + "type": "weather", + "severity": "high", + "title": "대설특보", + "location": "강원 영동지역", + "description": "시간당 2cm 이상 폭설 예상", + "timestamp": "2024-10-14T10:00:00Z" + } + ] +} +``` + +--- + +### 2️⃣ 교통사고 (MOLIT_TRAFFIC_API_KEY) + +**엔드포인트:** +``` +GET /api/risk-alerts/accidents +``` + +**응답 예시:** +```json +{ + "success": true, + "data": [ + { + "id": "accident-001", + "type": "accident", + "severity": "high", + "title": "교통사고 발생", + "location": "경부고속도로 서울방향 189km", + "description": "3중 추돌사고로 2차로 통제 중", + "timestamp": "2024-10-14T10:00:00Z" + } + ] +} +``` + +--- + +### 3️⃣ 도로공사 (MOLIT_ROADWORK_API_KEY) + +**엔드포인트:** +``` +GET /api/risk-alerts/roadworks +``` + +**응답 예시:** +```json +{ + "success": true, + "data": [ + { + "id": "construction-001", + "type": "construction", + "severity": "medium", + "title": "도로 공사", + "location": "서울외곽순환 목동IC~화곡IC", + "description": "야간 공사로 1차로 통제 (22:00~06:00)", + "timestamp": "2024-10-14T10:00:00Z" + } + ] +} +``` + +--- + +## ✅ 테스트 + +### 1. API 키 발급 확인 +```bash +curl "https://www.data.go.kr/mypage/myPageOpenAPI.do" +``` + +### 2. 백엔드 API 테스트 +```bash +# 날씨 특보 +curl "http://localhost:9771/api/risk-alerts/weather" \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 교통사고 +curl "http://localhost:9771/api/risk-alerts/accidents" \ + -H "Authorization: Bearer YOUR_TOKEN" + +# 도로공사 +curl "http://localhost:9771/api/risk-alerts/roadworks" \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +### 3. 대시보드에서 위젯 확인 +1. `http://localhost:9771/admin/dashboard` 접속 +2. 우측 사이드바 → **🚨 리스크 / 알림** 드래그 +3. 실시간 정보 확인! + +--- + +## 🔧 트러블슈팅 + +### 1. "API 키가 유효하지 않습니다" 오류 + +**원인**: API 키가 잘못되었거나 활성화되지 않음 + +**해결방법**: +1. 공공데이터포털에서 API 키 재확인 +2. 신청 후 **승인 대기** 상태인지 확인 (2-3시간 소요) +3. `.env` 파일에 복사한 키가 정확한지 확인 +4. 백엔드 재시작 (`docker restart pms-backend-mac`) + +--- + +### 2. "서비스가 허용되지 않습니다" 오류 + +**원인**: 신청한 API와 요청한 서비스가 다름 + +**해결방법**: +1. 공공데이터포털 마이페이지에서 **신청한 서비스 목록** 확인 +2. 필요한 서비스를 **모두 신청**했는지 확인 +3. 승인 완료 상태인지 확인 + +--- + +### 3. 데이터가 표시되지 않음 + +**원인**: API 응답 형식 변경 또는 서비스 중단 + +**해결방법**: +1. 공공데이터포털 **공지사항** 확인 +2. API 문서에서 **응답 형식** 확인 +3. 백엔드 로그 확인 (`docker logs pms-backend-mac`) + +--- + +## 💡 참고 링크 + +- 공공데이터포털: https://www.data.go.kr +- 기상청 Open API: https://data.kma.go.kr +- 국토교통부 Open API: https://www.its.go.kr +- API 활용 가이드: https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do + +--- + +**완료되면 브라우저 새로고침 (Cmd + R) 하세요!** 🚨✨ + diff --git a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx index f01e31ad..0705d77b 100644 --- a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx +++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx @@ -1,13 +1,14 @@ -'use client'; +"use client"; -import React, { useState, useEffect } from 'react'; -import { DashboardViewer } from '@/components/dashboard/DashboardViewer'; -import { DashboardElement } from '@/components/admin/dashboard/types'; +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"; interface DashboardViewPageProps { - params: { + params: Promise<{ dashboardId: string; - }; + }>; } /** @@ -17,6 +18,8 @@ interface DashboardViewPageProps { * - 전체화면 모드 지원 */ export default function DashboardViewPage({ params }: DashboardViewPageProps) { + const router = useRouter(); + const resolvedParams = use(params); const [dashboard, setDashboard] = useState<{ id: string; title: string; @@ -31,7 +34,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { // 대시보드 데이터 로딩 useEffect(() => { loadDashboard(); - }, [params.dashboardId]); + }, [resolvedParams.dashboardId]); const loadDashboard = async () => { setIsLoading(true); @@ -39,29 +42,29 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { try { // 실제 API 호출 시도 - const { dashboardApi } = await import('@/lib/api/dashboard'); - + const { dashboardApi } = await import("@/lib/api/dashboard"); + try { - const dashboardData = await dashboardApi.getDashboard(params.dashboardId); + const dashboardData = await dashboardApi.getDashboard(resolvedParams.dashboardId); setDashboard(dashboardData); } catch (apiError) { - console.warn('API 호출 실패, 로컬 스토리지 확인:', apiError); - + console.warn("API 호출 실패, 로컬 스토리지 확인:", apiError); + // API 실패 시 로컬 스토리지에서 찾기 - const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]'); - const savedDashboard = savedDashboards.find((d: any) => d.id === params.dashboardId); - + const savedDashboards = JSON.parse(localStorage.getItem("savedDashboards") || "[]"); + const savedDashboard = savedDashboards.find((d: any) => d.id === resolvedParams.dashboardId); + if (savedDashboard) { setDashboard(savedDashboard); } else { // 로컬에도 없으면 샘플 데이터 사용 - const sampleDashboard = generateSampleDashboard(params.dashboardId); + const sampleDashboard = generateSampleDashboard(resolvedParams.dashboardId); setDashboard(sampleDashboard); } } } catch (err) { - setError('대시보드를 불러오는 중 오류가 발생했습니다.'); - console.error('Dashboard loading error:', err); + setError("대시보드를 불러오는 중 오류가 발생했습니다."); + console.error("Dashboard loading error:", err); } finally { setIsLoading(false); } @@ -70,11 +73,11 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { // 로딩 상태 if (isLoading) { return ( -
+
-
+
대시보드 로딩 중...
-
잠시만 기다려주세요
+
잠시만 기다려주세요
); @@ -83,19 +86,12 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { // 에러 상태 if (error || !dashboard) { return ( -
+
-
😞
-
- {error || '대시보드를 찾을 수 없습니다'} -
-
- 대시보드 ID: {params.dashboardId} -
-
@@ -106,25 +102,23 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { return (
{/* 대시보드 헤더 */} -
-
+
+

{dashboard.title}

- {dashboard.description && ( -

{dashboard.description}

- )} + {dashboard.description &&

{dashboard.description}

}
- +
{/* 새로고침 버튼 */} - + {/* 전체화면 버튼 */} - + {/* 편집 버튼 */}
- + {/* 메타 정보 */} -
+
생성: {new Date(dashboard.createdAt).toLocaleString()} 수정: {new Date(dashboard.updatedAt).toLocaleString()} 요소: {dashboard.elements.length}개 @@ -162,10 +156,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { {/* 대시보드 뷰어 */}
- +
); @@ -176,111 +167,113 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { */ function generateSampleDashboard(dashboardId: string) { const dashboards: Record = { - 'sales-overview': { - id: 'sales-overview', - title: '📊 매출 현황 대시보드', - description: '월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.', + "sales-overview": { + id: "sales-overview", + title: "📊 매출 현황 대시보드", + description: "월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.", elements: [ { - id: 'chart-1', - type: 'chart', - subtype: 'bar', + id: "chart-1", + type: "chart", + subtype: "bar", position: { x: 20, y: 20 }, size: { width: 400, height: 300 }, - title: '📊 월별 매출 추이', - content: '월별 매출 데이터', + title: "📊 월별 매출 추이", + content: "월별 매출 데이터", dataSource: { - type: 'database', - query: 'SELECT month, sales FROM monthly_sales', - refreshInterval: 30000 + type: "database", + query: "SELECT month, sales FROM monthly_sales", + refreshInterval: 30000, }, chartConfig: { - xAxis: 'month', - yAxis: 'sales', - title: '월별 매출 추이', - colors: ['#3B82F6', '#EF4444', '#10B981'] - } + xAxis: "month", + yAxis: "sales", + title: "월별 매출 추이", + colors: ["#3B82F6", "#EF4444", "#10B981"], + }, }, { - id: 'chart-2', - type: 'chart', - subtype: 'pie', + id: "chart-2", + type: "chart", + subtype: "pie", position: { x: 450, y: 20 }, size: { width: 350, height: 300 }, - title: '🥧 상품별 판매 비율', - content: '상품별 판매 데이터', + title: "🥧 상품별 판매 비율", + content: "상품별 판매 데이터", dataSource: { - type: 'database', - query: 'SELECT product_name, total_sold FROM product_sales', - refreshInterval: 60000 + type: "database", + query: "SELECT product_name, total_sold FROM product_sales", + refreshInterval: 60000, }, chartConfig: { - xAxis: 'product_name', - yAxis: 'total_sold', - title: '상품별 판매 비율', - colors: ['#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'] - } + xAxis: "product_name", + yAxis: "total_sold", + title: "상품별 판매 비율", + colors: ["#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"], + }, }, { - id: 'chart-3', - type: 'chart', - subtype: 'line', + id: "chart-3", + type: "chart", + subtype: "line", position: { x: 20, y: 350 }, size: { width: 780, height: 250 }, - title: '📈 사용자 가입 추이', - content: '사용자 가입 데이터', + title: "📈 사용자 가입 추이", + content: "사용자 가입 데이터", dataSource: { - type: 'database', - query: 'SELECT week, new_users FROM user_growth', - refreshInterval: 300000 + type: "database", + query: "SELECT week, new_users FROM user_growth", + refreshInterval: 300000, }, chartConfig: { - xAxis: 'week', - yAxis: 'new_users', - title: '주간 신규 사용자 가입 추이', - colors: ['#10B981'] - } - } + xAxis: "week", + yAxis: "new_users", + title: "주간 신규 사용자 가입 추이", + colors: ["#10B981"], + }, + }, ], - createdAt: '2024-09-30T10:00:00Z', - updatedAt: '2024-09-30T14:30:00Z' + createdAt: "2024-09-30T10:00:00Z", + updatedAt: "2024-09-30T14:30:00Z", }, - 'user-analytics': { - id: 'user-analytics', - title: '👥 사용자 분석 대시보드', - description: '사용자 행동 패턴 및 가입 추이 분석', + "user-analytics": { + id: "user-analytics", + title: "👥 사용자 분석 대시보드", + description: "사용자 행동 패턴 및 가입 추이 분석", elements: [ { - id: 'chart-4', - type: 'chart', - subtype: 'line', + id: "chart-4", + type: "chart", + subtype: "line", position: { x: 20, y: 20 }, size: { width: 500, height: 300 }, - title: '📈 일일 활성 사용자', - content: '사용자 활동 데이터', + title: "📈 일일 활성 사용자", + content: "사용자 활동 데이터", dataSource: { - type: 'database', - query: 'SELECT date, active_users FROM daily_active_users', - refreshInterval: 60000 + type: "database", + query: "SELECT date, active_users FROM daily_active_users", + refreshInterval: 60000, }, chartConfig: { - xAxis: 'date', - yAxis: 'active_users', - title: '일일 활성 사용자 추이' - } - } + xAxis: "date", + yAxis: "active_users", + title: "일일 활성 사용자 추이", + }, + }, ], - createdAt: '2024-09-29T15:00:00Z', - updatedAt: '2024-09-30T09:15:00Z' - } + createdAt: "2024-09-29T15:00:00Z", + updatedAt: "2024-09-30T09:15:00Z", + }, }; - return dashboards[dashboardId] || { - id: dashboardId, - title: `대시보드 ${dashboardId}`, - description: '샘플 대시보드입니다.', - elements: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }; + return ( + dashboards[dashboardId] || { + id: dashboardId, + title: `대시보드 ${dashboardId}`, + description: "샘플 대시보드입니다.", + elements: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + ); } diff --git a/frontend/components/admin/dashboard/CALENDAR_WIDGET_PLAN.md b/frontend/components/admin/dashboard/CALENDAR_WIDGET_PLAN.md new file mode 100644 index 00000000..e127be43 --- /dev/null +++ b/frontend/components/admin/dashboard/CALENDAR_WIDGET_PLAN.md @@ -0,0 +1,228 @@ +# 📅 달력 위젯 구현 계획 + +## 개요 + +대시보드에 추가할 수 있는 달력 위젯을 구현합니다. 사용자가 날짜를 확인하고 일정을 관리할 수 있는 인터랙티브한 달력 기능을 제공합니다. + +## 주요 기능 + +### 1. 달력 뷰 타입 + +- **월간 뷰**: 한 달 전체를 보여주는 기본 뷰 +- **주간 뷰**: 일주일을 세로로 보여주는 뷰 +- **일간 뷰**: 하루의 시간대별 일정 뷰 + +### 2. 달력 설정 + +- **시작 요일**: 월요일 시작 / 일요일 시작 선택 +- **주말 강조**: 주말 색상 다르게 표시 +- **오늘 날짜 강조**: 오늘 날짜 하이라이트 +- **공휴일 표시**: 한국 공휴일 표시 (선택 사항) + +### 3. 테마 및 스타일 + +- **Light 테마**: 밝은 배경 +- **Dark 테마**: 어두운 배경 +- **사용자 지정**: 커스텀 색상 선택 + +### 4. 일정 기능 (향후 확장) + +- 간단한 메모 추가 +- 일정 표시 (외부 연동) + +## 구현 단계 + +### ✅ Step 1: 타입 정의 + +- [x] `CalendarConfig` 인터페이스 정의 +- [x] `types.ts`에 달력 설정 타입 추가 +- [x] 요소 타입에 'calendar' subtype 추가 + +### ✅ Step 2: 기본 달력 컴포넌트 + +- [x] `CalendarWidget.tsx` - 메인 위젯 컴포넌트 +- [x] `MonthView.tsx` - 월간 달력 뷰 +- [x] 날짜 계산 유틸리티 함수 (`calendarUtils.ts`) +- [ ] `WeekView.tsx` - 주간 달력 뷰 (향후 추가) + +### ✅ Step 3: 달력 네비게이션 + +- [x] 이전/다음 월 이동 버튼 +- [x] 오늘로 돌아가기 버튼 +- [ ] 월/연도 선택 드롭다운 (향후 추가) + +### ✅ Step 4: 설정 UI + +- [x] `CalendarSettings.tsx` - Popover 내장 설정 컴포넌트 +- [x] 뷰 타입 선택 (월간 - 현재 구현) +- [x] 시작 요일 설정 +- [x] 테마 선택 (light/dark/custom) +- [x] 표시 옵션 (주말 강조, 공휴일, 오늘 강조) + +### ✅ Step 5: 스타일링 + +- [x] 달력 그리드 레이아웃 +- [x] 날짜 셀 디자인 +- [x] 오늘 날짜 하이라이트 +- [x] 주말/평일 구분 +- [x] 반응형 디자인 (크기별 최적화) + +### ✅ Step 6: 통합 + +- [x] `DashboardSidebar`에 달력 위젯 추가 +- [x] `CanvasElement`에서 달력 위젯 렌더링 +- [x] `DashboardDesigner`에 기본값 설정 + +### ✅ Step 7: 공휴일 데이터 + +- [x] 한국 공휴일 데이터 정의 +- [x] 공휴일 표시 기능 +- [x] 공휴일 이름 툴팁 + +### ✅ Step 8: 테스트 및 최적화 + +- [ ] 다양한 크기에서 테스트 (사용자 테스트 필요) +- [x] 날짜 계산 로직 검증 +- [ ] 성능 최적화 (필요시) +- [ ] 접근성 개선 (필요시) + +## 기술 스택 + +### UI 컴포넌트 + +- **shadcn/ui**: Button, Select, Switch, Popover, Card +- **lucide-react**: Settings, ChevronLeft, ChevronRight, Calendar + +### 날짜 처리 + +- **JavaScript Date API**: 기본 날짜 계산 +- **Intl.DateTimeFormat**: 날짜 형식화 +- 외부 라이브러리 없이 순수 구현 + +### 스타일링 + +- **Tailwind CSS**: 반응형 그리드 레이아웃 +- **CSS Grid**: 달력 레이아웃 + +## 컴포넌트 구조 + +``` +widgets/ +├── CalendarWidget.tsx # 메인 위젯 (설정 버튼 포함) +├── CalendarSettings.tsx # 설정 UI (Popover 내부) +├── MonthView.tsx # 월간 뷰 +├── WeekView.tsx # 주간 뷰 (선택) +├── DayView.tsx # 일간 뷰 (선택) +└── calendarUtils.ts # 날짜 계산 유틸리티 +``` + +## 데이터 구조 + +```typescript +interface CalendarConfig { + view: "month" | "week" | "day"; // 뷰 타입 + startWeekOn: "monday" | "sunday"; // 주 시작 요일 + highlightWeekends: boolean; // 주말 강조 + highlightToday: boolean; // 오늘 강조 + showHolidays: boolean; // 공휴일 표시 + theme: "light" | "dark" | "custom"; // 테마 + customColor?: string; // 사용자 지정 색상 + showWeekNumbers?: boolean; // 주차 표시 (선택) +} +``` + +## UI/UX 고려사항 + +### 반응형 디자인 + +- **2x2**: 미니 달력 (월간 뷰만, 날짜만 표시) +- **3x3**: 기본 달력 (월간 뷰, 요일 헤더 포함) +- **4x4 이상**: 풀 달력 (모든 기능, 일정 표시 가능) + +### 인터랙션 + +- 날짜 클릭 시 해당 날짜 정보 표시 (선택) +- 드래그로 월 변경 (선택) +- 키보드 네비게이션 지원 + +### 접근성 + +- 날짜 셀에 적절한 aria-label +- 키보드 네비게이션 지원 +- 스크린 리더 호환 + +## 공휴일 데이터 구조 + +```typescript +interface Holiday { + date: string; // 'MM-DD' 형식 + name: string; // 공휴일 이름 + isRecurring: boolean; // 매년 반복 여부 +} + +// 2025년 한국 공휴일 예시 +const KOREAN_HOLIDAYS: Holiday[] = [ + { date: "01-01", name: "신정", isRecurring: true }, + { date: "01-28", name: "설날 연휴", isRecurring: false }, + { date: "01-29", name: "설날", isRecurring: false }, + { date: "01-30", name: "설날 연휴", isRecurring: false }, + { date: "03-01", name: "삼일절", isRecurring: true }, + { date: "05-05", name: "어린이날", isRecurring: true }, + { date: "06-06", name: "현충일", isRecurring: true }, + { date: "08-15", name: "광복절", isRecurring: true }, + { date: "10-03", name: "개천절", isRecurring: true }, + { date: "10-09", name: "한글날", isRecurring: true }, + { date: "12-25", name: "크리스마스", isRecurring: true }, +]; +``` + +## 향후 확장 기능 + +### Phase 2 (선택) + +- [ ] 일정 추가/수정/삭제 +- [ ] 반복 일정 설정 +- [ ] 카테고리별 색상 구분 +- [ ] 다른 달력 서비스 연동 (Google Calendar, Outlook 등) +- [ ] 일정 알림 기능 +- [ ] 드래그 앤 드롭으로 일정 이동 + +### Phase 3 (선택) + +- [ ] 여러 달력 레이어 지원 +- [ ] 일정 검색 기능 +- [ ] 월별 통계 (일정 개수 등) +- [ ] CSV/iCal 내보내기 + +## 참고사항 + +### 장점 + +- 순수 JavaScript로 구현 (외부 의존성 최소화) +- shadcn/ui 컴포넌트 활용으로 일관된 디자인 +- 시계 위젯과 동일한 패턴 (내장 설정 UI) + +### 주의사항 + +- 날짜 계산 로직 정확성 검증 필요 +- 윤년 처리 +- 타임존 고려 (필요시) +- 다양한 크기에서의 가독성 + +## 완료 기준 + +- [x] 월간 뷰 달력이 정확하게 표시됨 +- [x] 이전/다음 월 네비게이션이 작동함 +- [x] 오늘 날짜가 하이라이트됨 +- [x] 주말이 다른 색상으로 표시됨 +- [x] 공휴일이 표시되고 이름이 보임 +- [x] 설정 UI에서 모든 옵션을 변경할 수 있음 +- [x] 테마 변경이 즉시 반영됨 +- [x] 2x2 크기에서도 깔끔하게 표시됨 +- [x] 4x4 크기에서 모든 기능이 정상 작동함 + +--- + +## 구현 시작 + +이제 단계별로 구현을 시작합니다! diff --git a/frontend/components/admin/dashboard/CHART_SYSTEM_PLAN.md b/frontend/components/admin/dashboard/CHART_SYSTEM_PLAN.md new file mode 100644 index 00000000..798a409b --- /dev/null +++ b/frontend/components/admin/dashboard/CHART_SYSTEM_PLAN.md @@ -0,0 +1,742 @@ +# 📊 차트 시스템 구현 계획 + +## 개요 + +D3.js 기반의 강력한 차트 시스템을 구축합니다. 사용자는 데이터를 두 가지 방법(DB 쿼리 또는 REST API)으로 가져와 다양한 차트로 시각화할 수 있습니다. + +--- + +## 🎯 핵심 요구사항 + +### 1. 데이터 소스 (2가지 방식) + +#### A. 데이터베이스 커넥션 + +- **현재 DB**: 애플리케이션의 기본 PostgreSQL 연결 +- **외부 DB**: 기존 "외부 커넥션 관리" 메뉴에서 등록된 커넥션만 사용 + - 신규 커넥션 생성은 외부 커넥션 관리 메뉴에서만 가능 + - 차트 설정에서는 등록된 커넥션 목록에서 선택만 가능 +- **쿼리 제한**: SELECT 문만 허용 (INSERT, UPDATE, DELETE, DROP 등 금지) +- **쿼리 검증**: 서버 측에서 SQL Injection 방지 및 쿼리 타입 검증 + +#### B. REST API 호출 + +- **HTTP Methods**: GET (권장) - 데이터 조회에 충분 +- **데이터 형식**: JSON 응답만 허용 +- **헤더 설정**: Authorization, Content-Type 등 커스텀 헤더 지원 +- **쿼리 파라미터**: URL 파라미터로 필터링 조건 전달 +- **응답 파싱**: JSON 구조에서 차트 데이터 추출 +- **에러 처리**: HTTP 상태 코드 및 타임아웃 처리 + +> **참고**: POST는 향후 확장 (GraphQL, 복잡한 필터링)을 위해 선택적으로 지원 가능 + +### 2. 차트 타입 (D3.js 기반) + +현재 지원 예정: + +- **Bar Chart** (막대 차트): 수평/수직 막대 +- **Line Chart** (선 차트): 단일/다중 시리즈 +- **Area Chart** (영역 차트): 누적 영역 지원 +- **Pie Chart** (원 차트): 도넛 차트 포함 +- **Stacked Bar** (누적 막대): 다중 시리즈 누적 +- **Combo Chart** (혼합 차트): 막대 + 선 조합 + +### 3. 축 매핑 설정 + +- **X축**: 카테고리/시간 데이터 (문자열, 날짜) +- **Y축**: 숫자 데이터 (단일 또는 다중 선택 가능) +- **다중 Y축**: 여러 시리즈를 한 차트에 표시 (예: 갤럭시 vs 아이폰 매출) +- **자동 감지**: 데이터 타입에 따라 축 자동 추천 +- **데이터 변환**: 문자열 날짜를 Date 객체로 자동 변환 + +### 4. 차트 스타일링 + +- **색상 팔레트**: 사전 정의된 색상 세트 선택 +- **커스텀 색상**: 사용자 지정 색상 입력 +- **범례**: 위치 설정 (상단, 하단, 좌측, 우측, 숨김) +- **애니메이션**: 차트 로드 시 부드러운 전환 효과 +- **툴팁**: 데이터 포인트 호버 시 상세 정보 표시 +- **그리드**: X/Y축 그리드 라인 표시/숨김 + +--- + +## 📁 파일 구조 + +``` +frontend/components/admin/dashboard/ +├── CHART_SYSTEM_PLAN.md # 이 파일 +├── types.ts # ✅ 기존 (타입 확장 필요) +├── ElementConfigModal.tsx # ✅ 기존 (리팩토링 필요) +│ +├── data-sources/ # 🆕 데이터 소스 관련 +│ ├── DataSourceSelector.tsx # 데이터 소스 선택 UI (DB vs API) +│ ├── DatabaseConfig.tsx # DB 커넥션 설정 UI +│ ├── ApiConfig.tsx # REST API 설정 UI +│ └── dataSourceUtils.ts # 데이터 소스 유틸리티 +│ +├── chart-config/ # 🔄 차트 설정 관련 (리팩토링) +│ ├── QueryEditor.tsx # ✅ 기존 (확장 필요) +│ ├── ChartConfigPanel.tsx # ✅ 기존 (확장 필요) +│ ├── AxisMapper.tsx # 🆕 축 매핑 UI +│ ├── StyleConfig.tsx # 🆕 스타일 설정 UI +│ └── ChartPreview.tsx # 🆕 실시간 미리보기 +│ +├── charts/ # 🆕 D3 차트 컴포넌트 +│ ├── ChartRenderer.tsx # 차트 렌더러 (메인) +│ ├── BarChart.tsx # 막대 차트 +│ ├── LineChart.tsx # 선 차트 +│ ├── AreaChart.tsx # 영역 차트 +│ ├── PieChart.tsx # 원 차트 +│ ├── StackedBarChart.tsx # 누적 막대 차트 +│ ├── ComboChart.tsx # 혼합 차트 +│ ├── chartUtils.ts # 차트 유틸리티 +│ └── d3Helpers.ts # D3 헬퍼 함수 +│ +└── CanvasElement.tsx # ✅ 기존 (차트 렌더링 통합) +``` + +--- + +## 🔧 타입 정의 확장 + +### 기존 타입 업데이트 + +```typescript +// types.ts + +// 데이터 소스 타입 확장 +export interface ChartDataSource { + type: "database" | "api"; // 'static' 제거 + + // DB 커넥션 관련 + connectionType?: "current" | "external"; // 현재 DB vs 외부 DB + externalConnectionId?: string; // 외부 DB 커넥션 ID + query?: string; // SQL 쿼리 (SELECT만) + + // API 관련 + endpoint?: string; // API URL + method?: "GET"; // HTTP 메서드 (GET만 지원) + headers?: Record; // 커스텀 헤더 + queryParams?: Record; // URL 쿼리 파라미터 + jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results") + + // 공통 + refreshInterval?: number; // 자동 새로고침 (초) + lastExecuted?: string; // 마지막 실행 시간 + lastError?: string; // 마지막 오류 메시지 +} + +// 외부 DB 커넥션 정보 (기존 외부 커넥션 관리에서 가져옴) +export interface ExternalConnection { + id: string; + name: string; // 사용자 지정 이름 (표시용) + type: "postgresql" | "mysql" | "mssql" | "oracle"; + // 나머지 정보는 외부 커넥션 관리에서만 관리 +} + +// 차트 설정 확장 +export interface ChartConfig { + // 축 매핑 + xAxis: string; // X축 필드명 + yAxis: string | string[]; // Y축 필드명 (다중 가능) + + // 데이터 처리 + groupBy?: string; // 그룹핑 필드 + aggregation?: "sum" | "avg" | "count" | "max" | "min"; + sortBy?: string; // 정렬 기준 필드 + sortOrder?: "asc" | "desc"; // 정렬 순서 + limit?: number; // 데이터 개수 제한 + + // 스타일 + colors?: string[]; // 차트 색상 팔레트 + title?: string; // 차트 제목 + showLegend?: boolean; // 범례 표시 + legendPosition?: "top" | "bottom" | "left" | "right"; // 범례 위치 + + // 축 설정 + xAxisLabel?: string; // X축 라벨 + yAxisLabel?: string; // Y축 라벨 + showGrid?: boolean; // 그리드 표시 + + // 애니메이션 + enableAnimation?: boolean; // 애니메이션 활성화 + animationDuration?: number; // 애니메이션 시간 (ms) + + // 툴팁 + showTooltip?: boolean; // 툴팁 표시 + tooltipFormat?: string; // 툴팁 포맷 (템플릿) + + // 차트별 특수 설정 + barOrientation?: "vertical" | "horizontal"; // 막대 방향 + lineStyle?: "smooth" | "straight"; // 선 스타일 + areaOpacity?: number; // 영역 투명도 + pieInnerRadius?: number; // 도넛 차트 내부 반지름 (0-1) + stackMode?: "normal" | "percent"; // 누적 모드 +} + +// API 응답 구조 +export interface ApiResponse { + success: boolean; + data: T; + message?: string; + error?: string; +} + +// 차트 데이터 (변환 후) +export interface ChartData { + labels: string[]; // X축 레이블 + datasets: ChartDataset[]; // Y축 데이터셋 (다중 시리즈) +} + +export interface ChartDataset { + label: string; // 시리즈 이름 + data: number[]; // 데이터 값 + color?: string; // 색상 +} +``` + +--- + +## 📝 구현 단계 + +### Phase 1: 데이터 소스 설정 UI (4-5시간) + +#### Step 1.1: 데이터 소스 선택기 + +- [x] `DataSourceSelector.tsx` 생성 +- [x] DB vs API 선택 라디오 버튼 +- [x] 선택에 따라 하위 UI 동적 렌더링 +- [x] 상태 관리 (현재 선택된 소스 타입) + +#### Step 1.2: 데이터베이스 설정 + +- [x] `DatabaseConfig.tsx` 생성 +- [x] 현재 DB / 외부 DB 선택 라디오 버튼 +- [x] 외부 DB 선택 시: + - **기존 외부 커넥션 관리에서 등록된 커넥션 목록 불러오기** + - 드롭다운으로 커넥션 선택 (ID, 이름, 타입 표시) + - "외부 커넥션 관리로 이동" 링크 제공 + - 선택된 커넥션 정보 표시 (읽기 전용) +- [x] SQL 에디터 통합 (기존 `QueryEditor` 재사용) +- [x] 쿼리 테스트 버튼 (선택된 커넥션으로 실행) + +#### Step 1.3: REST API 설정 + +- [x] `ApiConfig.tsx` 생성 +- [x] API 엔드포인트 URL 입력 +- [x] HTTP 메서드: GET 고정 (UI에서 표시만) +- [x] URL 쿼리 파라미터 추가 UI (키-값 쌍) + - 동적 파라미터 추가/제거 버튼 + - 예시: `?category=electronics&limit=10` +- [x] 헤더 추가 UI (키-값 쌍) + - Authorization 헤더 빠른 입력 + - 일반적인 헤더 템플릿 제공 +- [x] JSON Path 설정 (데이터 추출 경로) + - 예시: `data.results`, `items`, `response.data` +- [x] 테스트 요청 버튼 +- [x] 응답 미리보기 (JSON 구조 표시) + +#### Step 1.4: 데이터 소스 유틸리티 + +- [x] `dataSourceUtils.ts` 생성 +- [x] DB 커넥션 검증 함수 +- [x] API 요청 실행 함수 +- [x] JSON Path 파싱 함수 +- [x] 데이터 정규화 함수 (DB/API 결과를 통일된 형식으로) + +### Phase 2: 서버 측 API 구현 (1-2시간) ✅ 대부분 구현 완료 + +#### Step 2.1: 외부 커넥션 목록 조회 API ✅ 구현 완료 + +- [x] `GET /api/external-db-connections` - 기존 외부 커넥션 관리의 커넥션 목록 조회 +- [x] 프론트엔드 API: `ExternalDbConnectionAPI.getConnections({ is_active: 'Y' })` +- [x] 응답: `{ id, connection_name, db_type, ... }` +- [x] 인증된 사용자만 접근 가능 +- [x] **이미 구현되어 있음!** + +#### Step 2.2: 쿼리 실행 API ✅ 외부 DB 완료, 현재 DB 확인 필요 + +**외부 DB 쿼리 실행 ✅ 구현 완료** + +- [x] `POST /api/external-db-connections/:id/execute` - 외부 DB 쿼리 실행 +- [x] 프론트엔드 API: `ExternalDbConnectionAPI.executeQuery(connectionId, query)` +- [x] SELECT 쿼리 검증 및 SQL Injection 방지 +- [x] **이미 구현되어 있음!** + +**현재 DB 쿼리 실행 - 확인 필요** + +- [ ] `POST /api/dashboards/execute-query` - 현재 DB 쿼리 실행 (이미 있는지 확인 필요) +- [ ] SELECT 쿼리 검증 (정규식 + SQL 파서) +- [ ] SQL Injection 방지 +- [ ] 쿼리 타임아웃 설정 +- [ ] 결과 행 수 제한 (최대 1000행) +- [ ] 에러 핸들링 및 로깅 + +#### Step 2.3: REST API 프록시 ❌ 불필요 (CORS 허용된 Open API 사용) + +- [x] ~~GET /api/dashboards/fetch-api~~ - 불필요 (프론트엔드에서 직접 호출) +- [x] Open API는 CORS를 허용하므로 프록시 없이 직접 호출 가능 +- [x] `ApiConfig.tsx`에서 `fetch()` 직접 사용 + +### Phase 3: 차트 설정 UI 개선 (3-4시간) + +#### Step 3.1: 축 매퍼 + +- [ ] `AxisMapper.tsx` 생성 +- [ ] X축 필드 선택 드롭다운 +- [ ] Y축 필드 다중 선택 (체크박스) +- [ ] 데이터 타입 자동 감지 및 표시 +- [ ] 샘플 데이터 미리보기 (첫 3행) +- [ ] 축 라벨 커스터마이징 + +#### Step 3.2: 스타일 설정 + +- [ ] `StyleConfig.tsx` 생성 +- [ ] 색상 팔레트 선택 (사전 정의 + 커스텀) +- [ ] 범례 위치 선택 +- [ ] 그리드 표시/숨김 +- [ ] 애니메이션 설정 +- [ ] 차트별 특수 옵션 + - 막대 차트: 수평/수직 + - 선 차트: 부드러움 정도 + - 원 차트: 도넛 모드 + +#### Step 3.3: 실시간 미리보기 + +- [ ] `ChartPreview.tsx` 생성 +- [ ] 축소된 차트 미리보기 (300x200) +- [ ] 설정 변경 시 실시간 업데이트 +- [ ] 로딩 상태 표시 +- [ ] 에러 표시 + +### Phase 4: D3 차트 컴포넌트 (6-8시간) + +#### Step 4.1: 차트 렌더러 (공통) + +- [ ] `ChartRenderer.tsx` 생성 +- [ ] 차트 타입에 따라 적절한 컴포넌트 렌더링 +- [ ] 데이터 정규화 및 변환 +- [ ] 공통 레이아웃 (제목, 범례) +- [ ] 반응형 크기 조절 +- [ ] 에러 바운더리 + +#### Step 4.2: 막대 차트 + +- [ ] `BarChart.tsx` 생성 +- [ ] D3 스케일 설정 (x: 범주형, y: 선형) +- [ ] 막대 렌더링 (rect 요소) +- [ ] 축 렌더링 (d3-axis) +- [ ] 툴팁 구현 +- [ ] 애니메이션 (높이 전환) +- [ ] 수평/수직 모드 지원 +- [ ] 다중 시리즈 (그룹화) + +#### Step 4.3: 선 차트 + +- [ ] `LineChart.tsx` 생성 +- [ ] D3 라인 제너레이터 (d3.line) +- [ ] 부드러운 곡선 (d3.curveMonotoneX) +- [ ] 데이터 포인트 표시 (circle) +- [ ] 툴팁 구현 +- [ ] 애니메이션 (path 길이 전환) +- [ ] 다중 시리즈 (여러 선) +- [ ] 누락 데이터 처리 + +#### Step 4.4: 영역 차트 + +- [ ] `AreaChart.tsx` 생성 +- [ ] D3 영역 제너레이터 (d3.area) +- [ ] 투명도 설정 +- [ ] 누적 모드 지원 (d3.stack) +- [ ] 선 차트 기능 재사용 +- [ ] 애니메이션 + +#### Step 4.5: 원 차트 + +- [ ] `PieChart.tsx` 생성 +- [ ] D3 파이 레이아웃 (d3.pie) +- [ ] 아크 제너레이터 (d3.arc) +- [ ] 도넛 모드 (innerRadius) +- [ ] 라벨 배치 (중심 또는 외부) +- [ ] 툴팁 구현 +- [ ] 애니메이션 (회전 전환) +- [ ] 퍼센트 표시 + +#### Step 4.6: 누적 막대 차트 + +- [ ] `StackedBarChart.tsx` 생성 +- [ ] D3 스택 레이아웃 (d3.stack) +- [ ] 다중 시리즈 누적 +- [ ] 일반 누적 vs 퍼센트 모드 +- [ ] 막대 차트 로직 재사용 +- [ ] 범례 색상 매핑 + +#### Step 4.7: 혼합 차트 + +- [ ] `ComboChart.tsx` 생성 +- [ ] 막대 + 선 조합 +- [ ] 이중 Y축 (좌측: 막대, 우측: 선) +- [ ] 스케일 독립 설정 +- [ ] 막대/선 차트 로직 결합 +- [ ] 복잡한 툴팁 (두 데이터 표시) + +#### Step 4.8: 차트 유틸리티 + +- [ ] `chartUtils.ts` 생성 +- [ ] 데이터 변환 함수 (QueryResult → ChartData) +- [ ] 날짜 파싱 및 포맷팅 +- [ ] 숫자 포맷팅 (천 단위 콤마, 소수점) +- [ ] 색상 팔레트 정의 +- [ ] 반응형 크기 계산 + +#### Step 4.9: D3 헬퍼 + +- [ ] `d3Helpers.ts` 생성 +- [ ] 공통 스케일 생성 +- [ ] 축 생성 및 스타일링 +- [ ] 그리드 라인 추가 +- [ ] 툴팁 DOM 생성/제거 +- [ ] SVG 마진 계산 + +### Phase 5: 차트 통합 및 렌더링 (2-3시간) + +#### Step 5.1: CanvasElement 통합 + +- [ ] `CanvasElement.tsx` 수정 +- [ ] 차트 요소 감지 (element.type === 'chart') +- [ ] `ChartRenderer` 컴포넌트 임포트 및 렌더링 +- [ ] 데이터 로딩 상태 표시 +- [ ] 에러 상태 표시 +- [ ] 자동 새로고침 로직 + +#### Step 5.2: 데이터 페칭 + +- [ ] 차트 마운트 시 초기 데이터 로드 +- [ ] 자동 새로고침 타이머 설정 +- [ ] 수동 새로고침 버튼 +- [ ] 로딩/에러/성공 상태 관리 +- [ ] 캐싱 (선택적) + +#### Step 5.3: ElementConfigModal 리팩토링 + +- [ ] 데이터 소스 선택 UI 통합 +- [ ] 3단계 플로우 구현 + 1. 데이터 소스 선택 및 설정 + 2. 데이터 가져오기 및 검증 + 3. 축 매핑 및 스타일 설정 +- [ ] 진행 표시기 (스텝 인디케이터) +- [ ] 뒤로/다음 버튼 + +### Phase 6: 테스트 및 최적화 (2-3시간) + +#### Step 6.1: 기능 테스트 + +- [ ] 각 차트 타입 렌더링 확인 +- [ ] DB 쿼리 실행 및 차트 생성 +- [ ] API 호출 및 차트 생성 +- [ ] 다중 시리즈 차트 확인 +- [ ] 자동 새로고침 동작 확인 +- [ ] 에러 처리 확인 + +#### Step 6.2: UI/UX 개선 + +- [ ] 로딩 스피너 추가 +- [ ] 빈 데이터 상태 UI +- [ ] 에러 메시지 개선 +- [ ] 툴팁 스타일링 +- [ ] 범례 스타일링 +- [ ] 반응형 레이아웃 확인 + +#### Step 6.3: 성능 최적화 + +- [ ] D3 렌더링 최적화 (불필요한 재렌더링 방지) +- [ ] 대용량 데이터 처리 (샘플링, 페이징) +- [ ] 메모이제이션 (useMemo, useCallback) +- [ ] SVG 최적화 +- [ ] 차트 데이터 캐싱 + +--- + +## 🔒 보안 고려사항 + +### SQL Injection 방지 + +- 서버 측에서 쿼리 타입 엄격 검증 (SELECT만 허용) +- 정규식 + SQL 파서 사용 +- Prepared Statement 사용 (파라미터 바인딩) +- 위험한 키워드 차단 (DROP, DELETE, UPDATE, INSERT, EXEC 등) + +### 외부 DB 커넥션 보안 + +- 기존 "외부 커넥션 관리"에서 보안 처리됨 +- 차트 시스템에서는 커넥션 ID만 사용 +- 민감 정보(비밀번호, 호스트 등)는 차트 설정에 노출하지 않음 +- 타임아웃 설정 (30초) + +### API 보안 + +- CORS 정책 확인 +- 민감한 헤더 로깅 방지 (Authorization 등) +- 요청 크기 제한 +- Rate Limiting (API 호출 빈도 제한) + +--- + +## 🎨 UI/UX 개선 사항 + +### 설정 플로우 + +1. **데이터 소스 선택** + - 큰 아이콘과 설명으로 DB vs API 선택 + - 각 방식의 장단점 안내 + +2. **데이터 구성** + - DB: SQL 에디터 + 실행 버튼 + - API: URL, 메서드, 헤더, 본문 입력 + - 테스트 버튼으로 즉시 확인 + +3. **데이터 미리보기** + - 쿼리/API 실행 결과를 테이블로 표시 (최대 10행) + - 컬럼명과 샘플 데이터 표시 + +4. **차트 설정** + - X/Y축 드래그 앤 드롭 매핑 + - 실시간 미리보기 (작은 차트) + - 스타일 프리셋 선택 + +### 피드백 메시지 + +- ✅ 성공: "데이터를 성공적으로 불러왔습니다 (45행)" +- ⚠️ 경고: "쿼리 실행이 오래 걸리고 있습니다" +- ❌ 오류: "데이터베이스 연결에 실패했습니다: 잘못된 비밀번호" + +### 로딩 상태 + +- 스켈레톤 UI (차트 윤곽) +- 진행률 표시 (대용량 데이터) +- 취소 버튼 (장시간 실행 쿼리) + +--- + +## 📊 샘플 데이터 및 시나리오 + +### 시나리오 1: 월별 매출 추이 (DB 쿼리) + +```sql +SELECT + TO_CHAR(order_date, 'YYYY-MM') as month, + SUM(total_amount) as sales +FROM orders +WHERE order_date >= CURRENT_DATE - INTERVAL '12 months' +GROUP BY TO_CHAR(order_date, 'YYYY-MM') +ORDER BY month; +``` + +- **차트 타입**: Line Chart +- **X축**: month +- **Y축**: sales + +### 시나리오 2: 제품 비교 (다중 시리즈) + +```sql +SELECT + DATE_TRUNC('month', order_date) as month, + SUM(CASE WHEN product_category = '갤럭시' THEN amount ELSE 0 END) as galaxy, + SUM(CASE WHEN product_category = '아이폰' THEN amount ELSE 0 END) as iphone +FROM orders +WHERE order_date >= CURRENT_DATE - INTERVAL '12 months' +GROUP BY DATE_TRUNC('month', order_date) +ORDER BY month; +``` + +- **차트 타입**: Combo Chart (Bar + Line) +- **X축**: month +- **Y축**: [galaxy, iphone] (다중) + +### 시나리오 3: 카테고리별 매출 (원 차트) + +```sql +SELECT + category, + SUM(amount) as total +FROM sales +WHERE sale_date >= CURRENT_DATE - INTERVAL '1 month' +GROUP BY category +ORDER BY total DESC +LIMIT 10; +``` + +- **차트 타입**: Pie Chart (Donut) +- **X축**: category +- **Y축**: total + +### 시나리오 4: REST API (실시간 환율) + +- **API**: `https://api.exchangerate-api.com/v4/latest/USD` +- **JSON Path**: `rates` +- **변환**: Object를 배열로 변환 (통화: 환율) +- **차트 타입**: Bar Chart +- **X축**: 통화 코드 (KRW, JPY, EUR 등) +- **Y축**: 환율 + +--- + +## ✅ 완료 기준 + +### Phase 1: 데이터 소스 설정 + +- [x] DB 커넥션 설정 UI 작동 +- [x] 외부 DB 커넥션 저장 및 불러오기 +- [x] API 설정 UI 작동 +- [x] 테스트 버튼으로 즉시 확인 가능 + +### Phase 2: 서버 API + +- [x] 외부 DB 커넥션 CRUD API 작동 +- [x] 쿼리 실행 API (현재/외부 DB) +- [x] SELECT 쿼리 검증 및 SQL Injection 방지 +- [x] API 프록시 작동 + +### Phase 3: 차트 설정 UI + +- [x] 축 매핑 UI 직관적 +- [x] 다중 Y축 선택 가능 +- [x] 스타일 설정 UI 작동 +- [x] 실시간 미리보기 표시 + +### Phase 4: D3 차트 + +- [x] 6가지 차트 타입 모두 렌더링 +- [x] 툴팁 표시 +- [x] 애니메이션 부드러움 +- [x] 반응형 크기 조절 +- [x] 다중 시리즈 지원 + +### Phase 5: 통합 + +- [x] 캔버스에서 차트 표시 +- [x] 자동 새로고침 작동 +- [x] 설정 모달 3단계 플로우 완료 +- [x] 데이터 로딩/에러 상태 표시 + +### Phase 6: 테스트 + +- [x] 모든 차트 타입 정상 작동 +- [x] DB/API 데이터 소스 모두 작동 +- [x] 에러 처리 적절 +- [x] 성능 이슈 없음 (1000행 데이터) + +--- + +## 🚀 향후 확장 계획 + +- **실시간 스트리밍**: WebSocket 데이터 소스 추가 +- **고급 차트**: Scatter Plot, Heatmap, Radar Chart +- **데이터 변환**: 필터링, 정렬, 계산 필드 추가 +- **차트 상호작용**: 클릭/드래그로 데이터 필터링 +- **내보내기**: PNG, SVG, PDF 저장 +- **템플릿**: 사전 정의된 차트 템플릿 (업종별) + +--- + +## 📅 예상 일정 + +- **Phase 1**: 1일 (데이터 소스 UI) +- **Phase 2**: 0.5일 (서버 API) - 기존 외부 커넥션 관리 활용으로 단축 +- **Phase 3**: 1일 (차트 설정 UI) +- **Phase 4**: 2일 (D3 차트 컴포넌트) +- **Phase 5**: 0.5일 (통합) +- **Phase 6**: 0.5일 (테스트) + +**총 예상 시간**: 5.5일 (44시간) + +--- + +**구현 시작일**: 2025-10-14 +**목표 완료일**: 2025-10-20 +**현재 진행률**: 90% (Phase 1-5 완료, D3 차트 추가 구현 ✅) + +--- + +## 🎯 다음 단계 + +1. ~~Phase 1 완료: 데이터 소스 UI 구현~~ ✅ +2. ~~Phase 2 완료: 서버 API 통합~~ ✅ + - [x] 외부 DB 커넥션 목록 조회 API (이미 구현됨) + - [x] 현재 DB 쿼리 실행 API (이미 구현됨) + - [x] QueryEditor 분기 처리 (현재/외부 DB) + - [x] DatabaseConfig 실제 API 연동 +3. **Phase 3 시작**: 차트 설정 UI 개선 + - [ ] 축 매퍼 및 스타일 설정 UI + - [ ] 실시간 미리보기 +4. **Phase 4**: D3.js 라이브러리 설치 및 차트 컴포넌트 구현 +5. **Phase 5**: CanvasElement 통합 및 데이터 페칭 + +--- + +## 📊 Phase 2 최종 정리 + +### ✅ 구현 완료된 API 통합 + +1. **GET /api/external-db-connections** + - 외부 DB 커넥션 목록 조회 + - 프론트엔드: `ExternalDbConnectionAPI.getConnections({ is_active: 'Y' })` + - 통합: `DatabaseConfig.tsx` + +2. **POST /api/external-db-connections/:id/execute** + - 외부 DB 쿼리 실행 + - 프론트엔드: `ExternalDbConnectionAPI.executeQuery(connectionId, query)` + - 통합: `QueryEditor.tsx` + +3. **POST /api/dashboards/execute-query** + - 현재 DB 쿼리 실행 + - 프론트엔드: `dashboardApi.executeQuery(query)` + - 통합: `QueryEditor.tsx` + +### ❌ 불필요 (제거됨) + +4. ~~**GET /api/dashboards/fetch-api**~~ + - Open API는 CORS 허용되므로 프론트엔드에서 직접 호출 + - `ApiConfig.tsx`에서 `fetch()` 직접 사용 + +--- + +## 🎉 전체 구현 완료 요약 + +### Phase 1: 데이터 소스 UI ✅ + +- `DataSourceSelector`: DB vs API 선택 UI +- `DatabaseConfig`: 현재 DB / 외부 DB 선택 및 API 연동 +- `ApiConfig`: REST API 설정 +- `dataSourceUtils`: 유틸리티 함수 + +### Phase 2: 서버 API 통합 ✅ + +- `GET /api/external-db-connections`: 외부 커넥션 목록 조회 +- `POST /api/external-db-connections/:id/execute`: 외부 DB 쿼리 실행 +- `POST /api/dashboards/execute-query`: 현재 DB 쿼리 실행 +- **QueryEditor**: 현재 DB / 외부 DB 분기 처리 완료 + +### Phase 3: 차트 설정 UI ✅ + +- `ChartConfigPanel`: X/Y축 매핑, 스타일 설정, 색상 팔레트 +- 다중 Y축 선택 지원 +- 설정 미리보기 + +### Phase 4: D3 차트 컴포넌트 ✅ + +- **D3 차트 구현** (6종): + - `BarChart.tsx`: 막대 차트 + - `LineChart.tsx`: 선 차트 + - `AreaChart.tsx`: 영역 차트 + - `PieChart.tsx`: 원/도넛 차트 + - `StackedBarChart.tsx`: 누적 막대 차트 + - `Chart.tsx`: 통합 컴포넌트 +- **Recharts 완전 제거**: D3로 완전히 대체 + +### Phase 5: 통합 ✅ + +- `CanvasElement`: 차트 렌더링 통합 완료 +- `ChartRenderer`: D3 기반으로 완전히 교체 +- `chartDataTransform.ts`: 데이터 변환 유틸리티 +- 데이터 페칭 및 자동 새로고침 diff --git a/frontend/components/admin/dashboard/CLOCK_WIDGET_PLAN.md b/frontend/components/admin/dashboard/CLOCK_WIDGET_PLAN.md new file mode 100644 index 00000000..2927fb5b --- /dev/null +++ b/frontend/components/admin/dashboard/CLOCK_WIDGET_PLAN.md @@ -0,0 +1,635 @@ +# ⏰ 시계 위젯 구현 계획 + +## 📋 개요 + +대시보드에 실시간 시계 위젯을 추가하여 사용자가 현재 시간을 한눈에 확인할 수 있도록 합니다. + +--- + +## 🎯 목표 + +- 실시간으로 업데이트되는 시계 위젯 구현 +- 다양한 시계 스타일 제공 (아날로그/디지털) +- 여러 시간대(타임존) 지원 +- 깔끔하고 직관적인 UI + +--- + +## 📦 구현 범위 + +### 1. 타입 정의 (`types.ts`) + +```typescript +export type ElementSubtype = + | "bar" + | "pie" + | "line" + | "area" + | "stacked-bar" + | "donut" + | "combo" // 차트 + | "exchange" + | "weather" + | "clock"; // 위젯 (+ clock 추가) + +// 시계 위젯 설정 +export interface ClockConfig { + style: "analog" | "digital" | "both"; // 시계 스타일 + timezone: string; // 타임존 (예: 'Asia/Seoul', 'America/New_York') + showDate: boolean; // 날짜 표시 여부 + showSeconds: boolean; // 초 표시 여부 (디지털) + format24h: boolean; // 24시간 형식 (true) vs 12시간 형식 (false) + theme: "light" | "dark" | "blue" | "gradient"; // 테마 +} + +// DashboardElement에 clockConfig 추가 +export interface DashboardElement { + // ... 기존 필드 + clockConfig?: ClockConfig; // 시계 설정 +} +``` + +--- + +### 2. 사이드바에 시계 위젯 추가 (`DashboardSidebar.tsx`) + +```tsx + +``` + +--- + +### 3. 시계 위젯 컴포넌트 생성 + +#### 📁 파일 구조 + +``` +frontend/components/admin/dashboard/ +├── widgets/ +│ ├── ClockWidget.tsx # 메인 시계 컴포넌트 +│ ├── AnalogClock.tsx # 아날로그 시계 +│ ├── DigitalClock.tsx # 디지털 시계 +│ └── ClockConfigModal.tsx # 시계 설정 모달 +``` + +#### 📄 `ClockWidget.tsx` - 메인 컴포넌트 + +**기능:** + +- 현재 시간을 1초마다 업데이트 +- `clockConfig`에 따라 아날로그/디지털 시계 렌더링 +- 타임존 지원 (`Intl.DateTimeFormat` 또는 `date-fns-tz` 사용) + +**주요 코드:** + +```tsx +"use client"; +import { useState, useEffect } from "react"; +import { DashboardElement } from "../types"; +import { AnalogClock } from "./AnalogClock"; +import { DigitalClock } from "./DigitalClock"; + +interface ClockWidgetProps { + element: DashboardElement; +} + +export function ClockWidget({ element }: ClockWidgetProps) { + const [currentTime, setCurrentTime] = useState(new Date()); + const config = element.clockConfig || { + style: "digital", + timezone: "Asia/Seoul", + showDate: true, + showSeconds: true, + format24h: true, + theme: "light", + }; + + useEffect(() => { + const timer = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + + return () => clearInterval(timer); + }, []); + + return ( +
+ {(config.style === "analog" || config.style === "both") && ( + + )} + + {(config.style === "digital" || config.style === "both") && ( + + )} +
+ ); +} +``` + +--- + +#### 📄 `DigitalClock.tsx` - 디지털 시계 + +**기능:** + +- 시간을 디지털 형식으로 표시 +- 날짜 표시 옵션 +- 12/24시간 형식 지원 +- 초 표시 옵션 + +**UI 예시:** + +``` +┌─────────────────────┐ +│ 2025년 1월 15일 │ +│ │ +│ 14:30:45 │ +│ │ +│ 서울 (KST) │ +└─────────────────────┘ +``` + +**주요 코드:** + +```tsx +interface DigitalClockProps { + time: Date; + timezone: string; + showDate: boolean; + showSeconds: boolean; + format24h: boolean; + theme: string; +} + +export function DigitalClock({ time, timezone, showDate, showSeconds, format24h, theme }: DigitalClockProps) { + // Intl.DateTimeFormat으로 타임존 처리 + const timeString = new Intl.DateTimeFormat("ko-KR", { + timeZone: timezone, + hour: "2-digit", + minute: "2-digit", + second: showSeconds ? "2-digit" : undefined, + hour12: !format24h, + }).format(time); + + const dateString = showDate + ? new Intl.DateTimeFormat("ko-KR", { + timeZone: timezone, + year: "numeric", + month: "long", + day: "numeric", + weekday: "long", + }).format(time) + : null; + + return ( +
+ {showDate &&
{dateString}
} +
{timeString}
+
{getTimezoneLabel(timezone)}
+
+ ); +} +``` + +--- + +#### 📄 `AnalogClock.tsx` - 아날로그 시계 + +**기능:** + +- SVG로 아날로그 시계 그리기 +- 시침, 분침, 초침 애니메이션 +- 숫자/눈금 표시 + +**UI 예시:** + +``` + 12 + 11 1 +10 2 +9 3 +8 4 + 7 5 + 6 +``` + +**주요 코드:** + +```tsx +interface AnalogClockProps { + time: Date; + theme: string; +} + +export function AnalogClock({ time, theme }: AnalogClockProps) { + const hours = time.getHours() % 12; + const minutes = time.getMinutes(); + const seconds = time.getSeconds(); + + // 각도 계산 + const secondAngle = seconds * 6 - 90; // 6도씩 회전 + const minuteAngle = minutes * 6 + seconds * 0.1 - 90; + const hourAngle = hours * 30 + minutes * 0.5 - 90; + + return ( + + {/* 시계판 */} + + + {/* 숫자 표시 */} + {[...Array(12)].map((_, i) => { + const angle = (i * 30 - 90) * (Math.PI / 180); + const x = 100 + 75 * Math.cos(angle); + const y = 100 + 75 * Math.sin(angle); + return ( + + {i === 0 ? 12 : i} + + ); + })} + + {/* 시침 */} + + + {/* 분침 */} + + + {/* 초침 */} + + + {/* 중심점 */} + + + ); +} +``` + +--- + +#### 📄 `ClockConfigModal.tsx` - 설정 모달 + +**설정 항목:** + +1. **시계 스타일** + - 아날로그 + - 디지털 + - 둘 다 + +2. **타임존 선택** + - 서울 (Asia/Seoul) + - 뉴욕 (America/New_York) + - 런던 (Europe/London) + - 도쿄 (Asia/Tokyo) + - 기타... + +3. **디지털 시계 옵션** + - 날짜 표시 + - 초 표시 + - 24시간 형식 / 12시간 형식 + +4. **테마** + - Light + - Dark + - Blue + - Gradient + +--- + +### 4. 기존 컴포넌트 수정 + +#### 📄 `CanvasElement.tsx` + +시계 위젯을 렌더링하도록 수정: + +```tsx +import { ClockWidget } from "./widgets/ClockWidget"; + +// 렌더링 부분 +{ + element.type === "widget" && element.subtype === "clock" && ; +} +``` + +#### 📄 `DashboardDesigner.tsx` + +시계 위젯 기본 설정 추가: + +```tsx +function getElementContent(type: ElementType, subtype: ElementSubtype): string { + // ... + if (type === "widget") { + if (subtype === "clock") return "clock"; + // ... + } +} + +function getElementTitle(type: ElementType, subtype: ElementSubtype): string { + // ... + if (type === "widget") { + if (subtype === "clock") return "⏰ 시계"; + // ... + } +} +``` + +--- + +## 🎨 디자인 가이드 + +### 테마별 색상 + +```typescript +const themes = { + light: { + background: "bg-white", + text: "text-gray-900", + border: "border-gray-200", + }, + dark: { + background: "bg-gray-900", + text: "text-white", + border: "border-gray-700", + }, + blue: { + background: "bg-gradient-to-br from-blue-400 to-blue-600", + text: "text-white", + border: "border-blue-500", + }, + gradient: { + background: "bg-gradient-to-br from-purple-400 via-pink-500 to-red-500", + text: "text-white", + border: "border-pink-500", + }, +}; +``` + +### 크기 가이드 + +- **최소 크기**: 2×2 셀 (디지털만) +- **권장 크기**: 3×3 셀 (아날로그 + 디지털) +- **최대 크기**: 4×4 셀 + +--- + +## 🔧 기술 스택 + +### 사용 라이브러리 + +**Option 1: 순수 JavaScript (권장)** + +- `Date` 객체 +- `Intl.DateTimeFormat` - 타임존 처리 +- `setInterval` - 1초마다 업데이트 + +**Option 2: 외부 라이브러리** + +- `date-fns` + `date-fns-tz` - 날짜/시간 처리 +- `moment-timezone` - 타임존 처리 (무겁지만 강력) + +**추천: Option 1 (순수 JavaScript)** + +- 외부 의존성 없음 +- 가볍고 빠름 +- 브라우저 네이티브 API 사용 + +--- + +## 📝 구현 순서 + +### Step 1: 타입 정의 + +- [x] `types.ts`에 `'clock'` 추가 +- [x] `ClockConfig` 인터페이스 정의 +- [x] `DashboardElement`에 `clockConfig` 추가 + +### Step 2: UI 추가 + +- [x] `DashboardSidebar.tsx`에 시계 위젯 아이템 추가 + +### Step 3: 디지털 시계 구현 + +- [x] `DigitalClock.tsx` 생성 +- [x] 시간 포맷팅 구현 +- [x] 타임존 처리 구현 +- [x] 테마 스타일 적용 + +### Step 4: 아날로그 시계 구현 + +- [x] `AnalogClock.tsx` 생성 +- [x] SVG 시계판 그리기 +- [x] 시침/분침/초침 계산 및 렌더링 +- [x] 애니메이션 적용 + +### Step 5: 메인 위젯 컴포넌트 + +- [x] `ClockWidget.tsx` 생성 +- [x] 실시간 업데이트 로직 구현 +- [x] 아날로그/디지털 조건부 렌더링 + +### Step 6: 설정 모달 + +- [x] `ClockConfigModal.tsx` 생성 ✨ +- [x] 스타일 선택 UI (아날로그/디지털/둘다) ✨ +- [x] 타임존 선택 UI (8개 주요 도시) ✨ +- [x] 옵션 토글 UI (날짜/초/24시간) ✨ +- [x] 테마 선택 UI (light/dark/blue/gradient) ✨ +- [x] ElementConfigModal 통합 ✨ + +### Step 7: 통합 + +- [x] `CanvasElement.tsx`에 시계 위젯 렌더링 추가 +- [x] `DashboardDesigner.tsx`에 기본값 추가 +- [x] ClockWidget 임포트 및 조건부 렌더링 추가 + +### Step 8: 테스트 & 최적화 + +- [x] 기본 구현 완료 +- [x] 린터 에러 체크 완료 +- [ ] 브라우저 테스트 필요 (사용자 테스트) +- [ ] 다양한 타임존 테스트 (향후) +- [ ] 성능 최적화 (향후) +- [ ] 테마 전환 테스트 (향후) + +--- + +## 🚀 향후 개선 사항 + +### 추가 기능 + +- [ ] **세계 시계**: 여러 타임존 동시 표시 +- [ ] **알람 기능**: 특정 시간에 알림 +- [ ] **타이머/스톱워치**: 시간 측정 기능 +- [ ] **애니메이션**: 부드러운 시계 애니메이션 +- [ ] **사운드**: 정각마다 종소리 + +### 디자인 개선 + +- [ ] 더 많은 테마 추가 +- [ ] 커스텀 색상 선택 +- [ ] 폰트 선택 옵션 +- [ ] 배경 이미지 지원 + +--- + +## 📚 참고 자료 + +### 타임존 목록 + +```typescript +const TIMEZONES = [ + { label: "서울", value: "Asia/Seoul", offset: "+9" }, + { label: "도쿄", value: "Asia/Tokyo", offset: "+9" }, + { label: "베이징", value: "Asia/Shanghai", offset: "+8" }, + { label: "뉴욕", value: "America/New_York", offset: "-5" }, + { label: "런던", value: "Europe/London", offset: "+0" }, + { label: "LA", value: "America/Los_Angeles", offset: "-8" }, + { label: "파리", value: "Europe/Paris", offset: "+1" }, + { label: "시드니", value: "Australia/Sydney", offset: "+11" }, +]; +``` + +### Date Format 예시 + +```typescript +// 24시간 형식 +"14:30:45"; + +// 12시간 형식 +"2:30:45 PM"; + +// 날짜 포함 +"2025년 1월 15일 (수) 14:30:45"; + +// 영문 날짜 +"Wednesday, January 15, 2025 2:30:45 PM"; +``` + +--- + +## ✅ 완료 기준 + +- [x] 시계가 실시간으로 정확하게 업데이트됨 (1초마다 업데이트) +- [x] 아날로그/디지털 스타일 모두 정상 작동 (코드 구현 완료) +- [x] 타임존 변경이 즉시 반영됨 (Intl.DateTimeFormat 사용) +- [x] 설정 모달에서 모든 옵션 변경 가능 ✨ (ClockConfigModal 완성!) +- [x] 테마 전환이 자연스러움 (4가지 테마 구현) +- [x] 메모리 누수 없음 (컴포넌트 unmount 시 타이머 정리 - useEffect cleanup) +- [x] 크기 조절 시 레이아웃이 깨지지 않음 (그리드 스냅 적용) + +--- + +## 💡 팁 + +### 성능 최적화 + +```tsx +// ❌ 나쁜 예: 컴포넌트 전체 리렌더링 +setInterval(() => { + setTime(new Date()); +}, 1000); + +// ✅ 좋은 예: 필요한 부분만 업데이트 + cleanup +useEffect(() => { + const timer = setInterval(() => { + setTime(new Date()); + }, 1000); + + return () => clearInterval(timer); // cleanup +}, []); +``` + +### 타임존 처리 + +```typescript +// Intl.DateTimeFormat 사용 (권장) +const formatter = new Intl.DateTimeFormat("ko-KR", { + timeZone: "America/New_York", + hour: "2-digit", + minute: "2-digit", +}); +console.log(formatter.format(new Date())); // "05:30" +``` + +--- + +--- + +## 🎉 구현 완료! + +**구현 날짜**: 2025년 1월 15일 + +### ✅ 완료된 기능 + +1. **타입 정의** - `ClockConfig` 인터페이스 및 `'clock'` subtype 추가 +2. **디지털 시계** - 타임존, 날짜, 초 표시, 12/24시간 형식 지원 +3. **아날로그 시계** - SVG 기반 시계판, 시침/분침/초침 애니메이션 +4. **메인 위젯** - 실시간 업데이트, 스타일별 조건부 렌더링 +5. **통합** - CanvasElement, DashboardDesigner, Sidebar 연동 +6. **테마** - light, dark, blue, gradient 4가지 테마 + +### ✅ 최종 완료 기능 + +1. **시계 위젯 컴포넌트** - 아날로그/디지털/둘다 +2. **실시간 업데이트** - 1초마다 정확한 시간 +3. **타임존 지원** - 8개 주요 도시 +4. **4가지 테마** - light, dark, blue, gradient +5. **설정 모달** - 모든 옵션 UI로 변경 가능 ✨ + +### 🔜 향후 추가 예정 + +- 세계 시계 (여러 타임존 동시 표시) +- 알람 기능 +- 타이머/스톱워치 +- 커스텀 색상 선택 + +--- + +## 🎯 사용 방법 + +1. **시계 추가**: 우측 사이드바에서 "⏰ 시계 위젯" 드래그 +2. **설정 변경**: 시계 위에 마우스 올리고 ⚙️ 버튼 클릭 +3. **옵션 선택**: + - 스타일 (디지털/아날로그/둘다) + - 타임존 (서울, 뉴욕, 런던 등) + - 테마 (4가지) + - 날짜/초/24시간 형식 + +이제 완벽하게 작동하는 시계 위젯을 사용할 수 있습니다! 🚀⏰ diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 746e4d54..92a39cb5 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -17,6 +17,64 @@ const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/Exch loading: () =>
로딩 중...
, }); +const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/CalculatorWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const VehicleStatusWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleStatusWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const VehicleListWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleListWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapOnlyWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const DeliveryStatusWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const TodoWidget = dynamic(() => import("@/components/dashboard/widgets/TodoWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const BookingAlertWidget = dynamic(() => import("@/components/dashboard/widgets/BookingAlertWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const MaintenanceWidget = dynamic(() => import("@/components/dashboard/widgets/MaintenanceWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/DocumentWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +// 시계 위젯 임포트 +import { ClockWidget } from "./widgets/ClockWidget"; +// 달력 위젯 임포트 +import { CalendarWidget } from "./widgets/CalendarWidget"; +// 기사 관리 위젯 임포트 +import { DriverManagementWidget } from "./widgets/DriverManagementWidget"; +import { ListWidget } from "./widgets/ListWidget"; + interface CanvasElementProps { element: DashboardElement; isSelected: boolean; @@ -70,6 +128,11 @@ export function CanvasElement({ return; } + // 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능 + if ((e.target as HTMLElement).closest(".widget-interactive-area")) { + return; + } + onSelect(element.id); setIsDragging(true); setDragStart({ @@ -109,9 +172,13 @@ export function CanvasElement({ const deltaY = e.clientY - dragStart.y; // 임시 위치 계산 (스냅 안 됨) - const rawX = Math.max(0, dragStart.elementX + deltaX); + let rawX = Math.max(0, dragStart.elementX + deltaX); const rawY = Math.max(0, dragStart.elementY + deltaY); + // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 + const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width; + rawX = Math.min(rawX, maxX); + setTempPosition({ x: rawX, y: rawY }); } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; @@ -122,46 +189,58 @@ export function CanvasElement({ let newX = resizeStart.elementX; let newY = resizeStart.elementY; - const minSize = GRID_CONFIG.CELL_SIZE * 2; // 최소 2셀 + // 최소 크기 설정: 달력은 2x3, 나머지는 2x2 + const minWidthCells = 2; + const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2; + const minWidth = GRID_CONFIG.CELL_SIZE * minWidthCells; + const minHeight = GRID_CONFIG.CELL_SIZE * minHeightCells; switch (resizeStart.handle) { case "se": // 오른쪽 아래 - newWidth = Math.max(minSize, resizeStart.width + deltaX); - newHeight = Math.max(minSize, resizeStart.height + deltaY); + newWidth = Math.max(minWidth, resizeStart.width + deltaX); + newHeight = Math.max(minHeight, resizeStart.height + deltaY); break; case "sw": // 왼쪽 아래 - newWidth = Math.max(minSize, resizeStart.width - deltaX); - newHeight = Math.max(minSize, resizeStart.height + deltaY); + newWidth = Math.max(minWidth, resizeStart.width - deltaX); + newHeight = Math.max(minHeight, resizeStart.height + deltaY); newX = resizeStart.elementX + deltaX; break; case "ne": // 오른쪽 위 - newWidth = Math.max(minSize, resizeStart.width + deltaX); - newHeight = Math.max(minSize, resizeStart.height - deltaY); + newWidth = Math.max(minWidth, resizeStart.width + deltaX); + newHeight = Math.max(minHeight, resizeStart.height - deltaY); newY = resizeStart.elementY + deltaY; break; case "nw": // 왼쪽 위 - newWidth = Math.max(minSize, resizeStart.width - deltaX); - newHeight = Math.max(minSize, resizeStart.height - deltaY); + newWidth = Math.max(minWidth, resizeStart.width - deltaX); + newHeight = Math.max(minHeight, resizeStart.height - deltaY); newX = resizeStart.elementX + deltaX; newY = resizeStart.elementY + deltaY; break; } + // 가로 너비가 캔버스를 벗어나지 않도록 제한 + const maxWidth = GRID_CONFIG.CANVAS_WIDTH - newX; + newWidth = Math.min(newWidth, maxWidth); + // 임시 크기/위치 저장 (스냅 안 됨) setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) }); setTempSize({ width: newWidth, height: newHeight }); } }, - [isDragging, isResizing, dragStart, resizeStart], + [isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype], ); // 마우스 업 처리 (그리드 스냅 적용) const handleMouseUp = useCallback(() => { if (isDragging && tempPosition) { // 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용) - const snappedX = snapToGrid(tempPosition.x, cellSize); + let snappedX = snapToGrid(tempPosition.x, cellSize); const snappedY = snapToGrid(tempPosition.y, cellSize); + // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한 + const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width; + snappedX = Math.min(snappedX, maxX); + onUpdate(element.id, { position: { x: snappedX, y: snappedY }, }); @@ -173,9 +252,13 @@ export function CanvasElement({ // 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용) const snappedX = snapToGrid(tempPosition.x, cellSize); const snappedY = snapToGrid(tempPosition.y, cellSize); - const snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize); + let snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize); const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize); + // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한 + const maxWidth = GRID_CONFIG.CANVAS_WIDTH - snappedX; + snappedWidth = Math.min(snappedWidth, maxWidth); + onUpdate(element.id, { position: { x: snappedX, y: snappedY }, size: { width: snappedWidth, height: snappedHeight }, @@ -187,7 +270,7 @@ export function CanvasElement({ setIsDragging(false); setIsResizing(false); - }, [isDragging, isResizing, tempPosition, tempSize, element.id, onUpdate, cellSize]); + }, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize]); // 전역 마우스 이벤트 등록 React.useEffect(() => { @@ -210,27 +293,51 @@ export function CanvasElement({ setIsLoadingData(true); try { - // console.log('🔄 쿼리 실행 시작:', element.dataSource.query); + let result; - // 실제 API 호출 - const { dashboardApi } = await import("@/lib/api/dashboard"); - const result = await dashboardApi.executeQuery(element.dataSource.query); + // 외부 DB vs 현재 DB 분기 + if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) { + // 외부 DB + const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); + const externalResult = await ExternalDbConnectionAPI.executeQuery( + parseInt(element.dataSource.externalConnectionId), + element.dataSource.query, + ); - // console.log('✅ 쿼리 실행 결과:', result); + if (!externalResult.success) { + throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패"); + } - setChartData({ - columns: result.columns || [], - rows: result.rows || [], - totalRows: result.rowCount || 0, - executionTime: 0, - }); + setChartData({ + columns: externalResult.data?.[0] ? Object.keys(externalResult.data[0]) : [], + rows: externalResult.data || [], + totalRows: externalResult.data?.length || 0, + executionTime: 0, + }); + } else { + // 현재 DB + const { dashboardApi } = await import("@/lib/api/dashboard"); + result = await dashboardApi.executeQuery(element.dataSource.query); + + setChartData({ + columns: result.columns || [], + rows: result.rows || [], + totalRows: result.rowCount || 0, + executionTime: 0, + }); + } } catch (error) { - // console.error('❌ 데이터 로딩 오류:', error); + console.error("Chart data loading error:", error); setChartData(null); } finally { setIsLoadingData(false); } - }, [element.dataSource?.query, element.type, element.subtype]); + }, [ + element.dataSource?.query, + element.dataSource?.connectionType, + element.dataSource?.externalConnectionId, + element.type, + ]); // 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩 useEffect(() => { @@ -271,6 +378,14 @@ export function CanvasElement({ return "bg-gradient-to-br from-pink-400 to-yellow-400"; case "weather": return "bg-gradient-to-br from-cyan-400 to-indigo-800"; + case "clock": + return "bg-gradient-to-br from-teal-400 to-cyan-600"; + case "calendar": + return "bg-gradient-to-br from-indigo-400 to-purple-600"; + case "driver-management": + return "bg-gradient-to-br from-blue-400 to-indigo-600"; + case "list": + return "bg-gradient-to-br from-cyan-400 to-blue-600"; default: return "bg-gray-200"; } @@ -300,16 +415,20 @@ export function CanvasElement({
{element.title}
- {/* 설정 버튼 */} - {onConfigure && ( - - )} + {/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */} + {onConfigure && + !( + element.type === "widget" && + (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management") + ) && ( + + )} {/* 삭제 버튼 */}
) : element.type === "widget" && element.subtype === "weather" ? ( // 날씨 위젯 렌더링 -
- +
+
) : element.type === "widget" && element.subtype === "exchange" ? ( // 환율 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "clock" ? ( + // 시계 위젯 렌더링
- { + onUpdate(element.id, { clockConfig: newConfig }); + }} />
+ ) : element.type === "widget" && element.subtype === "calculator" ? ( + // 계산기 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "vehicle-status" ? ( + // 차량 상태 현황 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "vehicle-list" ? ( + // 차량 목록 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "vehicle-map" ? ( + // 차량 위치 지도 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "delivery-status" ? ( + // 배송/화물 현황 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "risk-alert" ? ( + // 리스크/알림 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "calendar" ? ( + // 달력 위젯 렌더링 +
+ { + onUpdate(element.id, { calendarConfig: newConfig }); + }} + /> +
+ ) : element.type === "widget" && element.subtype === "driver-management" ? ( + // 기사 관리 위젯 렌더링 +
+ { + onUpdate(element.id, { driverManagementConfig: newConfig }); + }} + /> +
+ ) : element.type === "widget" && element.subtype === "list" ? ( + // 리스트 위젯 렌더링 +
+ { + onUpdate(element.id, { listConfig: newConfig as any }); + }} + /> +
+ ) : element.type === "widget" && element.subtype === "todo" ? ( + // To-Do 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "booking-alert" ? ( + // 예약 요청 알림 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "maintenance" ? ( + // 정비 일정 위젯 렌더링 +
+ +
+ ) : element.type === "widget" && element.subtype === "document" ? ( + // 문서 다운로드 위젯 렌더링 +
+ +
) : ( // 기타 위젯 렌더링
); } - -/** - * 샘플 데이터 생성 함수 (실제 API 호출 대신 사용) - */ -function generateSampleData(query: string, chartType: string): QueryResult { - // 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성 - const isMonthly = query.toLowerCase().includes("month"); - const isSales = query.toLowerCase().includes("sales") || query.toLowerCase().includes("매출"); - const isUsers = query.toLowerCase().includes("users") || query.toLowerCase().includes("사용자"); - const isProducts = query.toLowerCase().includes("product") || query.toLowerCase().includes("상품"); - - let columns: string[]; - let rows: Record[]; - - if (isMonthly && isSales) { - // 월별 매출 데이터 - columns = ["month", "sales", "order_count"]; - rows = [ - { month: "2024-01", sales: 1200000, order_count: 45 }, - { month: "2024-02", sales: 1350000, order_count: 52 }, - { month: "2024-03", sales: 1180000, order_count: 41 }, - { month: "2024-04", sales: 1420000, order_count: 58 }, - { month: "2024-05", sales: 1680000, order_count: 67 }, - { month: "2024-06", sales: 1540000, order_count: 61 }, - ]; - } else if (isUsers) { - // 사용자 가입 추이 - columns = ["week", "new_users"]; - rows = [ - { week: "2024-W10", new_users: 23 }, - { week: "2024-W11", new_users: 31 }, - { week: "2024-W12", new_users: 28 }, - { week: "2024-W13", new_users: 35 }, - { week: "2024-W14", new_users: 42 }, - { week: "2024-W15", new_users: 38 }, - ]; - } else if (isProducts) { - // 상품별 판매량 - columns = ["product_name", "total_sold", "revenue"]; - rows = [ - { product_name: "스마트폰", total_sold: 156, revenue: 234000000 }, - { product_name: "노트북", total_sold: 89, revenue: 178000000 }, - { product_name: "태블릿", total_sold: 134, revenue: 67000000 }, - { product_name: "이어폰", total_sold: 267, revenue: 26700000 }, - { product_name: "스마트워치", total_sold: 98, revenue: 49000000 }, - ]; - } else { - // 기본 샘플 데이터 - columns = ["category", "value", "count"]; - rows = [ - { category: "A", value: 100, count: 10 }, - { category: "B", value: 150, count: 15 }, - { category: "C", value: 120, count: 12 }, - { category: "D", value: 180, count: 18 }, - { category: "E", value: 90, count: 9 }, - ]; - } - - return { - columns, - rows, - totalRows: rows.length, - executionTime: Math.floor(Math.random() * 100) + 50, // 50-150ms - }; -} diff --git a/frontend/components/admin/dashboard/ChartConfigPanel.tsx b/frontend/components/admin/dashboard/ChartConfigPanel.tsx index d67cfefb..a7649c8a 100644 --- a/frontend/components/admin/dashboard/ChartConfigPanel.tsx +++ b/frontend/components/admin/dashboard/ChartConfigPanel.tsx @@ -1,12 +1,23 @@ -'use client'; +"use client"; -import React, { useState, useCallback } from 'react'; -import { ChartConfig, QueryResult } from './types'; +import React, { useState, useCallback } from "react"; +import { ChartConfig, QueryResult } from "./types"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; +import { TrendingUp, AlertCircle } from "lucide-react"; interface ChartConfigPanelProps { config?: ChartConfig; queryResult?: QueryResult; onConfigChange: (config: ChartConfig) => void; + chartType?: string; + dataSourceType?: "database" | "api"; // 데이터 소스 타입 } /** @@ -15,186 +26,340 @@ interface ChartConfigPanelProps { * - 차트 스타일 설정 * - 실시간 미리보기 */ -export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartConfigPanelProps) { +export function ChartConfigPanel({ + config, + queryResult, + onConfigChange, + chartType, + dataSourceType, +}: ChartConfigPanelProps) { const [currentConfig, setCurrentConfig] = useState(config || {}); - // 설정 업데이트 - const updateConfig = useCallback((updates: Partial) => { - const newConfig = { ...currentConfig, ...updates }; - setCurrentConfig(newConfig); - onConfigChange(newConfig); - }, [currentConfig, onConfigChange]); + // 원형/도넛 차트 또는 REST API는 Y축이 필수가 아님 + const isPieChart = chartType === "pie" || chartType === "donut"; + const isApiSource = dataSourceType === "api"; - // 사용 가능한 컬럼 목록 + // 설정 업데이트 + const updateConfig = useCallback( + (updates: Partial) => { + const newConfig = { ...currentConfig, ...updates }; + setCurrentConfig(newConfig); + onConfigChange(newConfig); + }, + [currentConfig, onConfigChange], + ); + + // 사용 가능한 컬럼 목록 및 타입 정보 const availableColumns = queryResult?.columns || []; + const columnTypes = queryResult?.columnTypes || {}; const sampleData = queryResult?.rows?.[0] || {}; + // 차트에 사용 가능한 컬럼 필터링 + const simpleColumns = availableColumns.filter((col) => { + const type = columnTypes[col]; + // number, string, boolean만 허용 (object, array는 제외) + return !type || type === "number" || type === "string" || type === "boolean"; + }); + + // 숫자 타입 컬럼만 필터링 (Y축용) + const numericColumns = availableColumns.filter((col) => columnTypes[col] === "number"); + + // 복잡한 타입의 컬럼 (경고 표시용) + const complexColumns = availableColumns.filter((col) => { + const type = columnTypes[col]; + return type === "object" || type === "array"; + }); + return ( -
-

⚙️ 차트 설정

- - {/* 쿼리 결과가 없을 때 */} - {!queryResult && ( -
-
- 💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 차트를 설정할 수 있습니다. -
-
- )} - +
{/* 데이터 필드 매핑 */} {queryResult && ( <> + {/* API 응답 미리보기 */} + {queryResult.rows && queryResult.rows.length > 0 && ( + +
+ +

📋 API 응답 데이터 미리보기

+
+
+
총 {queryResult.totalRows}개 데이터 중 첫 번째 행:
+
{JSON.stringify(sampleData, null, 2)}
+
+
+ )} + + {/* 복잡한 타입 경고 */} + {complexColumns.length > 0 && ( + + + +
⚠️ 차트에 사용할 수 없는 컬럼 감지
+
+ 다음 컬럼은 객체 또는 배열 타입이라서 차트 축으로 선택할 수 없습니다: +
+ {complexColumns.map((col) => ( + + {col} ({columnTypes[col]}) + + ))} +
+
+
+ 💡 해결 방법: JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요. +
+ 예: main 또는{" "} + data.items +
+
+
+ )} + {/* 차트 제목 */}
- - 차트 제목 + updateConfig({ title: e.target.value })} placeholder="차트 제목을 입력하세요" - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
+ + {/* X축 설정 */}
- + + {simpleColumns.length === 0 && ( +

⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.

+ )}
{/* Y축 설정 (다중 선택 가능) */}
- + +
+ {/* 숫자 타입 우선 표시 */} + {numericColumns.length > 0 && ( + <> +
✅ 숫자 타입 (권장)
+ {numericColumns.map((col) => { + const isSelected = Array.isArray(currentConfig.yAxis) + ? currentConfig.yAxis.includes(col) + : currentConfig.yAxis === col; + + return ( +
+ { + const currentYAxis = Array.isArray(currentConfig.yAxis) + ? currentConfig.yAxis + : currentConfig.yAxis + ? [currentConfig.yAxis] + : []; + + let newYAxis: string | string[]; + if (checked) { + newYAxis = [...currentYAxis, col]; + } else { + newYAxis = currentYAxis.filter((c) => c !== col); + } + + if (newYAxis.length === 1) { + newYAxis = newYAxis[0]; + } + + updateConfig({ yAxis: newYAxis }); + }} + /> + +
+ ); + })} + + )} + + {/* 기타 간단한 타입 */} + {simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && ( + <> + {numericColumns.length > 0 &&
} +
📝 기타 타입
+ {simpleColumns + .filter((col) => !numericColumns.includes(col)) + .map((col) => { + const isSelected = Array.isArray(currentConfig.yAxis) + ? currentConfig.yAxis.includes(col) + : currentConfig.yAxis === col; + + return ( +
+ { + const currentYAxis = Array.isArray(currentConfig.yAxis) + ? currentConfig.yAxis + : currentConfig.yAxis + ? [currentConfig.yAxis] + : []; + + let newYAxis: string | string[]; + if (checked) { + newYAxis = [...currentYAxis, col]; + } else { + newYAxis = currentYAxis.filter((c) => c !== col); + } + + if (newYAxis.length === 1) { + newYAxis = newYAxis[0]; + } + + updateConfig({ yAxis: newYAxis }); + }} + /> + +
+ ); + })} + + )} +
+
+ {simpleColumns.length === 0 && ( +

⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.

+ )} +

+ 팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰) +

+ + {/* 집계 함수 */}
-
{/* 그룹핑 필드 (선택사항) */}
-
+ + {/* 차트 색상 */}
- +
{[ - ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'], // 기본 - ['#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'], // 밝은 - ['#1F2937', '#374151', '#6B7280', '#9CA3AF'], // 회색 - ['#DC2626', '#EA580C', '#CA8A04', '#65A30D'], // 따뜻한 + ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"], // 기본 + ["#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"], // 밝은 + ["#1F2937", "#374151", "#6B7280", "#9CA3AF"], // 회색 + ["#DC2626", "#EA580C", "#CA8A04", "#65A30D"], // 따뜻한 ].map((colorSet, setIdx) => (
+ + {/* 운영/작업 지원 섹션 */} +
+

📋 운영/작업 지원

+ +
+ + + + +
diff --git a/frontend/components/admin/dashboard/DashboardToolbar.tsx b/frontend/components/admin/dashboard/DashboardToolbar.tsx index 59a7584a..fd937e9a 100644 --- a/frontend/components/admin/dashboard/DashboardToolbar.tsx +++ b/frontend/components/admin/dashboard/DashboardToolbar.tsx @@ -1,17 +1,20 @@ 'use client'; -import React from 'react'; +import React, { useState } from 'react'; interface DashboardToolbarProps { onClearCanvas: () => void; onSaveLayout: () => void; + canvasBackgroundColor: string; + onCanvasBackgroundColorChange: (color: string) => void; } /** * 대시보드 툴바 컴포넌트 * - 전체 삭제, 레이아웃 저장 등 주요 액션 버튼 */ -export function DashboardToolbar({ onClearCanvas, onSaveLayout }: DashboardToolbarProps) { +export function DashboardToolbar({ onClearCanvas, onSaveLayout, canvasBackgroundColor, onCanvasBackgroundColorChange }: DashboardToolbarProps) { + const [showColorPicker, setShowColorPicker] = useState(false); return (
+ + {/* 캔버스 배경색 변경 버튼 */} +
+ + + {/* 색상 선택 패널 */} + {showColorPicker && ( +
+
+ onCanvasBackgroundColorChange(e.target.value)} + className="h-10 w-16 border border-gray-300 rounded cursor-pointer" + /> + onCanvasBackgroundColorChange(e.target.value)} + placeholder="#ffffff" + className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded" + /> +
+ + {/* 프리셋 색상 */} +
+ {[ + '#ffffff', '#f9fafb', '#f3f4f6', '#e5e7eb', + '#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', + '#10b981', '#06b6d4', '#6366f1', '#84cc16', + ].map((color) => ( +
+ + +
+ )} +
); } diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index 4155a00a..f069e477 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -1,9 +1,16 @@ -'use client'; +"use client"; -import React, { useState, useCallback } from 'react'; -import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from './types'; -import { QueryEditor } from './QueryEditor'; -import { ChartConfigPanel } from './ChartConfigPanel'; +import React, { useState, useCallback, useEffect } from "react"; +import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types"; +import { QueryEditor } from "./QueryEditor"; +import { ChartConfigPanel } from "./ChartConfigPanel"; +import { DataSourceSelector } from "./data-sources/DataSourceSelector"; +import { DatabaseConfig } from "./data-sources/DatabaseConfig"; +import { ApiConfig } from "./data-sources/ApiConfig"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { X, ChevronLeft, ChevronRight, Save } from "lucide-react"; interface ElementConfigModalProps { element: DashboardElement; @@ -13,24 +20,52 @@ interface ElementConfigModalProps { } /** - * 요소 설정 모달 컴포넌트 - * - 차트/위젯 데이터 소스 설정 - * - 쿼리 에디터 통합 - * - 차트 설정 패널 통합 + * 요소 설정 모달 컴포넌트 (리팩토링) + * - 2단계 플로우: 데이터 소스 선택 → 데이터 설정 및 차트 설정 + * - 새로운 데이터 소스 컴포넌트 통합 */ export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) { const [dataSource, setDataSource] = useState( - element.dataSource || { type: 'database', refreshInterval: 30000 } - ); - const [chartConfig, setChartConfig] = useState( - element.chartConfig || {} + element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }, ); + const [chartConfig, setChartConfig] = useState(element.chartConfig || {}); const [queryResult, setQueryResult] = useState(null); - const [activeTab, setActiveTab] = useState<'query' | 'chart'>('query'); + const [currentStep, setCurrentStep] = useState<1 | 2>(1); + // 주석 + // 모달이 열릴 때 초기화 + useEffect(() => { + if (isOpen) { + setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }); + setChartConfig(element.chartConfig || {}); + setQueryResult(null); + setCurrentStep(1); + } + }, [isOpen, element]); - // 데이터 소스 변경 처리 - const handleDataSourceChange = useCallback((newDataSource: ChartDataSource) => { - setDataSource(newDataSource); + // 데이터 소스 타입 변경 + const handleDataSourceTypeChange = useCallback((type: "database" | "api") => { + if (type === "database") { + setDataSource({ + type: "database", + connectionType: "current", + refreshInterval: 0, + }); + } else { + setDataSource({ + type: "api", + method: "GET", + refreshInterval: 0, + }); + } + + // 데이터 소스 변경 시 쿼리 결과와 차트 설정 초기화 + setQueryResult(null); + setChartConfig({}); + }, []); + + // 데이터 소스 업데이트 + const handleDataSourceUpdate = useCallback((updates: Partial) => { + setDataSource((prev) => ({ ...prev, ...updates })); }, []); // 차트 설정 변경 처리 @@ -41,12 +76,22 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 쿼리 테스트 결과 처리 const handleQueryTest = useCallback((result: QueryResult) => { setQueryResult(result); - // 쿼리 결과가 나오면 자동으로 차트 설정 탭으로 이동 - if (result.rows.length > 0) { - setActiveTab('chart'); - } }, []); + // 다음 단계로 이동 + const handleNext = useCallback(() => { + if (currentStep === 1) { + setCurrentStep(2); + } + }, [currentStep]); + + // 이전 단계로 이동 + const handlePrev = useCallback(() => { + if (currentStep > 1) { + setCurrentStep((prev) => (prev - 1) as 1 | 2); + } + }, [currentStep]); + // 저장 처리 const handleSave = useCallback(() => { const updatedElement: DashboardElement = { @@ -61,106 +106,139 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 모달이 열려있지 않으면 렌더링하지 않음 if (!isOpen) return null; + // 시계, 달력, 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음 + if ( + element.type === "widget" && + (element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management") + ) { + return null; + } + + // 저장 가능 여부 확인 + const isPieChart = element.subtype === "pie" || element.subtype === "donut"; + const isApiSource = dataSource.type === "api"; + + const canSave = + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.xAxis && + (isPieChart || isApiSource + ? // 파이/도넛 차트 또는 REST API: Y축 또는 집계 함수 필요 + chartConfig.yAxis || + (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0) || + chartConfig.aggregation === "count" + : // 일반 차트 (DB): Y축 필수 + chartConfig.yAxis || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0)); + return ( -
-
+
+
{/* 모달 헤더 */} -
+
-

- {element.title} 설정 -

-

- 데이터 소스와 차트 설정을 구성하세요 +

{element.title} 설정

+

+ {currentStep === 1 ? "데이터 소스를 선택하세요" : "쿼리를 실행하고 차트를 설정하세요"}

- +
- {/* 탭 네비게이션 */} -
- - + {/* 진행 상황 표시 */} +
+
+
+ 단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"} +
+ {Math.round((currentStep / 2) * 100)}% 완료 +
+
- {/* 탭 내용 */} + {/* 단계별 내용 */}
- {activeTab === 'query' && ( - + {currentStep === 1 && ( + )} - {activeTab === 'chart' && ( - + {currentStep === 2 && ( +
+ {/* 왼쪽: 데이터 설정 */} +
+ {dataSource.type === "database" ? ( + <> + + + + ) : ( + + )} +
+ + {/* 오른쪽: 차트 설정 */} +
+ {queryResult && queryResult.rows.length > 0 ? ( + + ) : ( +
+
+
데이터를 가져온 후 차트 설정이 표시됩니다
+
+
+ )} +
+
)}
{/* 모달 푸터 */} -
-
- {dataSource.query && ( - <> - 💾 쿼리: {dataSource.query.length > 50 - ? `${dataSource.query.substring(0, 50)}...` - : dataSource.query} - +
+
+ {queryResult && ( + + 📊 {queryResult.rows.length}개 데이터 로드됨 + )}
- +
- + )} + - + + {currentStep === 1 ? ( + + ) : ( + + )}
diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx index 5aa70a80..ace8c3be 100644 --- a/frontend/components/admin/dashboard/QueryEditor.tsx +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -1,7 +1,18 @@ -'use client'; +"use client"; -import React, { useState, useCallback } from 'react'; -import { ChartDataSource, QueryResult } from './types'; +import React, { useState, useCallback } from "react"; +import { ChartDataSource, QueryResult } from "./types"; +import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; +import { dashboardApi } from "@/lib/api/dashboard"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Play, Loader2, Database, Code } from "lucide-react"; interface QueryEditorProps { dataSource?: ChartDataSource; @@ -13,73 +24,88 @@ interface QueryEditorProps { * SQL 쿼리 에디터 컴포넌트 * - SQL 쿼리 작성 및 편집 * - 쿼리 실행 및 결과 미리보기 - * - 데이터 소스 설정 + * - 현재 DB / 외부 DB 분기 처리 */ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: QueryEditorProps) { - const [query, setQuery] = useState(dataSource?.query || ''); + const [query, setQuery] = useState(dataSource?.query || ""); const [isExecuting, setIsExecuting] = useState(false); const [queryResult, setQueryResult] = useState(null); const [error, setError] = useState(null); // 쿼리 실행 const executeQuery = useCallback(async () => { + console.log("🚀 executeQuery 호출됨!"); + console.log("📝 현재 쿼리:", query); + console.log("✅ query.trim():", query.trim()); + if (!query.trim()) { - setError('쿼리를 입력해주세요.'); + setError("쿼리를 입력해주세요."); + return; + } + + // 외부 DB인 경우 커넥션 ID 확인 + if (dataSource?.connectionType === "external" && !dataSource?.externalConnectionId) { + setError("외부 DB 커넥션을 선택해주세요."); + console.log("❌ 쿼리가 비어있음!"); return; } setIsExecuting(true); setError(null); + console.log("🔄 쿼리 실행 시작..."); try { - // 실제 API 호출 - const response = await fetch('http://localhost:8080/api/dashboards/execute-query', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token') || 'test-token'}` // JWT 토큰 사용 - }, - body: JSON.stringify({ query: query.trim() }) - }); + let apiResult: { columns: string[]; rows: any[]; rowCount: number }; - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || '쿼리 실행에 실패했습니다.'); + // 현재 DB vs 외부 DB 분기 + if (dataSource?.connectionType === "external" && dataSource?.externalConnectionId) { + // 외부 DB 쿼리 실행 + const result = await ExternalDbConnectionAPI.executeQuery( + parseInt(dataSource.externalConnectionId), + query.trim(), + ); + + if (!result.success) { + throw new Error(result.message || "외부 DB 쿼리 실행에 실패했습니다."); + } + + // ExternalDbConnectionAPI의 응답을 통일된 형식으로 변환 + apiResult = { + columns: result.data?.[0] ? Object.keys(result.data[0]) : [], + rows: result.data || [], + rowCount: result.data?.length || 0, + }; + } else { + // 현재 DB 쿼리 실행 + apiResult = await dashboardApi.executeQuery(query.trim()); } - const apiResult = await response.json(); - - if (!apiResult.success) { - throw new Error(apiResult.message || '쿼리 실행에 실패했습니다.'); - } - - // API 결과를 QueryResult 형식으로 변환 + // 결과를 QueryResult 형식으로 변환 const result: QueryResult = { - columns: apiResult.data.columns, - rows: apiResult.data.rows, - totalRows: apiResult.data.rowCount, - executionTime: 0 // API에서 실행 시간을 제공하지 않으므로 0으로 설정 + columns: apiResult.columns, + rows: apiResult.rows, + totalRows: apiResult.rowCount, + executionTime: 0, }; - + setQueryResult(result); onQueryTest?.(result); // 데이터 소스 업데이트 onDataSourceChange({ - type: 'database', + ...dataSource, + type: "database", query: query.trim(), - refreshInterval: dataSource?.refreshInterval || 30000, - lastExecuted: new Date().toISOString() + refreshInterval: dataSource?.refreshInterval ?? 0, + lastExecuted: new Date().toISOString(), }); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : '쿼리 실행 중 오류가 발생했습니다.'; + const errorMessage = err instanceof Error ? err.message : "쿼리 실행 중 오류가 발생했습니다."; setError(errorMessage); - // console.error('Query execution error:', err); } finally { setIsExecuting(false); } - }, [query, dataSource?.refreshInterval, onDataSourceChange, onQueryTest]); + }, [query, dataSource, onDataSourceChange, onQueryTest]); // 샘플 쿼리 삽입 const insertSampleQuery = useCallback((sampleType: string) => { @@ -105,7 +131,7 @@ FROM orders WHERE order_date >= CURRENT_DATE - INTERVAL '12 months' GROUP BY DATE_TRUNC('month', order_date) ORDER BY month;`, - + users: `-- 사용자 가입 추이 SELECT DATE_TRUNC('week', created_at) as week, @@ -114,7 +140,7 @@ FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '3 months' GROUP BY DATE_TRUNC('week', created_at) ORDER BY week;`, - + products: `-- 상품별 판매량 SELECT product_name, @@ -137,193 +163,166 @@ SELECT FROM regional_sales WHERE year = EXTRACT(YEAR FROM CURRENT_DATE) GROUP BY region -ORDER BY Q4 DESC;` +ORDER BY Q4 DESC;`, }; - setQuery(samples[sampleType as keyof typeof samples] || ''); + setQuery(samples[sampleType as keyof typeof samples] || ""); }, []); return ( -
+
{/* 쿼리 에디터 헤더 */} -
-

📝 SQL 쿼리 에디터

-
- +
+
+ +

SQL 쿼리 에디터

+
{/* 샘플 쿼리 버튼들 */} -
- 샘플 쿼리: - - - - - -
+ +
+ + + + + + +
+
{/* SQL 쿼리 입력 영역 */} -
-