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/scripts/migrate-input-type-to-web-type.ts b/backend-node/scripts/migrate-input-type-to-web-type.ts new file mode 100644 index 00000000..65c64b14 --- /dev/null +++ b/backend-node/scripts/migrate-input-type-to-web-type.ts @@ -0,0 +1,168 @@ +import { query } from "../src/database/db"; +import { logger } from "../src/utils/logger"; + +/** + * input_type을 web_type으로 마이그레이션하는 스크립트 + * + * 목적: + * - column_labels 테이블의 input_type 값을 읽어서 + * - 해당하는 기본 web_type 값으로 변환 + * - web_type이 null인 경우에만 업데이트 + */ + +// input_type → 기본 web_type 매핑 +const INPUT_TYPE_TO_WEB_TYPE: Record = { + text: "text", // 일반 텍스트 + number: "number", // 정수 + date: "date", // 날짜 + code: "code", // 코드 선택박스 + entity: "entity", // 엔티티 참조 + select: "select", // 선택박스 + checkbox: "checkbox", // 체크박스 + radio: "radio", // 라디오버튼 + direct: "text", // direct는 text로 매핑 +}; + +async function migrateInputTypeToWebType() { + try { + logger.info("=".repeat(60)); + logger.info("input_type → web_type 마이그레이션 시작"); + logger.info("=".repeat(60)); + + // 1. 현재 상태 확인 + const stats = await query<{ + total: string; + has_input_type: string; + has_web_type: string; + needs_migration: string; + }>( + `SELECT + COUNT(*) as total, + COUNT(input_type) FILTER (WHERE input_type IS NOT NULL) as has_input_type, + COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type, + COUNT(*) FILTER (WHERE input_type IS NOT NULL AND web_type IS NULL) as needs_migration + FROM column_labels` + ); + + const stat = stats[0]; + logger.info("\n📊 현재 상태:"); + logger.info(` - 전체 컬럼: ${stat.total}개`); + logger.info(` - input_type 있음: ${stat.has_input_type}개`); + logger.info(` - web_type 있음: ${stat.has_web_type}개`); + logger.info(` - 마이그레이션 필요: ${stat.needs_migration}개`); + + if (parseInt(stat.needs_migration) === 0) { + logger.info("\n✅ 마이그레이션이 필요한 데이터가 없습니다."); + return; + } + + // 2. input_type별 분포 확인 + const distribution = await query<{ + input_type: string; + count: string; + }>( + `SELECT + input_type, + COUNT(*) as count + FROM column_labels + WHERE input_type IS NOT NULL AND web_type IS NULL + GROUP BY input_type + ORDER BY input_type` + ); + + logger.info("\n📋 input_type별 분포:"); + distribution.forEach((item) => { + const webType = + INPUT_TYPE_TO_WEB_TYPE[item.input_type] || item.input_type; + logger.info(` - ${item.input_type} → ${webType}: ${item.count}개`); + }); + + // 3. 마이그레이션 실행 + logger.info("\n🔄 마이그레이션 실행 중..."); + + let totalUpdated = 0; + + for (const [inputType, webType] of Object.entries(INPUT_TYPE_TO_WEB_TYPE)) { + const result = await query( + `UPDATE column_labels + SET + web_type = $1, + updated_date = NOW() + WHERE input_type = $2 + AND web_type IS NULL + RETURNING id, table_name, column_name`, + [webType, inputType] + ); + + if (result.length > 0) { + logger.info( + ` ✓ ${inputType} → ${webType}: ${result.length}개 업데이트` + ); + totalUpdated += result.length; + + // 처음 5개만 출력 + result.slice(0, 5).forEach((row: any) => { + logger.info(` - ${row.table_name}.${row.column_name}`); + }); + if (result.length > 5) { + logger.info(` ... 외 ${result.length - 5}개`); + } + } + } + + // 4. 결과 확인 + const afterStats = await query<{ + total: string; + has_web_type: string; + }>( + `SELECT + COUNT(*) as total, + COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type + FROM column_labels` + ); + + const afterStat = afterStats[0]; + + logger.info("\n" + "=".repeat(60)); + logger.info("✅ 마이그레이션 완료!"); + logger.info("=".repeat(60)); + logger.info(`📊 최종 통계:`); + logger.info(` - 전체 컬럼: ${afterStat.total}개`); + logger.info(` - web_type 설정됨: ${afterStat.has_web_type}개`); + logger.info(` - 업데이트된 컬럼: ${totalUpdated}개`); + logger.info("=".repeat(60)); + + // 5. 샘플 데이터 출력 + logger.info("\n📝 샘플 데이터 (check_report_mng 테이블):"); + const samples = await query<{ + column_name: string; + input_type: string; + web_type: string; + detail_settings: string; + }>( + `SELECT + column_name, + input_type, + web_type, + detail_settings + FROM column_labels + WHERE table_name = 'check_report_mng' + ORDER BY column_name + LIMIT 10` + ); + + samples.forEach((sample) => { + logger.info( + ` ${sample.column_name}: ${sample.input_type} → ${sample.web_type}` + ); + }); + + process.exit(0); + } catch (error) { + logger.error("❌ 마이그레이션 실패:", error); + process.exit(1); + } +} + +// 스크립트 실행 +migrateInputTypeToWebType(); 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/config/environment.ts b/backend-node/src/config/environment.ts index a4c6c33b..e350642d 100644 --- a/backend-node/src/config/environment.ts +++ b/backend-node/src/config/environment.ts @@ -75,6 +75,8 @@ const getCorsOrigin = (): string[] | boolean => { "http://localhost:9771", // 로컬 개발 환경 "http://192.168.0.70:5555", // 내부 네트워크 접근 "http://39.117.244.52:5555", // 외부 네트워크 접근 + "https://v1.vexplor.com", // 운영 프론트엔드 + "https://api.vexplor.com", // 운영 백엔드 ]; }; diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index d35c102b..0f6f07cc 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,170 @@ 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 호출 오류", + }); + } + } + + /** + * 테이블 스키마 조회 (날짜 컬럼 감지용) + * POST /api/dashboards/table-schema + */ + async getTableSchema( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName } = req.body; + + if (!tableName || typeof tableName !== "string") { + res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + }); + return; + } + + // 테이블명 검증 (SQL 인젝션 방지) + if (!/^[a-z_][a-z0-9_]*$/i.test(tableName)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + }); + return; + } + + // PostgreSQL information_schema에서 컬럼 정보 조회 + const query = ` + SELECT + column_name, + data_type, + udt_name + FROM information_schema.columns + WHERE table_name = $1 + ORDER BY ordinal_position + `; + + const result = await PostgreSQLService.query(query, [ + tableName.toLowerCase(), + ]); + + // 날짜/시간 타입 컬럼 필터링 + const dateColumns = result.rows + .filter((row: any) => { + const dataType = row.data_type?.toLowerCase(); + const udtName = row.udt_name?.toLowerCase(); + return ( + dataType === "timestamp" || + dataType === "timestamp without time zone" || + dataType === "timestamp with time zone" || + dataType === "date" || + dataType === "time" || + dataType === "time without time zone" || + dataType === "time with time zone" || + udtName === "timestamp" || + udtName === "timestamptz" || + udtName === "date" || + udtName === "time" || + udtName === "timetz" + ); + }) + .map((row: any) => row.column_name); + + res.status(200).json({ + success: true, + data: { + tableName, + columns: result.rows.map((row: any) => ({ + name: row.column_name, + type: row.data_type, + udtName: row.udt_name, + })), + dateColumns, + }, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: "테이블 스키마 조회 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : "스키마 조회 오류", + }); + } + } +} 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..87db696b 100644 --- a/backend-node/src/routes/dashboardRoutes.ts +++ b/backend-node/src/routes/dashboardRoutes.ts @@ -1,37 +1,67 @@ -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.post( + "/table-schema", + dashboardController.getTableSchema.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/DashboardService.ts b/backend-node/src/services/DashboardService.ts index fa0ce775..c25efe4f 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -300,23 +300,31 @@ export class DashboardService { const elementsResult = await PostgreSQLService.query(elementsQuery, [dashboardId]); // 3. 요소 데이터 변환 - const elements: DashboardElement[] = elementsResult.rows.map((row: any) => ({ - id: row.id, - type: row.element_type, - subtype: row.element_subtype, - position: { - x: row.position_x, - y: row.position_y - }, - size: { - width: row.width, - height: row.height - }, - title: row.title, - content: row.content, - dataSource: JSON.parse(row.data_source_config || '{}'), - chartConfig: JSON.parse(row.chart_config || '{}') - })); + console.log('📊 대시보드 요소 개수:', elementsResult.rows.length); + + const elements: DashboardElement[] = elementsResult.rows.map((row: any, index: number) => { + const element = { + id: row.id, + type: row.element_type, + subtype: row.element_subtype, + position: { + x: row.position_x, + y: row.position_y + }, + size: { + width: row.width, + height: row.height + }, + title: row.title, + content: row.content, + dataSource: JSON.parse(row.data_source_config || '{}'), + chartConfig: JSON.parse(row.chart_config || '{}') + }; + + console.log(`📊 위젯 #${index + 1}: type="${element.type}", subtype="${element.subtype}", title="${element.title}"`); + + return element; + }); return { id: dashboard.id, 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/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 7c32bda6..6da8d16a 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1018,14 +1018,14 @@ export class ScreenManagementService { [tableName] ); - // column_labels 테이블에서 웹타입 정보 조회 (있는 경우) + // column_labels 테이블에서 입력타입 정보 조회 (있는 경우) const webTypeInfo = await query<{ column_name: string; - web_type: string | null; + input_type: string | null; column_label: string | null; detail_settings: any; }>( - `SELECT column_name, web_type, column_label, detail_settings + `SELECT column_name, input_type, column_label, detail_settings FROM column_labels WHERE table_name = $1`, [tableName] @@ -1045,7 +1045,7 @@ export class ScreenManagementService { this.getColumnLabel(column.column_name), dataType: column.data_type, webType: - (webTypeData?.web_type as WebType) || + (webTypeData?.input_type as WebType) || this.inferWebType(column.data_type), isNullable: column.is_nullable, columnDefault: column.column_default || undefined, @@ -1522,7 +1522,7 @@ export class ScreenManagementService { c.column_name, COALESCE(cl.column_label, c.column_name) as column_label, c.data_type, - COALESCE(cl.web_type, 'text') as web_type, + COALESCE(cl.input_type, 'text') as web_type, c.is_nullable, c.column_default, c.character_maximum_length, @@ -1548,7 +1548,7 @@ export class ScreenManagementService { } /** - * 웹 타입 설정 (✅ Raw Query 전환 완료) + * 입력 타입 설정 (✅ Raw Query 전환 완료) */ async setColumnWebType( tableName: string, @@ -1556,16 +1556,16 @@ export class ScreenManagementService { webType: WebType, additionalSettings?: Partial ): Promise { - // UPSERT를 INSERT ... ON CONFLICT로 변환 + // UPSERT를 INSERT ... ON CONFLICT로 변환 (input_type 사용) await query( `INSERT INTO column_labels ( - table_name, column_name, column_label, web_type, detail_settings, + table_name, column_name, column_label, input_type, detail_settings, code_category, reference_table, reference_column, display_column, is_visible, display_order, description, created_date, updated_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (table_name, column_name) DO UPDATE SET - web_type = $4, + input_type = $4, column_label = $3, detail_settings = $5, code_category = $6, diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index dd8cb1cc..83f3a696 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -27,13 +27,13 @@ export class TableManagementService { columnName: string ): Promise<{ isCodeType: boolean; codeCategory?: string }> { try { - // column_labels 테이블에서 해당 컬럼의 web_type이 'code'인지 확인 + // column_labels 테이블에서 해당 컬럼의 input_type이 'code'인지 확인 const result = await query( - `SELECT web_type, code_category + `SELECT input_type, code_category FROM column_labels WHERE table_name = $1 AND column_name = $2 - AND web_type = 'code'`, + AND input_type = 'code'`, [tableName, columnName] ); @@ -167,7 +167,7 @@ export class TableManagementService { COALESCE(cl.column_label, c.column_name) as "displayName", c.data_type as "dataType", c.data_type as "dbType", - COALESCE(cl.web_type, 'text') as "webType", + COALESCE(cl.input_type, 'text') as "webType", COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.detail_settings, '') as "detailSettings", COALESCE(cl.description, '') as "description", @@ -483,7 +483,7 @@ export class TableManagementService { table_name: string; column_name: string; column_label: string | null; - web_type: string | null; + input_type: string | null; detail_settings: any; description: string | null; display_order: number | null; @@ -495,7 +495,7 @@ export class TableManagementService { created_date: Date | null; updated_date: Date | null; }>( - `SELECT id, table_name, column_name, column_label, web_type, detail_settings, + `SELECT id, table_name, column_name, column_label, input_type, detail_settings, description, display_order, is_visible, code_category, code_value, reference_table, reference_column, created_date, updated_date FROM column_labels @@ -512,7 +512,7 @@ export class TableManagementService { tableName: columnLabel.table_name || "", columnName: columnLabel.column_name || "", columnLabel: columnLabel.column_label || undefined, - webType: columnLabel.web_type || undefined, + webType: columnLabel.input_type || undefined, detailSettings: columnLabel.detail_settings || undefined, description: columnLabel.description || undefined, displayOrder: columnLabel.display_order || undefined, @@ -539,7 +539,7 @@ export class TableManagementService { } /** - * 컬럼 웹 타입 설정 + * 컬럼 입력 타입 설정 (web_type → input_type 통합) */ async updateColumnWebType( tableName: string, @@ -550,7 +550,7 @@ export class TableManagementService { ): Promise { try { logger.info( - `컬럼 웹 타입 설정 시작: ${tableName}.${columnName} = ${webType}` + `컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${webType}` ); // 웹 타입별 기본 상세 설정 생성 @@ -562,35 +562,28 @@ export class TableManagementService { ...detailSettings, }; - // column_labels UPSERT로 업데이트 또는 생성 + // column_labels UPSERT로 업데이트 또는 생성 (input_type만 사용) await query( `INSERT INTO column_labels ( - table_name, column_name, web_type, detail_settings, input_type, created_date, updated_date - ) VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + table_name, column_name, input_type, detail_settings, created_date, updated_date + ) VALUES ($1, $2, $3, $4, NOW(), NOW()) ON CONFLICT (table_name, column_name) DO UPDATE SET - web_type = EXCLUDED.web_type, + input_type = EXCLUDED.input_type, detail_settings = EXCLUDED.detail_settings, - input_type = COALESCE(EXCLUDED.input_type, column_labels.input_type), updated_date = NOW()`, - [ - tableName, - columnName, - webType, - JSON.stringify(finalDetailSettings), - inputType || null, - ] + [tableName, columnName, webType, JSON.stringify(finalDetailSettings)] ); logger.info( - `컬럼 웹 타입 설정 완료: ${tableName}.${columnName} = ${webType}` + `컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${webType}` ); } catch (error) { logger.error( - `컬럼 웹 타입 설정 중 오류 발생: ${tableName}.${columnName}`, + `컬럼 입력 타입 설정 중 오류 발생: ${tableName}.${columnName}`, error ); throw new Error( - `컬럼 웹 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}` + `컬럼 입력 타입 설정 실패: ${error instanceof Error ? error.message : "Unknown error"}` ); } } 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/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index 8257a238..4d862d9e 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -19,6 +19,9 @@ services: - CORS_CREDENTIALS=true - LOG_LEVEL=debug - ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure + - KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA + - ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 + - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} volumes: - ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영 - /app/node_modules diff --git a/docs/input-type-detail-type-system.md b/docs/input-type-detail-type-system.md new file mode 100644 index 00000000..e214a256 --- /dev/null +++ b/docs/input-type-detail-type-system.md @@ -0,0 +1,304 @@ +# 입력 타입과 세부 타입 시스템 가이드 + +## 📋 개요 + +화면 관리 시스템에서 사용자가 **입력 타입**과 **세부 타입**을 2단계로 선택할 수 있는 시스템입니다. + +### 구조 + +1. **입력 타입 (Input Type)**: 테이블 타입 관리에서 정의한 8개 핵심 타입 +2. **세부 타입 (Detail Type)**: 입력 타입의 구체적인 구현 방식 (웹타입) + +``` +입력 타입 (PropertiesPanel에서 선택) + ↓ +세부 타입 (DetailSettingsPanel에서 선택) + ↓ +세부 설정 (DetailSettingsPanel에서 설정) +``` + +--- + +## 🎯 8개 핵심 입력 타입과 세부 타입 + +### 1. **텍스트 (text)** + +사용 가능한 세부 타입: + +- `text` - 일반 텍스트 입력 +- `email` - 이메일 주소 입력 +- `tel` - 전화번호 입력 +- `url` - 웹사이트 주소 입력 +- `textarea` - 여러 줄 텍스트 입력 +- `password` - 비밀번호 입력 (마스킹) + +### 2. **숫자 (number)** + +사용 가능한 세부 타입: + +- `number` - 정수 숫자 입력 +- `decimal` - 소수점 포함 숫자 입력 + +### 3. **날짜 (date)** + +사용 가능한 세부 타입: + +- `date` - 날짜 선택 (YYYY-MM-DD) +- `datetime` - 날짜와 시간 선택 +- `time` - 시간 선택 (HH:mm) + +### 4. **코드 (code)** + +세부 타입: + +- `code` - 공통 코드 선택 (세부 타입 고정) +- 코드 카테고리는 상세 설정에서 선택 + +### 5. **엔티티 (entity)** + +세부 타입: + +- `entity` - 다른 테이블 참조 (세부 타입 고정) +- 참조 테이블은 상세 설정에서 선택 + +### 6. **선택박스 (select)** + +사용 가능한 세부 타입: + +- `select` - 기본 드롭다운 선택 +- `dropdown` - 검색 기능이 있는 드롭다운 + +### 7. **체크박스 (checkbox)** + +사용 가능한 세부 타입: + +- `checkbox` - 단일 체크박스 +- `boolean` - On/Off 스위치 + +### 8. **라디오버튼 (radio)** + +세부 타입: + +- `radio` - 라디오 버튼 그룹 (세부 타입 고정) + +--- + +## 🔧 사용 방법 + +### 1. PropertiesPanel - 입력 타입 선택 + +위젯 컴포넌트를 선택하면 **속성 편집** 패널에서 입력 타입을 선택할 수 있습니다. + +```typescript +// 입력 타입 선택 + +``` + +**동작:** + +- 입력 타입을 선택하면 해당 타입의 **기본 세부 타입**이 자동으로 설정됩니다 +- 예: `text` 입력 타입 선택 → `text` 세부 타입 자동 설정 + +### 2. DetailSettingsPanel - 세부 타입 선택 + +**상세 설정** 패널에서 선택한 입력 타입의 세부 타입을 선택할 수 있습니다. + +```typescript +// 세부 타입 선택 + +``` + +**동작:** + +- 입력 타입에 해당하는 세부 타입만 표시됩니다 +- 세부 타입을 변경하면 `widgetType` 속성이 업데이트됩니다 + +### 3. DetailSettingsPanel - 세부 설정 + +세부 타입을 선택한 후, 해당 타입의 상세 설정을 할 수 있습니다. + +예: + +- **날짜 (date)**: 날짜 형식, 최소/최대 날짜 등 +- **숫자 (number)**: 최소/최대값, 소수점 자리수 등 +- **코드 (code)**: 코드 카테고리 선택 +- **엔티티 (entity)**: 참조 테이블, 표시 컬럼 선택 + +--- + +## 📁 파일 구조 + +### 새로 추가된 파일 + +#### `frontend/types/input-type-mapping.ts` + +입력 타입과 세부 타입 매핑 정의 + +```typescript +// 8개 핵심 입력 타입 +export type BaseInputType = "text" | "number" | "date" | ...; + +// 입력 타입별 세부 타입 매핑 +export const INPUT_TYPE_DETAIL_TYPES: Record; + +// 유틸리티 함수들 +export function getBaseInputType(webType: WebType): BaseInputType; +export function getDetailTypes(baseInputType: BaseInputType): DetailTypeOption[]; +export function getDefaultDetailType(baseInputType: BaseInputType): WebType; +``` + +### 수정된 파일 + +#### `frontend/components/screen/panels/PropertiesPanel.tsx` + +- 입력 타입 선택 UI 추가 +- 웹타입 선택 → 입력 타입 선택으로 변경 + +#### `frontend/components/screen/panels/DetailSettingsPanel.tsx` + +- 세부 타입 선택 UI 추가 +- 입력 타입 표시 +- 세부 타입 목록 동적 생성 + +--- + +## 🎨 UI 레이아웃 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 속성 편집 (PropertiesPanel) │ +├─────────────────────────────────────────────────────────────┤ +│ 입력 타입: [텍스트 ▼] ← 8개 중 선택 │ +│ 세부 타입은 "상세 설정"에서 선택하세요 │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ 상세 설정 (DetailSettingsPanel) │ +├─────────────────────────────────────────────────────────────┤ +│ 입력 타입: [text] │ +├─────────────────────────────────────────────────────────────┤ +│ 세부 타입 선택: │ +│ [일반 텍스트 ▼] ← 입력 타입에 따라 동적으로 변경 │ +│ - 일반 텍스트 │ +│ - 이메일 │ +│ - 전화번호 │ +│ - URL │ +│ - 여러 줄 텍스트 │ +│ - 비밀번호 │ +├─────────────────────────────────────────────────────────────┤ +│ [세부 설정 영역] │ +│ (선택한 세부 타입에 맞는 설정 패널) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 🔄 데이터 흐름 + +### 1. 새 컴포넌트 생성 시 + +``` +테이블 컬럼 드래그 + → 컬럼의 dataType 분석 + → 입력 타입 자동 선택 (text, number, date 등) + → 기본 세부 타입 자동 설정 (text, number, date 등) +``` + +### 2. 입력 타입 변경 시 + +``` +PropertiesPanel에서 입력 타입 선택 + → 해당 입력 타입의 기본 세부 타입 설정 + → DetailSettingsPanel 세부 타입 목록 업데이트 +``` + +### 3. 세부 타입 변경 시 + +``` +DetailSettingsPanel에서 세부 타입 선택 + → widgetType 업데이트 + → 해당 세부 타입의 설정 패널 표시 +``` + +--- + +## 🚀 확장 가능성 + +### 세부 타입 추가 + +새로운 세부 타입을 추가하려면: + +1. `frontend/types/input-type-mapping.ts`의 `INPUT_TYPE_DETAIL_TYPES`에 추가 +2. 해당 세부 타입의 설정 패널 구현 +3. DB의 `web_types` 테이블에 레코드 추가 + +### 입력 타입 추가 + +새로운 입력 타입을 추가하려면: + +1. `BaseInputType` 타입에 추가 +2. `BASE_INPUT_TYPE_OPTIONS`에 옵션 추가 +3. `INPUT_TYPE_DETAIL_TYPES`에 세부 타입 목록 정의 +4. 테이블 타입 관리 시스템 업데이트 + +--- + +## ✅ 체크리스트 + +- [x] 8개 핵심 입력 타입 정의 +- [x] 입력 타입별 세부 타입 매핑 +- [x] PropertiesPanel에 입력 타입 선택 UI 추가 +- [x] DetailSettingsPanel에 세부 타입 선택 UI 추가 +- [x] 입력 타입 변경 시 기본 세부 타입 자동 설정 +- [x] 세부 타입 변경 시 widgetType 업데이트 +- [x] 타입 안전성 보장 (TypeScript) + +--- + +## 📝 사용 예시 + +### 텍스트 입력 필드 생성 + +1. **PropertiesPanel**에서 입력 타입을 "텍스트"로 선택 +2. **DetailSettingsPanel**로 이동 +3. 세부 타입에서 "이메일" 선택 +4. 이메일 형식 검증 등 세부 설정 입력 + +### 날짜 입력 필드 생성 + +1. **PropertiesPanel**에서 입력 타입을 "날짜"로 선택 +2. **DetailSettingsPanel**로 이동 +3. 세부 타입에서 "날짜+시간" 선택 +4. 날짜 형식, 최소/최대 날짜 등 설정 + +--- + +## 🐛 문제 해결 + +### 세부 타입이 표시되지 않음 + +- 입력 타입이 올바르게 설정되었는지 확인 +- `getDetailTypes()` 함수가 올바른 값을 반환하는지 확인 + +### 입력 타입 변경 시 세부 타입이 초기화되지 않음 + +- `getDefaultDetailType()` 함수 확인 +- `onUpdateProperty("widgetType", ...)` 호출 확인 + +--- + +## 📚 참고 자료 + +- [테이블 타입 관리 개선 계획서](../테이블_타입_관리_개선_계획서.md) +- [테이블 타입 관리 개선 사용 가이드](../테이블_타입_관리_개선_사용_가이드.md) +- [화면관리 시스템 개요](./screen-management-system.md) diff --git a/docs/그리드_컬럼수_옵션_통합.md b/docs/그리드_컬럼수_옵션_통합.md new file mode 100644 index 00000000..34b1ba41 --- /dev/null +++ b/docs/그리드_컬럼수_옵션_통합.md @@ -0,0 +1,223 @@ +# 그리드 컬럼 수 옵션 통합 + +## 개요 + +"그리드 컬럼 수" 옵션과 "컴포넌트 너비" 옵션이 중복된 기능을 제공하여 혼란을 야기했습니다. +사용자 편의성을 위해 **"컴포넌트 너비" 옵션만 사용**하도록 통합하고, 내부적으로 `gridColumns` 값을 자동 계산하도록 변경했습니다. + +## 문제점 + +### 기존 상황 + +1. **그리드 컬럼 수 옵션**: 1-12 숫자 입력 +2. **컴포넌트 너비 옵션**: 1/12 ~ 12/12 선택 (퍼센트로 변환) + +→ 같은 기능을 두 가지 방식으로 제공하여 사용자 혼란 발생 + +### 예시 + +- 사용자가 "그리드 컬럼 수"를 6으로 설정 +- 하지만 "컴포넌트 너비"가 1/4 (3컬럼)로 설정되어 있음 +- 두 설정이 충돌하여 예상과 다른 결과 발생 + +## 해결 방법 + +### 1. UI 단순화 + +**제거된 옵션**: + +- ❌ PropertiesPanel의 "그리드 컬럼 수 (1-12)" 입력 필드 +- ❌ DataTableConfigPanel의 "그리드 컬럼 수" 선택 상자 + +**유지된 옵션**: + +- ✅ PropertiesPanel의 "컴포넌트 너비" 선택 상자 (1/12 ~ 12/12) + +### 2. 자동 계산 로직 + +컴포넌트 너비 선택 시 `gridColumns` 자동 계산: + +```typescript +// PropertiesPanel.tsx (764-788줄) +const columnsMap: Record = { + twelfth: 1, // 1/12 + small: 2, // 2/12 + quarter: 3, // 3/12 (1/4) + third: 4, // 4/12 (1/3) + "five-twelfths": 5, // 5/12 + half: 6, // 6/12 (절반) + "seven-twelfths": 7, // 7/12 + twoThirds: 8, // 8/12 (2/3) + threeQuarters: 9, // 9/12 (3/4) + "five-sixths": 10, // 10/12 + "eleven-twelfths": 11, // 11/12 + full: 12, // 12/12 (전체) +}; + +// 컴포넌트 너비 변경 시 +onUpdateProperty("style.width", newWidth); // 퍼센트 값 저장 +const gridColumns = columnsMap[value] || 6; +onUpdateProperty("gridColumns", gridColumns); // 컬럼 수 자동 계산 +``` + +### 3. 컴포넌트 생성 시 동작 + +```typescript +// ScreenDesigner.tsx (1756-1772줄) +// 일반 컴포넌트: defaultSize.width를 기준으로 그리드 컬럼 수 계산 +if (layout.gridSettings?.snapToGrid && gridInfo) { + const columnWidth = gridInfo.columnWidth + gridInfo.gap; + const estimatedColumns = Math.round( + component.defaultSize.width / columnWidth + ); + gridColumns = Math.max(1, Math.min(12, estimatedColumns)); // 1-12 범위 +} +``` + +## 변경 사항 + +### 파일 수정 + +#### 1. PropertiesPanel.tsx + +- ❌ 삭제: "그리드 컬럼 수" 입력 필드 (916-940줄) +- ❌ 삭제: `localInputs.gridColumns` 상태 (206-213줄) +- ✅ 추가: 컴포넌트 너비 변경 시 `gridColumns` 자동 계산 (764-788줄) + +#### 2. DataTableConfigPanel.tsx + +- ❌ 삭제: "그리드 컬럼 수" 선택 상자 (1437-1456줄) +- ❌ 삭제: `localValues.gridColumns` 초기화 (72줄, 182줄) + +#### 3. ScreenDesigner.tsx + +- ✅ 개선: 컴포넌트 드롭 시 `defaultSize.width` 기반으로 `gridColumns` 자동 계산 (1756-1772줄) + +## 사용 방법 + +### 컴포넌트 너비 조정 + +#### 방법 1: 드롭다운 선택 + +1. 컴포넌트 선택 +2. 속성 패널 > "컴포넌트 너비" 드롭다운 +3. 원하는 너비 선택 (예: "절반 (6/12)") +4. 자동으로 `style.width`와 `gridColumns` 모두 업데이트됨 + +#### 방법 2: 컴포넌트 생성 시 + +1. 컴포넌트 팔레트에서 드래그 +2. 캔버스에 드롭 +3. `defaultSize.width`를 기준으로 적절한 `gridColumns` 자동 설정 + +### 너비 옵션 설명 + +| 옵션 | 컬럼 수 | 퍼센트 | 설명 | +| ------------ | ------- | ------ | ----------- | +| 1/12 | 1 | 8.33% | 최소 | +| 작게 (2/12) | 2 | 16.67% | 매우 작음 | +| 1/4 (3/12) | 3 | 25% | 4등분의 1 | +| 1/3 (4/12) | 4 | 33.33% | 3등분의 1 | +| 5/12 | 5 | 41.67% | | +| 절반 (6/12) | 6 | 50% | 정확히 절반 | +| 7/12 | 7 | 58.33% | | +| 2/3 (8/12) | 8 | 66.67% | 3등분의 2 | +| 3/4 (9/12) | 9 | 75% | 4등분의 3 | +| 10/12 | 10 | 83.33% | | +| 11/12 | 11 | 91.67% | | +| 전체 (12/12) | 12 | 100% | 최대 | + +## 적용 효과 + +### 1. 사용자 경험 개선 + +- ✅ 단일 옵션으로 간소화 +- ✅ 직관적인 분수 표현 (1/4, 절반, 2/3 등) +- ✅ 설정 충돌 제거 + +### 2. 일관성 보장 + +- ✅ 컴포넌트 너비와 gridColumns 항상 동기화 +- ✅ 그리드 시스템과 자연스러운 통합 + +### 3. 개발자 편의 + +- ✅ 내부적으로 gridColumns는 여전히 사용 가능 +- ✅ 기존 데이터 호환성 유지 (gridColumns 필드 존재) + +## 내부 동작 + +### gridColumns 사용처 + +`gridColumns` 값은 사용자에게 직접 노출되지 않지만, 내부적으로 여전히 중요한 역할을 합니다: + +1. **그리드 레이아웃 계산**: 컴포넌트가 차지할 그리드 셀 수 결정 +2. **자동 배치**: 컴포넌트 자동 정렬 시 참조 +3. **반응형 조정**: 화면 크기 변경 시 비율 유지 + +### 값 동기화 흐름 + +``` +사용자 선택: "절반 (6/12)" + ↓ +1. style.width = "50%" 저장 + ↓ +2. gridColumns = 6 자동 계산 + ↓ +3. 그리드 시스템에서 6컬럼 너비로 렌더링 + ↓ +4. 실제 픽셀 너비 계산 및 적용 +``` + +## 마이그레이션 가이드 + +### 기존 화면 데이터 + +- **영향 없음**: 기존에 저장된 `gridColumns` 값은 그대로 유지 +- **자동 변환**: 컴포넌트 편집 시 `style.width`로부터 재계산 + +### 사용자 교육 + +1. "그리드 컬럼 수" 설정이 제거되었음을 안내 +2. "컴포넌트 너비"로 동일한 기능 사용 가능 +3. 더 직관적인 분수 표현 (1/4, 1/2 등) 강조 + +## 테스트 체크리스트 + +### UI 확인 + +- [ ] PropertiesPanel에 "그리드 컬럼 수" 입력 필드가 없는지 확인 +- [ ] DataTableConfigPanel에 "그리드 컬럼 수" 선택 상자가 없는지 확인 +- [ ] "컴포넌트 너비" 드롭다운이 정상 작동하는지 확인 + +### 기능 확인 + +- [ ] 컴포넌트 너비 변경 시 시각적으로 제대로 반영되는지 확인 +- [ ] 새 컴포넌트 생성 시 적절한 초기 너비로 생성되는지 확인 +- [ ] 그리드 ON/OFF 시 너비가 올바르게 적용되는지 확인 + +### 데이터 확인 + +- [ ] 컴포넌트 너비 변경 후 저장/불러오기 테스트 +- [ ] 기존 화면 데이터가 정상적으로 로드되는지 확인 +- [ ] `gridColumns` 값이 자동으로 계산되는지 확인 + +## 관련 파일 + +### 수정된 파일 + +- `/frontend/components/screen/panels/PropertiesPanel.tsx` +- `/frontend/components/screen/panels/DataTableConfigPanel.tsx` +- `/frontend/components/screen/ScreenDesigner.tsx` + +### 관련 문서 + +- [컴포넌트*기본*너비*설정*가이드.md](./컴포넌트_기본_너비_설정_가이드.md) + +## 버전 히스토리 + +### v1.0.0 (2025-10-14) + +- "그리드 컬럼 수" 옵션 제거 +- "컴포넌트 너비" 옵션으로 통합 +- `gridColumns` 자동 계산 로직 추가 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/docs/컴포넌트_기본_너비_설정_가이드.md b/docs/컴포넌트_기본_너비_설정_가이드.md new file mode 100644 index 00000000..94c36736 --- /dev/null +++ b/docs/컴포넌트_기본_너비_설정_가이드.md @@ -0,0 +1,225 @@ +# 컴포넌트 기본 너비 설정 가이드 + +## 개요 + +화면 관리에서 각 컴포넌트 타입별로 적절한 기본 너비를 설정하고, 컴포넌트가 지정된 너비를 벗어나지 않도록 스타일을 적용했습니다. + +## 변경 사항 + +### 1. 인풋 컴포넌트 기본 너비 조정 + +각 인풋 타입별로 적절한 기본 크기를 설정했습니다: + +#### 텍스트 입력 계열 + +- **텍스트 입력** (`text-input`): 300px × 40px +- **숫자 입력** (`number-input`): 200px × 40px +- **텍스트 영역** (`textarea-basic`): 400px × 100px + +#### 선택 입력 계열 + +- **선택상자** (`select-basic`): 250px × 40px +- **날짜 선택** (`date-input`): 220px × 40px +- **체크박스** (`checkbox-basic`): 150px × 32px +- **라디오 버튼** (`radio-basic`): 150px × 32px +- **슬라이더** (`slider-basic`): 250px × 40px +- **토글 스위치** (`toggle-switch`): 180px × 40px + +#### 파일 및 기타 + +- **파일 업로드** (`file-upload`): 350px × 40px + +#### 표시 컴포넌트 + +- **기본 버튼** (`button-primary`): 120px × 40px +- **텍스트 표시** (`text-display`): 150px × 24px +- **이미지 표시** (`image-display`): 200px × 200px +- **구분선** (`divider-line`): 400px × 2px +- **아코디언** (`accordion-basic`): 400px × 200px + +#### 데이터 컴포넌트 + +- **테이블 리스트** (`table-list`): 120px × 600px +- **카드 표시** (`card-display`): 기존 유지 + +### 2. 공통 스타일 적용 + +`/frontend/lib/registry/components/common/inputStyles.ts` 파일의 모든 스타일 클래스에 다음을 추가: + +- `max-w-full`: 최대 너비를 부모 컨테이너로 제한 +- `overflow-hidden`: 내용이 넘칠 경우 숨김 처리 + +적용된 클래스: + +- `INPUT_CLASSES.base` +- `INPUT_CLASSES.container` +- `INPUT_CLASSES.textarea` +- `INPUT_CLASSES.select` +- `INPUT_CLASSES.flexContainer` + +### 3. 개별 컴포넌트 스타일 적용 + +#### TextInputComponent + +- 컨테이너 div: `max-w-full overflow-hidden` 추가 +- input 요소: `max-w-full` 추가 +- textarea 요소: `max-w-full` 추가 + +#### RealtimePreviewDynamic + +- 컴포넌트 렌더링 컨테이너: `max-w-full overflow-hidden` 추가 + +## 적용 효과 + +### 1. 일관된 초기 크기 + +- 컴포넌트 드래그 앤 드롭 시 각 타입별로 적절한 기본 크기로 생성됨 +- 사용자가 별도로 크기를 조정할 필요 없이 바로 사용 가능 + +### 2. 그리드 시스템과의 통합 + +- **그리드 활성화 시**: `defaultSize.width`를 기준으로 적절한 그리드 컬럼 수 자동 계산 + - 예: 300px 너비 → 약 3-4 컬럼 (그리드 설정에 따라 다름) + - 계산된 컬럼 수에 맞춰 정확한 너비로 재조정 +- **그리드 비활성화 시**: `defaultSize`의 픽셀 값을 그대로 사용 +- 일관된 사용자 경험 제공 + +### 3. 너비 제한 + +- 컴포넌트가 설정된 너비를 벗어나지 않음 +- 부모 컨테이너 크기에 맞춰 자동으로 조정됨 +- 레이아웃 깨짐 방지 + +### 4. 반응형 대응 + +- `max-w-full` 속성으로 부모 컨테이너에 맞춰 자동 축소 +- `overflow-hidden`으로 내용 넘침 방지 + +## 사용 방법 + +### 새 컴포넌트 생성 시 + +1. 컴포넌트 팔레트에서 원하는 타입 선택 +2. 캔버스에 드래그 앤 드롭 +3. 자동으로 적절한 기본 크기로 생성됨 + +### 크기 조정 + +1. 컴포넌트 선택 +2. 속성 패널에서 "컴포넌트 너비" 선택 +3. 드롭다운에서 원하는 너비 선택 (1/12 ~ 12/12) +4. 또는 직접 픽셀 값 입력 + +## 주의 사항 + +### 기존 화면에 미치는 영향 + +- 이미 생성된 컴포넌트는 영향 받지 않음 +- 새로 생성되는 컴포넌트만 새로운 기본값 적용 + +### 스타일 우선순위 + +1. 인라인 style 속성 +2. componentConfig에서 설정한 크기 +3. defaultSize (새로 적용된 기본값) + +### 커스터마이징 + +- 각 컴포넌트의 `index.ts` 파일에서 `defaultSize` 수정 가능 +- 프로젝트 요구사항에 맞춰 조정 가능 + +## 테스트 방법 + +### 기본 크기 테스트 + +``` +1. 화면 디자이너 열기 +2. 각 인풋 타입 컴포넌트를 캔버스에 드롭 +3. 기본 크기가 적절한지 확인 +4. 여러 컴포넌트를 나란히 배치하여 일관성 확인 +``` + +### 너비 제한 테스트 + +``` +1. 컴포넌트 생성 후 선택 +2. 속성 패널에서 너비를 작은 값으로 설정 (예: 100px) +3. 컴포넌트 내부의 input이 너비를 벗어나지 않는지 확인 +4. 긴 텍스트 입력 시 overflow 처리 확인 +``` + +### 반응형 테스트 + +``` +1. 레이아웃 컨테이너 내부에 컴포넌트 배치 +2. 레이아웃 크기를 조정하여 컴포넌트가 적절히 축소되는지 확인 +3. 다양한 화면 해상도에서 테스트 +``` + +## 관련 파일 + +### 컴포넌트 정의 파일 + +- `/frontend/lib/registry/components/text-input/index.ts` +- `/frontend/lib/registry/components/number-input/index.ts` +- `/frontend/lib/registry/components/select-basic/index.ts` +- `/frontend/lib/registry/components/date-input/index.ts` +- `/frontend/lib/registry/components/textarea-basic/index.ts` +- `/frontend/lib/registry/components/checkbox-basic/index.ts` +- `/frontend/lib/registry/components/radio-basic/index.ts` +- `/frontend/lib/registry/components/file-upload/index.ts` +- `/frontend/lib/registry/components/slider-basic/index.ts` +- `/frontend/lib/registry/components/toggle-switch/index.ts` +- `/frontend/lib/registry/components/button-primary/index.ts` +- `/frontend/lib/registry/components/text-display/index.ts` +- `/frontend/lib/registry/components/image-display/index.ts` +- `/frontend/lib/registry/components/divider-line/index.ts` +- `/frontend/lib/registry/components/accordion-basic/index.ts` +- `/frontend/lib/registry/components/table-list/index.ts` + +### 공통 스타일 파일 + +- `/frontend/lib/registry/components/common/inputStyles.ts` + +### 렌더링 관련 파일 + +- `/frontend/components/screen/RealtimePreviewDynamic.tsx` +- `/frontend/lib/registry/components/text-input/TextInputComponent.tsx` + +### 화면 디자이너 + +- `/frontend/components/screen/ScreenDesigner.tsx` + - `handleComponentDrop` 함수 (1751-1800줄): 컴포넌트 드롭 시 그리드 컬럼 수 자동 계산 + - 그리드 활성화 시: `defaultSize.width` 기반으로 gridColumns 계산 후 너비 재조정 + - 그리드 비활성화 시: `defaultSize` 그대로 사용 + +## 향후 개선 사항 + +### 1. 반응형 기본값 + +- 화면 크기에 따라 다른 기본값 적용 +- 모바일, 태블릿, 데스크톱 각각 최적화 + +### 2. 사용자 정의 기본값 + +- 사용자가 자주 사용하는 크기를 기본값으로 저장 +- 프로젝트별 기본값 설정 기능 + +### 3. 스마트 크기 조정 + +- 주변 컴포넌트에 맞춰 자동으로 크기 조정 +- 레이블 길이에 따른 동적 너비 계산 + +### 4. 프리셋 제공 + +- 폼 레이아웃 프리셋 (라벨-입력 쌍) +- 검색 바 프리셋 +- 로그인 폼 프리셋 + +## 버전 히스토리 + +### v1.0.0 (2025-10-14) + +- 초기 기본 너비 설정 적용 +- 공통 스타일에 max-w-full, overflow-hidden 추가 +- 모든 인풋 컴포넌트 기본 크기 조정 diff --git a/docs/테이블_패널_컴포넌트_기본_너비_설정.md b/docs/테이블_패널_컴포넌트_기본_너비_설정.md new file mode 100644 index 00000000..2b3da3dd --- /dev/null +++ b/docs/테이블_패널_컴포넌트_기본_너비_설정.md @@ -0,0 +1,322 @@ +# 테이블 패널 컴포넌트 기본 너비 설정 + +## 개요 + +테이블 패널에서 컬럼과 필터를 드래그 드롭으로 추가할 때, 각 웹타입별로 적절한 기본 너비(gridColumns)가 자동으로 설정되도록 개선했습니다. + +## 문제점 + +### 기존 방식 + +- **모든 컬럼**: `gridColumns: 2` (2/12, 16.67%) 고정 +- **모든 필터**: `gridColumns: 3` (3/12, 25%) 고정 +- 웹타입에 관계없이 동일한 너비 적용 +- 긴 텍스트 입력이나 짧은 숫자 입력 모두 같은 크기 + +### 문제 사례 + +``` +❌ text (긴 텍스트) → 2컬럼 (너무 좁음) +❌ textarea (여러 줄) → 2컬럼 (너무 좁음) +❌ checkbox (체크박스) → 2컬럼 (너무 넓음) +``` + +## 해결 방법 + +### 웹타입별 기본 너비 함수 추가 + +```typescript +// DataTableConfigPanel.tsx (891-929줄) +const getDefaultGridColumns = (webType: WebType): number => { + const widthMap: Record = { + // 텍스트 입력 계열 (넓게) + text: 4, // 1/3 (33%) + email: 4, // 1/3 (33%) + tel: 3, // 1/4 (25%) + url: 4, // 1/3 (33%) + textarea: 6, // 절반 (50%) + + // 숫자/날짜 입력 (중간) + number: 2, // 2/12 (16.67%) + decimal: 2, // 2/12 (16.67%) + date: 3, // 1/4 (25%) + datetime: 3, // 1/4 (25%) + time: 2, // 2/12 (16.67%) + + // 선택 입력 (중간) + select: 3, // 1/4 (25%) + radio: 3, // 1/4 (25%) + checkbox: 2, // 2/12 (16.67%) + boolean: 2, // 2/12 (16.67%) + + // 코드/참조 (넓게) + code: 3, // 1/4 (25%) + entity: 4, // 1/3 (33%) + + // 파일/이미지 (넓게) + file: 4, // 1/3 (33%) + image: 3, // 1/4 (25%) + + // 기타 + button: 2, // 2/12 (16.67%) + label: 2, // 2/12 (16.67%) + }; + + return widthMap[webType] || 3; // 기본값 3 (1/4, 25%) +}; +``` + +## 적용된 함수 + +### 1. addColumn (컬럼 추가) + +```typescript +// Before +const newColumn: DataTableColumn = { + // ... + gridColumns: 2, // ❌ 모든 타입에 2 고정 + // ... +}; + +// After +const newColumn: DataTableColumn = { + // ... + gridColumns: getDefaultGridColumns(widgetType), // ✅ 웹타입별 자동 계산 + // ... +}; +``` + +### 2. addFilter (필터 추가) + +```typescript +// Before +const newFilter: DataTableFilter = { + // ... + gridColumns: 3, // ❌ 모든 타입에 3 고정 + // ... +}; + +// After +const newFilter: DataTableFilter = { + // ... + gridColumns: getDefaultGridColumns(widgetType), // ✅ 웹타입별 자동 계산 + // ... +}; +``` + +### 3. addVirtualFileColumn (가상 파일 컬럼 추가) + +```typescript +// Before +const newColumn: DataTableColumn = { + // ... + widgetType: "file", + gridColumns: 2, // ❌ 파일 타입에 2 고정 + // ... +}; + +// After +const newColumn: DataTableColumn = { + // ... + widgetType: "file", + gridColumns: getDefaultGridColumns("file"), // ✅ 파일 타입 기본값 (4컬럼, 33%) + // ... +}; +``` + +## 웹타입별 기본 너비 상세 + +### 텍스트 입력 계열 (넓게 설정) + +| 웹타입 | 컬럼 수 | 퍼센트 | 설명 | +| -------- | ------- | ------ | -------------------------- | +| text | 4 | 33% | 일반 텍스트 입력 | +| email | 4 | 33% | 이메일 주소 (길이 필요) | +| tel | 3 | 25% | 전화번호 (중간 길이) | +| url | 4 | 33% | URL 주소 (길이 필요) | +| textarea | 6 | 50% | 여러 줄 텍스트 (가장 넓게) | + +### 숫자/날짜 입력 (중간 설정) + +| 웹타입 | 컬럼 수 | 퍼센트 | 설명 | +| -------- | ------- | ------ | -------------- | +| number | 2 | 16.67% | 정수 입력 | +| decimal | 2 | 16.67% | 소수 입력 | +| date | 3 | 25% | 날짜 선택 | +| datetime | 3 | 25% | 날짜+시간 선택 | +| time | 2 | 16.67% | 시간 선택 | + +### 선택 입력 (중간 설정) + +| 웹타입 | 컬럼 수 | 퍼센트 | 설명 | +| -------- | ------- | ------ | --------------- | +| select | 3 | 25% | 드롭다운 선택 | +| radio | 3 | 25% | 라디오 버튼 | +| checkbox | 2 | 16.67% | 체크박스 (작게) | +| boolean | 2 | 16.67% | 참/거짓 (작게) | + +### 코드/참조 (넓게 설정) + +| 웹타입 | 컬럼 수 | 퍼센트 | 설명 | +| ------ | ------- | ------ | ----------------------- | +| code | 3 | 25% | 코드 선택 | +| entity | 4 | 33% | 엔티티 참조 (길이 필요) | + +### 파일/이미지 (넓게 설정) + +| 웹타입 | 컬럼 수 | 퍼센트 | 설명 | +| ------ | ------- | ------ | ------------- | +| file | 4 | 33% | 파일 업로드 | +| image | 3 | 25% | 이미지 업로드 | + +### 기타 + +| 웹타입 | 컬럼 수 | 퍼센트 | 설명 | +| ---------- | ------- | ------ | ------------------ | +| button | 2 | 16.67% | 버튼 | +| label | 2 | 16.67% | 라벨 | +| **기본값** | 3 | 25% | 정의되지 않은 타입 | + +## 적용 효과 + +### Before (기존) + +``` +[컬럼 추가] +- 이름 (text) → 2컬럼 → 너무 좁음 😞 +- 설명 (textarea) → 2컬럼 → 너무 좁음 😞 +- 나이 (number) → 2컬럼 → 적절함 😐 +- 활성화 (checkbox) → 2컬럼 → 너무 넓음 😞 + +[필터 추가] +- 검색어 (text) → 3컬럼 → 약간 좁음 😐 +- 날짜 (date) → 3컬럼 → 적절함 😐 +- 승인 (boolean) → 3컬럼 → 너무 넓음 😞 +``` + +### After (개선) + +``` +[컬럼 추가] +- 이름 (text) → 4컬럼 (33%) → 적절함 ✅ +- 설명 (textarea) → 6컬럼 (50%) → 충분함 ✅ +- 나이 (number) → 2컬럼 (16.67%) → 적절함 ✅ +- 활성화 (checkbox) → 2컬럼 (16.67%) → 적절함 ✅ + +[필터 추가] +- 검색어 (text) → 4컬럼 (33%) → 충분함 ✅ +- 날짜 (date) → 3컬럼 (25%) → 적절함 ✅ +- 승인 (boolean) → 2컬럼 (16.67%) → 적절함 ✅ +``` + +## 사용 방법 + +### 1. 컬럼 추가 + +1. 테이블 선택 +2. "컬럼 추가" 버튼 클릭 또는 드롭다운에서 컬럼 선택 +3. 웹타입에 맞는 기본 너비로 자동 생성됨 +4. 필요시 속성 패널에서 너비 조정 가능 + +### 2. 필터 추가 + +1. 테이블 선택 +2. "필터 추가" 버튼 클릭 +3. 웹타입에 맞는 기본 너비로 자동 생성됨 +4. 필요시 컬럼별 너비 조정 가능 + +### 3. 가상 파일 컬럼 추가 + +1. "파일 컬럼" 버튼 클릭 +2. 파일 타입에 맞는 기본 너비(4컬럼, 33%)로 생성됨 + +### 4. 너비 조정 (수동) + +**컬럼 너비 조정**: + +- 컬럼 설정 탭에서 각 컬럼별 "컬럼 너비" 드롭다운 선택 +- 1/12 (8.33%)부터 12/12 (100%)까지 선택 가능 +- 기본값은 웹타입에 따라 자동 설정됨 + +**필터 너비 조정**: + +- 필터 설정 탭에서 각 필터별 "필터 너비" 드롭다운 선택 +- 1/12 (8.33%)부터 12/12 (100%)까지 선택 가능 +- 기본값은 웹타입에 따라 자동 설정됨 + +## 주의 사항 + +### 기존 데이터 + +- **영향 없음**: 이미 생성된 컬럼/필터는 변경되지 않음 +- **새로 추가되는 항목만** 새로운 기본값 적용 + +### 커스터마이징 + +- 기본값이 맞지 않으면 수동으로 조정 가능 +- 자주 사용하는 너비가 있다면 `getDefaultGridColumns` 함수 수정 가능 + +### 레이아웃 고려 + +- 한 행에 총 12컬럼까지 배치 가능 +- 예: 4컬럼 + 4컬럼 + 4컬럼 = 12컬럼 (딱 맞음) +- 예: 4컬럼 + 4컬럼 + 6컬럼 = 14컬럼 (넘침 → 다음 줄로 이동) + +## 테스트 체크리스트 + +### 컬럼 추가 테스트 + +- [ ] text 타입 컬럼 추가 → 4컬럼(33%) 확인 +- [ ] number 타입 컬럼 추가 → 2컬럼(16.67%) 확인 +- [ ] textarea 타입 컬럼 추가 → 6컬럼(50%) 확인 +- [ ] select 타입 컬럼 추가 → 3컬럼(25%) 확인 +- [ ] checkbox 타입 컬럼 추가 → 2컬럼(16.67%) 확인 + +### 필터 추가 테스트 + +- [ ] text 타입 필터 추가 → 4컬럼(33%) 확인 +- [ ] date 타입 필터 추가 → 3컬럼(25%) 확인 +- [ ] boolean 타입 필터 추가 → 2컬럼(16.67%) 확인 + +### 가상 파일 컬럼 테스트 + +- [ ] 파일 컬럼 추가 → 4컬럼(33%) 확인 + +### 수동 조정 테스트 + +- [ ] 생성 후 너비 수동 변경 가능한지 확인 +- [ ] 변경된 너비가 저장/로드 시 유지되는지 확인 + +## 관련 파일 + +### 수정된 파일 + +#### 1. `/frontend/components/screen/panels/DataTableConfigPanel.tsx` + +- `getDefaultGridColumns` 함수 추가 (891-929줄) +- `addColumn` 함수 수정 (954줄) - 웹타입별 기본 너비 자동 계산 +- `addFilter` 함수 수정 (781줄) - 웹타입별 기본 너비 자동 계산 +- `addVirtualFileColumn` 함수 수정 (1055줄) - 파일 타입 기본 너비 적용 +- 컬럼 설정 UI 개선 (1652줄) - "그리드 컬럼" → "컬럼 너비" (1/12 ~ 12/12) +- 필터 설정 UI 개선 (2131줄) - "그리드 컬럼" → "필터 너비" (1/12 ~ 12/12) + +#### 2. `/frontend/components/screen/ScreenDesigner.tsx` + +- `getDefaultGridColumns` 함수 추가 (1946-1984줄) - 드래그 드롭 컴포넌트용 +- `getDefaultGridColumnsForTemplate` 함수 추가 (1429-1438줄) - 템플릿 컴포넌트용 +- 템플릿 컴포넌트 생성 시 기본 너비 적용 (1514줄) +- 폼 컨테이너 내 컴포넌트 생성 시 기본 너비 적용 (2151줄) +- 드래그 드롭 컴포넌트 생성 시 기본 너비 적용 (2194줄) + +### 관련 문서 + +- [컴포넌트*기본*너비*설정*가이드.md](./컴포넌트_기본_너비_설정_가이드.md) +- [그리드*컬럼수*옵션\_통합.md](./그리드_컬럼수_옵션_통합.md) + +## 버전 히스토리 + +### v1.0.0 (2025-10-14) + +- 웹타입별 기본 너비 자동 설정 기능 추가 +- `getDefaultGridColumns` 함수 구현 +- `addColumn`, `addFilter`, `addVirtualFileColumn` 함수에 적용 diff --git a/frontend/app/(main)/admin/dashboard/edit/[id]/page.tsx b/frontend/app/(main)/admin/dashboard/edit/[id]/page.tsx new file mode 100644 index 00000000..92220b6c --- /dev/null +++ b/frontend/app/(main)/admin/dashboard/edit/[id]/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import React from "react"; +import { use } from "react"; +import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +/** + * 대시보드 편집 페이지 + * - 기존 대시보드 편집 + */ +export default function DashboardEditPage({ params }: PageProps) { + const { id } = use(params); + + return ( +
+ +
+ ); +} diff --git a/frontend/app/(main)/admin/dashboard/new/page.tsx b/frontend/app/(main)/admin/dashboard/new/page.tsx new file mode 100644 index 00000000..d2f2ce11 --- /dev/null +++ b/frontend/app/(main)/admin/dashboard/new/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import React from "react"; +import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner"; + +/** + * 새 대시보드 생성 페이지 + */ +export default function DashboardNewPage() { + return ( +
+ +
+ ); +} diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index cd65cd8a..dcf81963 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -1,18 +1,236 @@ -'use client'; +"use client"; -import React from 'react'; -import DashboardDesigner from '@/components/admin/dashboard/DashboardDesigner'; +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { dashboardApi } from "@/lib/api/dashboard"; +import { Dashboard } from "@/lib/api/dashboard"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Plus, Search, MoreVertical, Edit, Trash2, Copy, Eye } from "lucide-react"; /** * 대시보드 관리 페이지 - * - 드래그 앤 드롭으로 대시보드 레이아웃 설계 - * - 차트 및 위젯 배치 관리 - * - 독립적인 컴포넌트로 구성되어 다른 시스템에 영향 없음 + * - 대시보드 목록 조회 + * - 대시보드 생성/수정/삭제/복사 */ -export default function DashboardPage() { +export default function DashboardListPage() { + const router = useRouter(); + const [dashboards, setDashboards] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [error, setError] = useState(null); + + // 대시보드 목록 로드 + const loadDashboards = async () => { + try { + setLoading(true); + setError(null); + const result = await dashboardApi.getMyDashboards({ search: searchTerm }); + setDashboards(result.dashboards); + } catch (err) { + console.error("Failed to load dashboards:", err); + setError("대시보드 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadDashboards(); + }, [searchTerm]); + + // 대시보드 삭제 + const handleDelete = async (id: string, title: string) => { + if (!confirm(`"${title}" 대시보드를 삭제하시겠습니까?`)) { + return; + } + + try { + await dashboardApi.deleteDashboard(id); + alert("대시보드가 삭제되었습니다."); + loadDashboards(); + } catch (err) { + console.error("Failed to delete dashboard:", err); + alert("대시보드 삭제에 실패했습니다."); + } + }; + + // 대시보드 복사 + const handleCopy = async (dashboard: Dashboard) => { + try { + const newDashboard = await dashboardApi.createDashboard({ + title: `${dashboard.title} (복사본)`, + description: dashboard.description, + elements: dashboard.elements || [], + isPublic: false, + tags: dashboard.tags, + category: dashboard.category, + }); + alert("대시보드가 복사되었습니다."); + loadDashboards(); + } catch (err) { + console.error("Failed to copy dashboard:", err); + alert("대시보드 복사에 실패했습니다."); + } + }; + + // 포맷팅 헬퍼 + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + }; + + if (loading) { + return ( +
+
+
로딩 중...
+
대시보드 목록을 불러오고 있습니다
+
+
+ ); + } + return ( -
- +
+
+ {/* 헤더 */} +
+

대시보드 관리

+

대시보드를 생성하고 관리할 수 있습니다

+
+ + {/* 액션 바 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9" + /> +
+ +
+ + {/* 에러 메시지 */} + {error && ( + +

{error}

+
+ )} + + {/* 대시보드 목록 */} + {dashboards.length === 0 ? ( + +
+ +
+

대시보드가 없습니다

+

첫 번째 대시보드를 생성하여 데이터 시각화를 시작하세요

+ +
+ ) : ( + + + + + 제목 + 설명 + 요소 수 + 상태 + 생성일 + 수정일 + 작업 + + + + {dashboards.map((dashboard) => ( + + +
+ {dashboard.title} + {dashboard.isPublic && ( + + 공개 + + )} +
+
+ + {dashboard.description || "-"} + + + {dashboard.elementsCount || 0}개 + + + {dashboard.isPublic ? ( + 공개 + ) : ( + 비공개 + )} + + {formatDate(dashboard.createdAt)} + {formatDate(dashboard.updatedAt)} + + + + + + + router.push(`/dashboard/${dashboard.id}`)} className="gap-2"> + + 보기 + + router.push(`/admin/dashboard/edit/${dashboard.id}`)} + className="gap-2" + > + + 편집 + + handleCopy(dashboard)} className="gap-2"> + + 복사 + + handleDelete(dashboard.id, dashboard.title)} + className="gap-2 text-red-600 focus:text-red-600" + > + + 삭제 + + + + +
+ ))} +
+
+
+ )} +
); } diff --git a/frontend/app/(main)/admin/screenMng/page.tsx b/frontend/app/(main)/admin/screenMng/page.tsx index 54da701b..ae622128 100644 --- a/frontend/app/(main)/admin/screenMng/page.tsx +++ b/frontend/app/(main)/admin/screenMng/page.tsx @@ -17,6 +17,9 @@ export default function ScreenManagementPage() { const [selectedScreen, setSelectedScreen] = useState(null); const [stepHistory, setStepHistory] = useState(["list"]); + // 화면 설계 모드일 때는 전체 화면 사용 + const isDesignMode = currentStep === "design"; + // 단계별 제목과 설명 const stepConfig = { list: { @@ -65,11 +68,20 @@ export default function ScreenManagementPage() { // 현재 단계가 마지막 단계인지 확인 const isLastStep = currentStep === "template"; + // 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 (고정 높이) + if (isDesignMode) { + return ( +
+ goToStep("list")} /> +
+ ); + } + return (
-
+
{/* 페이지 제목 */} -
+

화면 관리

화면을 설계하고 템플릿을 관리합니다

@@ -81,40 +93,27 @@ export default function ScreenManagementPage() { {/* 화면 목록 단계 */} {currentStep === "list" && (
-
+

{stepConfig.list.title}

- { - setSelectedScreen(screen); - goToNextStep("design"); - }} - /> -
- )} - - {/* 화면 설계 단계 */} - {currentStep === "design" && ( -
-
-

{stepConfig.design.title}

- -
- goToStep("list")} /> + { + setSelectedScreen(screen); + goToNextStep("design"); + }} + />
)} {/* 템플릿 관리 단계 */} {currentStep === "template" && (
-
+

{stepConfig.template.title}

@@ -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/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index f2b3027e..27adafe3 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -122,9 +122,9 @@ export default function ScreenViewPage() { if (loading) { return (
-
+
-

화면을 불러오는 중...

+

화면을 불러오는 중...

); @@ -133,12 +133,12 @@ export default function ScreenViewPage() { if (error || !screen) { return (
-
+
⚠️

화면을 찾을 수 없습니다

-

{error || "요청하신 화면이 존재하지 않습니다."}

+

{error || "요청하신 화면이 존재하지 않습니다."}

@@ -156,7 +156,7 @@ export default function ScreenViewPage() { {layout && layout.components.length > 0 ? ( // 캔버스 컴포넌트들을 정확한 해상도로 표시
{/* 그룹 제목 */} {(component as any).title && ( -
{(component as any).title}
+
+ {(component as any).title} +
)} {/* 그룹 내 자식 컴포넌트들 렌더링 */} @@ -201,8 +203,8 @@ export default function ScreenViewPage() { position: "absolute", left: `${child.position.x}px`, top: `${child.position.y}px`, - width: `${child.size.width}px`, - height: `${child.size.height}px`, + width: child.style?.width || `${child.size.width}px`, + height: child.style?.height || `${child.size.height}px`, zIndex: child.position.z || 1, }} > @@ -275,8 +277,8 @@ export default function ScreenViewPage() { position: "absolute", left: `${component.position.x}px`, top: `${component.position.y}px`, - width: `${component.size.width}px`, - height: `${component.size.height}px`, + width: component.style?.width || `${component.size.width}px`, + height: component.style?.height || `${component.size.height}px`, zIndex: component.position.z || 1, }} onMouseEnter={() => { @@ -295,7 +297,13 @@ export default function ScreenViewPage() { {/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */} {component.type !== "widget" ? ( { @@ -332,10 +340,10 @@ export default function ScreenViewPage() { webType={(() => { // 유틸리티 함수로 파일 컴포넌트 감지 if (isFileComponent(component)) { - console.log(`🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"`, { + console.log('🎯 page.tsx - 파일 컴포넌트 감지 → webType: "file"', { componentId: component.id, componentType: component.type, - originalWebType: component.webType + originalWebType: component.webType, }); return "file"; } @@ -382,7 +390,7 @@ export default function ScreenViewPage() { ) : ( // 빈 화면일 때도 깔끔하게 표시
**참고**: 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..5c75acb7 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -17,6 +17,95 @@ 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 MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +// 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합) +const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +// 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석 +/* const ListSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/ListSummaryWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); */ + +// 개별 위젯들 (주석 처리 - StatusSummaryWidget으로 통합됨) +// const DeliveryStatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusSummaryWidget"), { +// ssr: false, +// loading: () =>
로딩 중...
, +// }); +// const DeliveryTodayStatsWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryTodayStatsWidget"), { +// ssr: false, +// loading: () =>
로딩 중...
, +// }); +// const CargoListWidget = dynamic(() => import("@/components/dashboard/widgets/CargoListWidget"), { +// ssr: false, +// loading: () =>
로딩 중...
, +// }); +// const CustomerIssuesWidget = dynamic(() => import("@/components/dashboard/widgets/CustomerIssuesWidget"), { +// 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 +159,11 @@ export function CanvasElement({ return; } + // 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능 + if ((e.target as HTMLElement).closest(".widget-interactive-area")) { + return; + } + onSelect(element.id); setIsDragging(true); setDragStart({ @@ -109,9 +203,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 +220,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 +283,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 +301,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 +324,56 @@ 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); + // 필터 적용 (날짜 필터 등) + const { applyQueryFilters } = await import("./utils/queryHelpers"); + const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig); - // console.log('✅ 쿼리 실행 결과:', result); + // 외부 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), + filteredQuery, + ); - setChartData({ - columns: result.columns || [], - rows: result.rows || [], - totalRows: result.rowCount || 0, - executionTime: 0, - }); + if (!externalResult.success) { + throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패"); + } + + 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(filteredQuery); + + 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.chartConfig, + element.type, + ]); // 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩 useEffect(() => { @@ -271,6 +414,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 +451,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 === "map-summary" ? ( + // 커스텀 지도 카드 - 범용 위젯 +
+ +
+ ) : element.type === "widget" && element.subtype === "vehicle-map" ? ( + // 차량 위치 지도 위젯 렌더링 (구버전 - 호환용) +
+ +
+ ) : element.type === "widget" && element.subtype === "status-summary" ? ( + // 커스텀 상태 카드 - 범용 위젯 +
+ +
+ ) : /* element.type === "widget" && element.subtype === "list-summary" ? ( + // 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석) +
+ +
+ ) : */ element.type === "widget" && element.subtype === "delivery-status" ? ( + // 배송/화물 현황 위젯 - 범용 위젯 사용 (구버전 호환) +
+ +
+ ) : element.type === "widget" && element.subtype === "delivery-status-summary" ? ( + // 배송 상태 요약 - 범용 위젯 사용 +
+ +
+ ) : element.type === "widget" && element.subtype === "delivery-today-stats" ? ( + // 오늘 처리 현황 - 범용 위젯 사용 +
+ +
+ ) : element.type === "widget" && element.subtype === "cargo-list" ? ( + // 화물 목록 - 범용 위젯 사용 +
+ +
+ ) : element.type === "widget" && element.subtype === "customer-issues" ? ( + // 고객 클레임/이슈 - 범용 위젯 사용 +
+ +
+ ) : 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..2257848d 100644 --- a/frontend/components/admin/dashboard/ChartConfigPanel.tsx +++ b/frontend/components/admin/dashboard/ChartConfigPanel.tsx @@ -1,12 +1,27 @@ -'use client'; +"use client"; -import React, { useState, useCallback } from 'react'; -import { ChartConfig, QueryResult } from './types'; +import React, { useState, useCallback, useEffect } 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 { AlertCircle } from "lucide-react"; +import { DateFilterPanel } from "./DateFilterPanel"; +import { extractTableNameFromQuery } from "./utils/queryHelpers"; +import { dashboardApi } from "@/lib/api/dashboard"; interface ChartConfigPanelProps { config?: ChartConfig; queryResult?: QueryResult; onConfigChange: (config: ChartConfig) => void; + chartType?: string; + dataSourceType?: "database" | "api"; // 데이터 소스 타입 + query?: string; // SQL 쿼리 (테이블명 추출용) } /** @@ -15,186 +30,367 @@ interface ChartConfigPanelProps { * - 차트 스타일 설정 * - 실시간 미리보기 */ -export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartConfigPanelProps) { +export function ChartConfigPanel({ + config, + queryResult, + onConfigChange, + chartType, + dataSourceType, + query, +}: ChartConfigPanelProps) { const [currentConfig, setCurrentConfig] = useState(config || {}); + const [dateColumns, setDateColumns] = useState([]); + + // 원형/도넛 차트 또는 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 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"; + }); + + // 테이블 스키마에서 실제 날짜 컬럼 가져오기 + useEffect(() => { + if (!query || !queryResult || dataSourceType === "api") { + // API 소스는 스키마 조회 불가 + setDateColumns([]); + return; + } + + const tableName = extractTableNameFromQuery(query); + + if (!tableName) { + setDateColumns([]); + return; + } + dashboardApi + .getTableSchema(tableName) + .then((schema) => { + // 원본 테이블의 모든 날짜 컬럼을 표시 + // (SELECT에 없어도 WHERE 절에 사용 가능) + setDateColumns(schema.dateColumns); + }) + .catch((error) => { + console.error("❌ 테이블 스키마 조회 실패:", error); + // 실패 시 빈 배열 (날짜 필터 비활성화) + setDateColumns([]); + }); + }, [query, queryResult, dataSourceType]); + 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) => (
@@ -259,12 +283,23 @@ export default function DashboardDesigner() { {/* 요소 설정 모달 */} {configModalElement && ( - + <> + {configModalElement.type === "widget" && configModalElement.subtype === "list" ? ( + + ) : ( + + )} + )}
); @@ -276,6 +311,8 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { switch (subtype) { case "bar": return "📊 바 차트"; + case "horizontal-bar": + return "📊 수평 바 차트"; case "pie": return "🥧 원형 차트"; case "line": @@ -289,6 +326,18 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { return "💱 환율 위젯"; case "weather": return "☁️ 날씨 위젯"; + case "clock": + return "⏰ 시계 위젯"; + case "calculator": + return "🧮 계산기 위젯"; + case "vehicle-map": + return "🚚 차량 위치 지도"; + case "calendar": + return "📅 달력 위젯"; + case "driver-management": + return "🚚 기사 관리 위젯"; + case "list": + return "📋 리스트 위젯"; default: return "🔧 위젯"; } @@ -302,6 +351,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string { switch (subtype) { case "bar": return "바 차트가 여기에 표시됩니다"; + case "horizontal-bar": + return "수평 바 차트가 여기에 표시됩니다"; case "pie": return "원형 차트가 여기에 표시됩니다"; case "line": @@ -315,6 +366,18 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string { return "USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450"; case "weather": return "서울\n23°C\n구름 많음"; + case "clock": + return "clock"; + case "calculator": + return "calculator"; + case "vehicle-map": + return "vehicle-map"; + case "calendar": + return "calendar"; + case "driver-management": + return "driver-management"; + case "list": + return "list-widget"; default: return "위젯 내용이 여기에 표시됩니다"; } diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index 6ff1502c..6c03b398 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -1,14 +1,26 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import { DragData, ElementType, ElementSubtype } from "./types"; +import { ChevronDown, ChevronRight } from "lucide-react"; /** * 대시보드 사이드바 컴포넌트 * - 드래그 가능한 차트/위젯 목록 - * - 카테고리별 구분 + * - 아코디언 방식으로 카테고리별 구분 */ export function DashboardSidebar() { + const [expandedSections, setExpandedSections] = useState({ + charts: true, + widgets: true, + operations: true, + }); + + // 섹션 토글 + const toggleSection = (section: keyof typeof expandedSections) => { + setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] })); + }; + // 드래그 시작 처리 const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => { const dragData: DragData = { type, subtype }; @@ -17,19 +29,36 @@ export function DashboardSidebar() { }; return ( -
+
{/* 차트 섹션 */} -
-

📊 차트 종류

+
+ -
+ {expandedSections.charts && ( +
+ -
+
+ )}
{/* 위젯 섹션 */} -
-

🔧 위젯 종류

+
+ -
- + {expandedSections.widgets && ( +
+ -
+ + + + {/* 주석: 다른 분이 범용 리스트 작업 중 - 충돌 방지를 위해 임시 주석처리 */} + {/* */} + + + +
+ )} +
+ + {/* 운영/작업 지원 섹션 */} +
+ + + {expandedSections.operations && ( +
+ + + {/* 정비 일정 관리 위젯 제거 - 커스텀 목록 카드로 대체 가능 */} + + +
+ )}
); @@ -125,10 +258,9 @@ function DraggableItem({ icon, title, type, subtype, className = "", onDragStart return (
onDragStart(e, type, subtype)} > - {icon} {title}
); 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/DateFilterPanel.tsx b/frontend/components/admin/dashboard/DateFilterPanel.tsx new file mode 100644 index 00000000..3fb94bb4 --- /dev/null +++ b/frontend/components/admin/dashboard/DateFilterPanel.tsx @@ -0,0 +1,198 @@ +"use client"; + +import React from "react"; +import { ChartConfig } from "./types"; +import { Card } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Calendar, ChevronDown, ChevronUp } from "lucide-react"; +import { getQuickDateRange } from "./utils/queryHelpers"; + +interface DateFilterPanelProps { + config: ChartConfig; + dateColumns: string[]; + onChange: (updates: Partial) => void; +} + +export function DateFilterPanel({ config, dateColumns, onChange }: DateFilterPanelProps) { + const [isExpanded, setIsExpanded] = React.useState(false); + + const dateFilter = config.dateFilter || { + enabled: false, + dateColumn: dateColumns[0] || "", + startDate: "", + endDate: "", + }; + + const handleQuickRange = (range: "today" | "week" | "month" | "year") => { + const { startDate, endDate } = getQuickDateRange(range); + onChange({ + dateFilter: { + ...dateFilter, + enabled: true, + startDate, + endDate, + quickRange: range, + }, + }); + }; + + // 날짜 컬럼이 없으면 표시하지 않음 + if (dateColumns.length === 0) { + return null; + } + + return ( + +
setIsExpanded(!isExpanded)}> +
+ + + {dateFilter.enabled && 활성} +
+ {isExpanded ? : } +
+ + {isExpanded && ( +
+ {/* 필터 활성화 */} +
+ + onChange({ + dateFilter: { + ...dateFilter, + enabled: checked as boolean, + }, + }) + } + /> + +
+ + {dateFilter.enabled && ( + <> + {/* 날짜 컬럼 선택 */} +
+ + +

감지된 날짜 컬럼: {dateColumns.join(", ")}

+
+ + {/* 빠른 선택 */} +
+ +
+ + + + +
+
+ + {/* 직접 입력 */} +
+
+ + + onChange({ + dateFilter: { + ...dateFilter, + startDate: e.target.value, + quickRange: undefined, // 직접 입력 시 빠른 선택 해제 + }, + }) + } + /> +
+
+ + + onChange({ + dateFilter: { + ...dateFilter, + endDate: e.target.value, + quickRange: undefined, // 직접 입력 시 빠른 선택 해제 + }, + }) + } + /> +
+
+ + {/* 필터 정보 */} + {dateFilter.startDate && dateFilter.endDate && ( +
+ 필터 적용: {dateFilter.dateColumn} 컬럼에서 {dateFilter.startDate}부터{" "} + {dateFilter.endDate}까지 데이터를 가져옵니다. +
+ )} + + )} +
+ )} +
+ ); +} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index 4155a00a..aa074317 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 { VehicleMapConfigPanel } from "./VehicleMapConfigPanel"; +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 { X, ChevronLeft, ChevronRight, Save } from "lucide-react"; interface ElementConfigModalProps { element: DashboardElement; @@ -13,24 +20,71 @@ 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); + const [customTitle, setCustomTitle] = useState(element.customTitle || ""); - // 데이터 소스 변경 처리 - const handleDataSourceChange = useCallback((newDataSource: ChartDataSource) => { - setDataSource(newDataSource); + // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) + const isSimpleWidget = + element.subtype === "vehicle-status" || + element.subtype === "vehicle-list" || + element.subtype === "status-summary" || // 커스텀 상태 카드 + // element.subtype === "list-summary" || // 커스텀 목록 카드 (다른 분 작업 중 - 임시 주석) + element.subtype === "delivery-status" || + element.subtype === "delivery-status-summary" || + element.subtype === "delivery-today-stats" || + element.subtype === "cargo-list" || + element.subtype === "customer-issues" || + element.subtype === "driver-management"; + + // 지도 위젯 (위도/경도 매핑 필요) + const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary"; + + // 주석 + // 모달이 열릴 때 초기화 + useEffect(() => { + if (isOpen) { + setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }); + setChartConfig(element.chartConfig || {}); + setQueryResult(null); + setCurrentStep(1); + setCustomTitle(element.customTitle || ""); + } + }, [isOpen, element]); + + // 데이터 소스 타입 변경 + 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,126 +95,234 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 쿼리 테스트 결과 처리 const handleQueryTest = useCallback((result: QueryResult) => { setQueryResult(result); - // 쿼리 결과가 나오면 자동으로 차트 설정 탭으로 이동 - if (result.rows.length > 0) { - setActiveTab('chart'); - } + + // 쿼리가 변경되었으므로 차트 설정 초기화 (X/Y축 리셋) + console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화"); + setChartConfig({}); }, []); + // 다음 단계로 이동 + 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 = { ...element, dataSource, chartConfig, + customTitle: customTitle.trim() || undefined, // 빈 문자열이면 undefined }; + + console.log(" 저장할 element:", updatedElement); + onSave(updatedElement); onClose(); - }, [element, dataSource, chartConfig, onSave, onClose]); + }, [element, dataSource, chartConfig, customTitle, onSave, onClose]); // 모달이 열려있지 않으면 렌더링하지 않음 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"; + + // Y축 검증 헬퍼 + const hasYAxis = + chartConfig.yAxis && + (typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0)); + + // customTitle이 변경되었는지 확인 + const isTitleChanged = customTitle.trim() !== (element.customTitle || ""); + + const canSave = isTitleChanged || // 제목만 변경해도 저장 가능 + (isSimpleWidget + ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 + currentStep === 2 && queryResult && queryResult.rows.length > 0 + : isMapWidget + ? // 지도 위젯: 위도/경도 매핑 필요 + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.latitudeColumn && + chartConfig.longitudeColumn + : // 차트: 기존 로직 (2단계에서 차트 설정 필요) + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.xAxis && + (isPieChart || isApiSource + ? // 파이/도넛 차트 또는 REST API + chartConfig.aggregation === "count" + ? true // count는 Y축 없어도 됨 + : hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수 + : // 일반 차트 (DB): Y축 필수 + hasYAxis)); + return ( -
-
+
+
{/* 모달 헤더 */} -
-
-

- {element.title} 설정 -

-

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

+
+
+

{element.title} 설정

+

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

+
+ +
+ + {/* 커스텀 제목 입력 */} +
+ + setCustomTitle(e.target.value)} + placeholder={`예: 정비 일정 목록, 창고 위치 현황 등 (비워두면 자동 생성)`} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + /> +

+ 💡 비워두면 테이블명으로 자동 생성됩니다 (예: "maintenance_schedules 목록")

-
- {/* 탭 네비게이션 */} -
- - -
+ {/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */} + {!isSimpleWidget && ( +
+
+
+ 단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"} +
+
+
+ )} - {/* 탭 내용 */} + {/* 단계별 내용 */}
- {activeTab === 'query' && ( - + {currentStep === 1 && ( + )} - {activeTab === 'chart' && ( - + {currentStep === 2 && ( +
+ {/* 왼쪽: 데이터 설정 */} +
+ {dataSource.type === "database" ? ( + <> + + + + ) : ( + + )} +
+ + {/* 오른쪽: 설정 패널 */} + {!isSimpleWidget && ( +
+ {isMapWidget ? ( + // 지도 위젯: 위도/경도 매핑 패널 + queryResult && queryResult.rows.length > 0 ? ( + + ) : ( +
+
+
데이터를 가져온 후 지도 설정이 표시됩니다
+
+
+ ) + ) : // 차트: 차트 설정 패널 + queryResult && queryResult.rows.length > 0 ? ( + + ) : ( +
+
+
데이터를 가져온 후 차트 설정이 표시됩니다
+
+
+ )} +
+ )} +
)}
{/* 모달 푸터 */} -
-
- {dataSource.query && ( - <> - 💾 쿼리: {dataSource.query.length > 50 - ? `${dataSource.query.substring(0, 50)}...` - : dataSource.query} - - )} -
- +
+
{queryResult && {queryResult.rows.length}개 데이터 로드됨}
+
- + )} + - + + {currentStep === 1 ? ( + // 1단계: 다음 버튼 (모든 타입 공통) + + ) : ( + // 2단계: 저장 버튼 (모든 타입 공통) + + )}
diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx index 5aa70a80..181d80fa 100644 --- a/frontend/components/admin/dashboard/QueryEditor.tsx +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -1,7 +1,19 @@ -'use client'; +"use client"; -import React, { useState, useCallback } from 'react'; -import { ChartDataSource, QueryResult } from './types'; +import React, { useState, useCallback } from "react"; +import { ChartDataSource, QueryResult, ChartConfig } 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"; +import { applyQueryFilters } from "./utils/queryHelpers"; interface QueryEditorProps { dataSource?: ChartDataSource; @@ -13,73 +25,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 +132,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 +141,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 +164,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 쿼리 입력 영역 */} -
-