Merge branch 'main' into feature/screen-management
This commit is contained in:
commit
4c05b25fd8
|
|
@ -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
|
||||
|
||||
|
|
@ -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. 그대로 사용하면 됩니다!
|
||||
# (팀 전체가 동일한 키 사용)
|
||||
#
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
|
@ -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 연동 준비 완료
|
||||
|
||||
|
|
@ -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 확인 필요
|
||||
|
||||
|
|
@ -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`를 복사해서 사용하세요!** 👍
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
async createDashboard(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
async updateDashboard(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async deleteDashboard(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
async getMyDashboards(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
|
||||
|
||||
if (!userId) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: '인증이 필요합니다.'
|
||||
message: "인증이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const query: DashboardListQuery = {
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||
search: req.query.search as string,
|
||||
category: req.query.category as string,
|
||||
createdBy: userId // 본인이 만든 대시보드만
|
||||
createdBy: userId, // 본인이 만든 대시보드만
|
||||
};
|
||||
|
||||
|
||||
const result = await DashboardService.getDashboards(query, userId);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.dashboards,
|
||||
pagination: result.pagination
|
||||
pagination: result.pagination,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// console.error('My dashboards error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '내 대시보드 목록 조회 중 오류가 발생했습니다.',
|
||||
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
||||
message: "내 대시보드 목록 조회 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -387,31 +438,31 @@ export class DashboardController {
|
|||
// }
|
||||
|
||||
const { query } = req.body;
|
||||
|
||||
|
||||
// 유효성 검증
|
||||
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
||||
if (!query || typeof query !== "string" || query.trim().length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: '쿼리가 필요합니다.'
|
||||
message: "쿼리가 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 기본적인 검증
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
if (!trimmedQuery.startsWith('select')) {
|
||||
if (!trimmedQuery.startsWith("select")) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'SELECT 쿼리만 허용됩니다.'
|
||||
message: "SELECT 쿼리만 허용됩니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 쿼리 실행
|
||||
const result = await PostgreSQLService.query(query.trim());
|
||||
|
||||
|
||||
// 결과 변환
|
||||
const columns = result.fields?.map(field => field.name) || [];
|
||||
const columns = result.fields?.map((field) => field.name) || [];
|
||||
const rows = result.rows || [];
|
||||
|
||||
res.status(200).json({
|
||||
|
|
@ -419,18 +470,81 @@ export class DashboardController {
|
|||
data: {
|
||||
columns,
|
||||
rows,
|
||||
rowCount: rows.length
|
||||
rowCount: rows.length,
|
||||
},
|
||||
message: '쿼리가 성공적으로 실행되었습니다.'
|
||||
message: "쿼리가 성공적으로 실행되었습니다.",
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// console.error('Query execution error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '쿼리 실행 중 오류가 발생했습니다.',
|
||||
error: process.env.NODE_ENV === 'development' ? (error as Error).message : '쿼리 실행 오류'
|
||||
message: "쿼리 실행 중 오류가 발생했습니다.",
|
||||
error:
|
||||
process.env.NODE_ENV === "development"
|
||||
? (error as Error).message
|
||||
: "쿼리 실행 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 API 프록시 (CORS 우회용)
|
||||
* POST /api/dashboards/fetch-external-api
|
||||
*/
|
||||
async fetchExternalApi(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
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 호출 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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: '이슈 상태 업데이트에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<void> => {
|
||||
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<void> => {
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -1,37 +1,61 @@
|
|||
import { Router } from 'express';
|
||||
import { DashboardController } from '../controllers/DashboardController';
|
||||
import { authenticateToken } from '../middleware/authMiddleware';
|
||||
import { Router } from "express";
|
||||
import { DashboardController } from "../controllers/DashboardController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
const dashboardController = new DashboardController();
|
||||
|
||||
/**
|
||||
* 대시보드 API 라우트
|
||||
*
|
||||
*
|
||||
* 모든 엔드포인트는 인증이 필요하지만,
|
||||
* 공개 대시보드 조회는 인증 없이도 가능
|
||||
*/
|
||||
|
||||
// 공개 대시보드 목록 조회 (인증 불필요)
|
||||
router.get('/public', dashboardController.getDashboards.bind(dashboardController));
|
||||
router.get(
|
||||
"/public",
|
||||
dashboardController.getDashboards.bind(dashboardController)
|
||||
);
|
||||
|
||||
// 공개 대시보드 상세 조회 (인증 불필요)
|
||||
router.get('/public/:id', dashboardController.getDashboard.bind(dashboardController));
|
||||
router.get(
|
||||
"/public/:id",
|
||||
dashboardController.getDashboard.bind(dashboardController)
|
||||
);
|
||||
|
||||
// 쿼리 실행 (인증 불필요 - 개발용)
|
||||
router.post('/execute-query', dashboardController.executeQuery.bind(dashboardController));
|
||||
router.post(
|
||||
"/execute-query",
|
||||
dashboardController.executeQuery.bind(dashboardController)
|
||||
);
|
||||
|
||||
// 외부 API 프록시 (CORS 우회)
|
||||
router.post(
|
||||
"/fetch-external-api",
|
||||
dashboardController.fetchExternalApi.bind(dashboardController)
|
||||
);
|
||||
|
||||
// 인증이 필요한 라우트들
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 내 대시보드 목록 조회
|
||||
router.get('/my', dashboardController.getMyDashboards.bind(dashboardController));
|
||||
router.get(
|
||||
"/my",
|
||||
dashboardController.getMyDashboards.bind(dashboardController)
|
||||
);
|
||||
|
||||
// 대시보드 CRUD
|
||||
router.post('/', dashboardController.createDashboard.bind(dashboardController));
|
||||
router.get('/', dashboardController.getDashboards.bind(dashboardController));
|
||||
router.get('/:id', dashboardController.getDashboard.bind(dashboardController));
|
||||
router.put('/:id', dashboardController.updateDashboard.bind(dashboardController));
|
||||
router.delete('/:id', dashboardController.deleteDashboard.bind(dashboardController));
|
||||
router.post("/", dashboardController.createDashboard.bind(dashboardController));
|
||||
router.get("/", dashboardController.getDashboards.bind(dashboardController));
|
||||
router.get("/:id", dashboardController.getDashboard.bind(dashboardController));
|
||||
router.put(
|
||||
"/:id",
|
||||
dashboardController.updateDashboard.bind(dashboardController)
|
||||
);
|
||||
router.delete(
|
||||
"/:id",
|
||||
dashboardController.deleteDashboard.bind(dashboardController)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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<BookingRequest> {
|
||||
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<BookingRequest> {
|
||||
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<BookingRequest[]> {
|
||||
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<BookingRequest> {
|
||||
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<BookingRequest> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DeliveryStatusResponse> {
|
||||
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<DeliveryItem[]> {
|
||||
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<CustomerIssue[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<Document[]> {
|
||||
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<Document> {
|
||||
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<string, number>;
|
||||
totalSize: number;
|
||||
}> {
|
||||
try {
|
||||
const documents = await this.getAllDocuments();
|
||||
|
||||
const byCategory: Record<string, number> = {
|
||||
"계약서": 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<Document[]> {
|
||||
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<Document> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<MaintenanceSchedule[]> {
|
||||
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<MaintenanceSchedule> {
|
||||
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<MaintenanceSchedule[]> {
|
||||
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<MaintenanceSchedule> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지도 데이터 서비스
|
||||
* 외부/내부 DB에서 위도/경도 데이터를 조회하여 지도 마커로 변환
|
||||
*/
|
||||
export class MapDataService {
|
||||
constructor() {
|
||||
// ExternalDbConnectionService는 static 메서드를 사용
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB에서 지도 데이터 조회
|
||||
*/
|
||||
async getMapData(params: MapDataQuery): Promise<MapMarker[]> {
|
||||
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<MapDataQuery, "connectionId">
|
||||
): Promise<MapMarker[]> {
|
||||
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<string, any> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<void> {
|
||||
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<Alert[]> {
|
||||
await this.refreshCache();
|
||||
return this.cachedAlerts;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<Alert[]> {
|
||||
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<string, { title: string; severity: 'high' | 'medium' | 'low' }> = {
|
||||
'풍랑': { 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<Alert[]> {
|
||||
// 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<Alert[]> {
|
||||
// 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<Alert[]> {
|
||||
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(),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<TodoListResponse> {
|
||||
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<TodoItem>): Promise<TodoItem> {
|
||||
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<TodoItem>): Promise<TodoItem> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<TodoItem[]> {
|
||||
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<void> {
|
||||
// 현재 최대 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<TodoItem>): Promise<TodoItem> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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>): 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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) 하세요!** 🚨✨
|
||||
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DashboardViewer } from '@/components/dashboard/DashboardViewer';
|
||||
import { DashboardElement } from '@/components/admin/dashboard/types';
|
||||
import React, { useState, useEffect, use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardViewer } from "@/components/dashboard/DashboardViewer";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
|
||||
interface DashboardViewPageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
dashboardId: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -17,6 +18,8 @@ interface DashboardViewPageProps {
|
|||
* - 전체화면 모드 지원
|
||||
*/
|
||||
export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||
const router = useRouter();
|
||||
const resolvedParams = use(params);
|
||||
const [dashboard, setDashboard] = useState<{
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -31,7 +34,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
// 대시보드 데이터 로딩
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, [params.dashboardId]);
|
||||
}, [resolvedParams.dashboardId]);
|
||||
|
||||
const loadDashboard = async () => {
|
||||
setIsLoading(true);
|
||||
|
|
@ -39,29 +42,29 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
|
||||
try {
|
||||
// 실제 API 호출 시도
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
|
||||
try {
|
||||
const dashboardData = await dashboardApi.getDashboard(params.dashboardId);
|
||||
const dashboardData = await dashboardApi.getDashboard(resolvedParams.dashboardId);
|
||||
setDashboard(dashboardData);
|
||||
} catch (apiError) {
|
||||
console.warn('API 호출 실패, 로컬 스토리지 확인:', apiError);
|
||||
|
||||
console.warn("API 호출 실패, 로컬 스토리지 확인:", apiError);
|
||||
|
||||
// API 실패 시 로컬 스토리지에서 찾기
|
||||
const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]');
|
||||
const savedDashboard = savedDashboards.find((d: any) => d.id === params.dashboardId);
|
||||
|
||||
const savedDashboards = JSON.parse(localStorage.getItem("savedDashboards") || "[]");
|
||||
const savedDashboard = savedDashboards.find((d: any) => d.id === resolvedParams.dashboardId);
|
||||
|
||||
if (savedDashboard) {
|
||||
setDashboard(savedDashboard);
|
||||
} else {
|
||||
// 로컬에도 없으면 샘플 데이터 사용
|
||||
const sampleDashboard = generateSampleDashboard(params.dashboardId);
|
||||
const sampleDashboard = generateSampleDashboard(resolvedParams.dashboardId);
|
||||
setDashboard(sampleDashboard);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError('대시보드를 불러오는 중 오류가 발생했습니다.');
|
||||
console.error('Dashboard loading error:', err);
|
||||
setError("대시보드를 불러오는 중 오류가 발생했습니다.");
|
||||
console.error("Dashboard loading error:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -70,11 +73,11 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="flex h-screen items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
|
||||
<div className="text-lg font-medium text-gray-700">대시보드 로딩 중...</div>
|
||||
<div className="text-sm text-gray-500 mt-1">잠시만 기다려주세요</div>
|
||||
<div className="mt-1 text-sm text-gray-500">잠시만 기다려주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -83,19 +86,12 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
// 에러 상태
|
||||
if (error || !dashboard) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="flex h-screen items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">😞</div>
|
||||
<div className="text-xl font-medium text-gray-700 mb-2">
|
||||
{error || '대시보드를 찾을 수 없습니다'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mb-4">
|
||||
대시보드 ID: {params.dashboardId}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadDashboard}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
>
|
||||
<div className="mb-4 text-6xl">😞</div>
|
||||
<div className="mb-2 text-xl font-medium text-gray-700">{error || "대시보드를 찾을 수 없습니다"}</div>
|
||||
<div className="mb-4 text-sm text-gray-500">대시보드 ID: {resolvedParams.dashboardId}</div>
|
||||
<button onClick={loadDashboard} className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600">
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -106,25 +102,23 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
return (
|
||||
<div className="h-screen bg-gray-50">
|
||||
{/* 대시보드 헤더 */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">{dashboard.title}</h1>
|
||||
{dashboard.description && (
|
||||
<p className="text-sm text-gray-600 mt-1">{dashboard.description}</p>
|
||||
)}
|
||||
{dashboard.description && <p className="mt-1 text-sm text-gray-600">{dashboard.description}</p>}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 새로고침 버튼 */}
|
||||
<button
|
||||
onClick={loadDashboard}
|
||||
className="px-3 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 hover:text-gray-800"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
|
||||
|
||||
{/* 전체화면 버튼 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -134,26 +128,26 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 hover:bg-gray-50 hover:text-gray-800"
|
||||
title="전체화면"
|
||||
>
|
||||
⛶
|
||||
</button>
|
||||
|
||||
|
||||
{/* 편집 버튼 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.open(`/admin/dashboard?load=${params.dashboardId}`, '_blank');
|
||||
router.push(`/admin/dashboard?load=${resolvedParams.dashboardId}`);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>생성: {new Date(dashboard.createdAt).toLocaleString()}</span>
|
||||
<span>수정: {new Date(dashboard.updatedAt).toLocaleString()}</span>
|
||||
<span>요소: {dashboard.elements.length}개</span>
|
||||
|
|
@ -162,10 +156,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
|
||||
{/* 대시보드 뷰어 */}
|
||||
<div className="h-[calc(100vh-120px)]">
|
||||
<DashboardViewer
|
||||
elements={dashboard.elements}
|
||||
dashboardId={dashboard.id}
|
||||
/>
|
||||
<DashboardViewer elements={dashboard.elements} dashboardId={dashboard.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -176,111 +167,113 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
*/
|
||||
function generateSampleDashboard(dashboardId: string) {
|
||||
const dashboards: Record<string, any> = {
|
||||
'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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
# 📅 달력 위젯 구현 계획
|
||||
|
||||
## 개요
|
||||
|
||||
대시보드에 추가할 수 있는 달력 위젯을 구현합니다. 사용자가 날짜를 확인하고 일정을 관리할 수 있는 인터랙티브한 달력 기능을 제공합니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
### 1. 달력 뷰 타입
|
||||
|
||||
- **월간 뷰**: 한 달 전체를 보여주는 기본 뷰
|
||||
- **주간 뷰**: 일주일을 세로로 보여주는 뷰
|
||||
- **일간 뷰**: 하루의 시간대별 일정 뷰
|
||||
|
||||
### 2. 달력 설정
|
||||
|
||||
- **시작 요일**: 월요일 시작 / 일요일 시작 선택
|
||||
- **주말 강조**: 주말 색상 다르게 표시
|
||||
- **오늘 날짜 강조**: 오늘 날짜 하이라이트
|
||||
- **공휴일 표시**: 한국 공휴일 표시 (선택 사항)
|
||||
|
||||
### 3. 테마 및 스타일
|
||||
|
||||
- **Light 테마**: 밝은 배경
|
||||
- **Dark 테마**: 어두운 배경
|
||||
- **사용자 지정**: 커스텀 색상 선택
|
||||
|
||||
### 4. 일정 기능 (향후 확장)
|
||||
|
||||
- 간단한 메모 추가
|
||||
- 일정 표시 (외부 연동)
|
||||
|
||||
## 구현 단계
|
||||
|
||||
### ✅ Step 1: 타입 정의
|
||||
|
||||
- [x] `CalendarConfig` 인터페이스 정의
|
||||
- [x] `types.ts`에 달력 설정 타입 추가
|
||||
- [x] 요소 타입에 'calendar' subtype 추가
|
||||
|
||||
### ✅ Step 2: 기본 달력 컴포넌트
|
||||
|
||||
- [x] `CalendarWidget.tsx` - 메인 위젯 컴포넌트
|
||||
- [x] `MonthView.tsx` - 월간 달력 뷰
|
||||
- [x] 날짜 계산 유틸리티 함수 (`calendarUtils.ts`)
|
||||
- [ ] `WeekView.tsx` - 주간 달력 뷰 (향후 추가)
|
||||
|
||||
### ✅ Step 3: 달력 네비게이션
|
||||
|
||||
- [x] 이전/다음 월 이동 버튼
|
||||
- [x] 오늘로 돌아가기 버튼
|
||||
- [ ] 월/연도 선택 드롭다운 (향후 추가)
|
||||
|
||||
### ✅ Step 4: 설정 UI
|
||||
|
||||
- [x] `CalendarSettings.tsx` - Popover 내장 설정 컴포넌트
|
||||
- [x] 뷰 타입 선택 (월간 - 현재 구현)
|
||||
- [x] 시작 요일 설정
|
||||
- [x] 테마 선택 (light/dark/custom)
|
||||
- [x] 표시 옵션 (주말 강조, 공휴일, 오늘 강조)
|
||||
|
||||
### ✅ Step 5: 스타일링
|
||||
|
||||
- [x] 달력 그리드 레이아웃
|
||||
- [x] 날짜 셀 디자인
|
||||
- [x] 오늘 날짜 하이라이트
|
||||
- [x] 주말/평일 구분
|
||||
- [x] 반응형 디자인 (크기별 최적화)
|
||||
|
||||
### ✅ Step 6: 통합
|
||||
|
||||
- [x] `DashboardSidebar`에 달력 위젯 추가
|
||||
- [x] `CanvasElement`에서 달력 위젯 렌더링
|
||||
- [x] `DashboardDesigner`에 기본값 설정
|
||||
|
||||
### ✅ Step 7: 공휴일 데이터
|
||||
|
||||
- [x] 한국 공휴일 데이터 정의
|
||||
- [x] 공휴일 표시 기능
|
||||
- [x] 공휴일 이름 툴팁
|
||||
|
||||
### ✅ Step 8: 테스트 및 최적화
|
||||
|
||||
- [ ] 다양한 크기에서 테스트 (사용자 테스트 필요)
|
||||
- [x] 날짜 계산 로직 검증
|
||||
- [ ] 성능 최적화 (필요시)
|
||||
- [ ] 접근성 개선 (필요시)
|
||||
|
||||
## 기술 스택
|
||||
|
||||
### UI 컴포넌트
|
||||
|
||||
- **shadcn/ui**: Button, Select, Switch, Popover, Card
|
||||
- **lucide-react**: Settings, ChevronLeft, ChevronRight, Calendar
|
||||
|
||||
### 날짜 처리
|
||||
|
||||
- **JavaScript Date API**: 기본 날짜 계산
|
||||
- **Intl.DateTimeFormat**: 날짜 형식화
|
||||
- 외부 라이브러리 없이 순수 구현
|
||||
|
||||
### 스타일링
|
||||
|
||||
- **Tailwind CSS**: 반응형 그리드 레이아웃
|
||||
- **CSS Grid**: 달력 레이아웃
|
||||
|
||||
## 컴포넌트 구조
|
||||
|
||||
```
|
||||
widgets/
|
||||
├── CalendarWidget.tsx # 메인 위젯 (설정 버튼 포함)
|
||||
├── CalendarSettings.tsx # 설정 UI (Popover 내부)
|
||||
├── MonthView.tsx # 월간 뷰
|
||||
├── WeekView.tsx # 주간 뷰 (선택)
|
||||
├── DayView.tsx # 일간 뷰 (선택)
|
||||
└── calendarUtils.ts # 날짜 계산 유틸리티
|
||||
```
|
||||
|
||||
## 데이터 구조
|
||||
|
||||
```typescript
|
||||
interface CalendarConfig {
|
||||
view: "month" | "week" | "day"; // 뷰 타입
|
||||
startWeekOn: "monday" | "sunday"; // 주 시작 요일
|
||||
highlightWeekends: boolean; // 주말 강조
|
||||
highlightToday: boolean; // 오늘 강조
|
||||
showHolidays: boolean; // 공휴일 표시
|
||||
theme: "light" | "dark" | "custom"; // 테마
|
||||
customColor?: string; // 사용자 지정 색상
|
||||
showWeekNumbers?: boolean; // 주차 표시 (선택)
|
||||
}
|
||||
```
|
||||
|
||||
## UI/UX 고려사항
|
||||
|
||||
### 반응형 디자인
|
||||
|
||||
- **2x2**: 미니 달력 (월간 뷰만, 날짜만 표시)
|
||||
- **3x3**: 기본 달력 (월간 뷰, 요일 헤더 포함)
|
||||
- **4x4 이상**: 풀 달력 (모든 기능, 일정 표시 가능)
|
||||
|
||||
### 인터랙션
|
||||
|
||||
- 날짜 클릭 시 해당 날짜 정보 표시 (선택)
|
||||
- 드래그로 월 변경 (선택)
|
||||
- 키보드 네비게이션 지원
|
||||
|
||||
### 접근성
|
||||
|
||||
- 날짜 셀에 적절한 aria-label
|
||||
- 키보드 네비게이션 지원
|
||||
- 스크린 리더 호환
|
||||
|
||||
## 공휴일 데이터 구조
|
||||
|
||||
```typescript
|
||||
interface Holiday {
|
||||
date: string; // 'MM-DD' 형식
|
||||
name: string; // 공휴일 이름
|
||||
isRecurring: boolean; // 매년 반복 여부
|
||||
}
|
||||
|
||||
// 2025년 한국 공휴일 예시
|
||||
const KOREAN_HOLIDAYS: Holiday[] = [
|
||||
{ date: "01-01", name: "신정", isRecurring: true },
|
||||
{ date: "01-28", name: "설날 연휴", isRecurring: false },
|
||||
{ date: "01-29", name: "설날", isRecurring: false },
|
||||
{ date: "01-30", name: "설날 연휴", isRecurring: false },
|
||||
{ date: "03-01", name: "삼일절", isRecurring: true },
|
||||
{ date: "05-05", name: "어린이날", isRecurring: true },
|
||||
{ date: "06-06", name: "현충일", isRecurring: true },
|
||||
{ date: "08-15", name: "광복절", isRecurring: true },
|
||||
{ date: "10-03", name: "개천절", isRecurring: true },
|
||||
{ date: "10-09", name: "한글날", isRecurring: true },
|
||||
{ date: "12-25", name: "크리스마스", isRecurring: true },
|
||||
];
|
||||
```
|
||||
|
||||
## 향후 확장 기능
|
||||
|
||||
### Phase 2 (선택)
|
||||
|
||||
- [ ] 일정 추가/수정/삭제
|
||||
- [ ] 반복 일정 설정
|
||||
- [ ] 카테고리별 색상 구분
|
||||
- [ ] 다른 달력 서비스 연동 (Google Calendar, Outlook 등)
|
||||
- [ ] 일정 알림 기능
|
||||
- [ ] 드래그 앤 드롭으로 일정 이동
|
||||
|
||||
### Phase 3 (선택)
|
||||
|
||||
- [ ] 여러 달력 레이어 지원
|
||||
- [ ] 일정 검색 기능
|
||||
- [ ] 월별 통계 (일정 개수 등)
|
||||
- [ ] CSV/iCal 내보내기
|
||||
|
||||
## 참고사항
|
||||
|
||||
### 장점
|
||||
|
||||
- 순수 JavaScript로 구현 (외부 의존성 최소화)
|
||||
- shadcn/ui 컴포넌트 활용으로 일관된 디자인
|
||||
- 시계 위젯과 동일한 패턴 (내장 설정 UI)
|
||||
|
||||
### 주의사항
|
||||
|
||||
- 날짜 계산 로직 정확성 검증 필요
|
||||
- 윤년 처리
|
||||
- 타임존 고려 (필요시)
|
||||
- 다양한 크기에서의 가독성
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- [x] 월간 뷰 달력이 정확하게 표시됨
|
||||
- [x] 이전/다음 월 네비게이션이 작동함
|
||||
- [x] 오늘 날짜가 하이라이트됨
|
||||
- [x] 주말이 다른 색상으로 표시됨
|
||||
- [x] 공휴일이 표시되고 이름이 보임
|
||||
- [x] 설정 UI에서 모든 옵션을 변경할 수 있음
|
||||
- [x] 테마 변경이 즉시 반영됨
|
||||
- [x] 2x2 크기에서도 깔끔하게 표시됨
|
||||
- [x] 4x4 크기에서 모든 기능이 정상 작동함
|
||||
|
||||
---
|
||||
|
||||
## 구현 시작
|
||||
|
||||
이제 단계별로 구현을 시작합니다!
|
||||
|
|
@ -0,0 +1,742 @@
|
|||
# 📊 차트 시스템 구현 계획
|
||||
|
||||
## 개요
|
||||
|
||||
D3.js 기반의 강력한 차트 시스템을 구축합니다. 사용자는 데이터를 두 가지 방법(DB 쿼리 또는 REST API)으로 가져와 다양한 차트로 시각화할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 핵심 요구사항
|
||||
|
||||
### 1. 데이터 소스 (2가지 방식)
|
||||
|
||||
#### A. 데이터베이스 커넥션
|
||||
|
||||
- **현재 DB**: 애플리케이션의 기본 PostgreSQL 연결
|
||||
- **외부 DB**: 기존 "외부 커넥션 관리" 메뉴에서 등록된 커넥션만 사용
|
||||
- 신규 커넥션 생성은 외부 커넥션 관리 메뉴에서만 가능
|
||||
- 차트 설정에서는 등록된 커넥션 목록에서 선택만 가능
|
||||
- **쿼리 제한**: SELECT 문만 허용 (INSERT, UPDATE, DELETE, DROP 등 금지)
|
||||
- **쿼리 검증**: 서버 측에서 SQL Injection 방지 및 쿼리 타입 검증
|
||||
|
||||
#### B. REST API 호출
|
||||
|
||||
- **HTTP Methods**: GET (권장) - 데이터 조회에 충분
|
||||
- **데이터 형식**: JSON 응답만 허용
|
||||
- **헤더 설정**: Authorization, Content-Type 등 커스텀 헤더 지원
|
||||
- **쿼리 파라미터**: URL 파라미터로 필터링 조건 전달
|
||||
- **응답 파싱**: JSON 구조에서 차트 데이터 추출
|
||||
- **에러 처리**: HTTP 상태 코드 및 타임아웃 처리
|
||||
|
||||
> **참고**: POST는 향후 확장 (GraphQL, 복잡한 필터링)을 위해 선택적으로 지원 가능
|
||||
|
||||
### 2. 차트 타입 (D3.js 기반)
|
||||
|
||||
현재 지원 예정:
|
||||
|
||||
- **Bar Chart** (막대 차트): 수평/수직 막대
|
||||
- **Line Chart** (선 차트): 단일/다중 시리즈
|
||||
- **Area Chart** (영역 차트): 누적 영역 지원
|
||||
- **Pie Chart** (원 차트): 도넛 차트 포함
|
||||
- **Stacked Bar** (누적 막대): 다중 시리즈 누적
|
||||
- **Combo Chart** (혼합 차트): 막대 + 선 조합
|
||||
|
||||
### 3. 축 매핑 설정
|
||||
|
||||
- **X축**: 카테고리/시간 데이터 (문자열, 날짜)
|
||||
- **Y축**: 숫자 데이터 (단일 또는 다중 선택 가능)
|
||||
- **다중 Y축**: 여러 시리즈를 한 차트에 표시 (예: 갤럭시 vs 아이폰 매출)
|
||||
- **자동 감지**: 데이터 타입에 따라 축 자동 추천
|
||||
- **데이터 변환**: 문자열 날짜를 Date 객체로 자동 변환
|
||||
|
||||
### 4. 차트 스타일링
|
||||
|
||||
- **색상 팔레트**: 사전 정의된 색상 세트 선택
|
||||
- **커스텀 색상**: 사용자 지정 색상 입력
|
||||
- **범례**: 위치 설정 (상단, 하단, 좌측, 우측, 숨김)
|
||||
- **애니메이션**: 차트 로드 시 부드러운 전환 효과
|
||||
- **툴팁**: 데이터 포인트 호버 시 상세 정보 표시
|
||||
- **그리드**: X/Y축 그리드 라인 표시/숨김
|
||||
|
||||
---
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
```
|
||||
frontend/components/admin/dashboard/
|
||||
├── CHART_SYSTEM_PLAN.md # 이 파일
|
||||
├── types.ts # ✅ 기존 (타입 확장 필요)
|
||||
├── ElementConfigModal.tsx # ✅ 기존 (리팩토링 필요)
|
||||
│
|
||||
├── data-sources/ # 🆕 데이터 소스 관련
|
||||
│ ├── DataSourceSelector.tsx # 데이터 소스 선택 UI (DB vs API)
|
||||
│ ├── DatabaseConfig.tsx # DB 커넥션 설정 UI
|
||||
│ ├── ApiConfig.tsx # REST API 설정 UI
|
||||
│ └── dataSourceUtils.ts # 데이터 소스 유틸리티
|
||||
│
|
||||
├── chart-config/ # 🔄 차트 설정 관련 (리팩토링)
|
||||
│ ├── QueryEditor.tsx # ✅ 기존 (확장 필요)
|
||||
│ ├── ChartConfigPanel.tsx # ✅ 기존 (확장 필요)
|
||||
│ ├── AxisMapper.tsx # 🆕 축 매핑 UI
|
||||
│ ├── StyleConfig.tsx # 🆕 스타일 설정 UI
|
||||
│ └── ChartPreview.tsx # 🆕 실시간 미리보기
|
||||
│
|
||||
├── charts/ # 🆕 D3 차트 컴포넌트
|
||||
│ ├── ChartRenderer.tsx # 차트 렌더러 (메인)
|
||||
│ ├── BarChart.tsx # 막대 차트
|
||||
│ ├── LineChart.tsx # 선 차트
|
||||
│ ├── AreaChart.tsx # 영역 차트
|
||||
│ ├── PieChart.tsx # 원 차트
|
||||
│ ├── StackedBarChart.tsx # 누적 막대 차트
|
||||
│ ├── ComboChart.tsx # 혼합 차트
|
||||
│ ├── chartUtils.ts # 차트 유틸리티
|
||||
│ └── d3Helpers.ts # D3 헬퍼 함수
|
||||
│
|
||||
└── CanvasElement.tsx # ✅ 기존 (차트 렌더링 통합)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 타입 정의 확장
|
||||
|
||||
### 기존 타입 업데이트
|
||||
|
||||
```typescript
|
||||
// types.ts
|
||||
|
||||
// 데이터 소스 타입 확장
|
||||
export interface ChartDataSource {
|
||||
type: "database" | "api"; // 'static' 제거
|
||||
|
||||
// DB 커넥션 관련
|
||||
connectionType?: "current" | "external"; // 현재 DB vs 외부 DB
|
||||
externalConnectionId?: string; // 외부 DB 커넥션 ID
|
||||
query?: string; // SQL 쿼리 (SELECT만)
|
||||
|
||||
// API 관련
|
||||
endpoint?: string; // API URL
|
||||
method?: "GET"; // HTTP 메서드 (GET만 지원)
|
||||
headers?: Record<string, string>; // 커스텀 헤더
|
||||
queryParams?: Record<string, string>; // 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<T = any> {
|
||||
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`: 데이터 변환 유틸리티
|
||||
- 데이터 페칭 및 자동 새로고침
|
||||
|
|
@ -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
|
||||
<DraggableItem
|
||||
icon="⏰"
|
||||
title="시계 위젯"
|
||||
type="widget"
|
||||
subtype="clock"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-teal-500"
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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 (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
{(config.style === "analog" || config.style === "both") && (
|
||||
<AnalogClock time={currentTime} theme={config.theme} />
|
||||
)}
|
||||
|
||||
{(config.style === "digital" || config.style === "both") && (
|
||||
<DigitalClock
|
||||
time={currentTime}
|
||||
timezone={config.timezone}
|
||||
showDate={config.showDate}
|
||||
showSeconds={config.showSeconds}
|
||||
format24h={config.format24h}
|
||||
theme={config.theme}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 📄 `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 (
|
||||
<div className={`text-center ${getThemeClass(theme)}`}>
|
||||
{showDate && <div className="mb-2 text-sm opacity-80">{dateString}</div>}
|
||||
<div className="text-4xl font-bold tabular-nums">{timeString}</div>
|
||||
<div className="mt-2 text-xs opacity-60">{getTimezoneLabel(timezone)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 📄 `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 (
|
||||
<svg viewBox="0 0 200 200" className="w-full max-w-[200px]">
|
||||
{/* 시계판 */}
|
||||
<circle cx="100" cy="100" r="95" fill="white" stroke="black" strokeWidth="2" />
|
||||
|
||||
{/* 숫자 표시 */}
|
||||
{[...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 (
|
||||
<text key={i} x={x} y={y} textAnchor="middle" dy="5" fontSize="14">
|
||||
{i === 0 ? 12 : i}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 시침 */}
|
||||
<line
|
||||
x1="100"
|
||||
y1="100"
|
||||
x2={100 + 40 * Math.cos((hourAngle * Math.PI) / 180)}
|
||||
y2={100 + 40 * Math.sin((hourAngle * Math.PI) / 180)}
|
||||
stroke="black"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* 분침 */}
|
||||
<line
|
||||
x1="100"
|
||||
y1="100"
|
||||
x2={100 + 60 * Math.cos((minuteAngle * Math.PI) / 180)}
|
||||
y2={100 + 60 * Math.sin((minuteAngle * Math.PI) / 180)}
|
||||
stroke="black"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* 초침 */}
|
||||
<line
|
||||
x1="100"
|
||||
y1="100"
|
||||
x2={100 + 70 * Math.cos((secondAngle * Math.PI) / 180)}
|
||||
y2={100 + 70 * Math.sin((secondAngle * Math.PI) / 180)}
|
||||
stroke="red"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* 중심점 */}
|
||||
<circle cx="100" cy="100" r="5" fill="black" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 📄 `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" && <ClockWidget element={element} />;
|
||||
}
|
||||
```
|
||||
|
||||
#### 📄 `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시간 형식
|
||||
|
||||
이제 완벽하게 작동하는 시계 위젯을 사용할 수 있습니다! 🚀⏰
|
||||
|
|
@ -17,6 +17,64 @@ const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/Exch
|
|||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const CalculatorWidget = dynamic(() => import("@/components/dashboard/widgets/CalculatorWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const VehicleStatusWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleStatusWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const VehicleListWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleListWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widgets/VehicleMapOnlyWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const DeliveryStatusWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const TodoWidget = dynamic(() => import("@/components/dashboard/widgets/TodoWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const BookingAlertWidget = dynamic(() => import("@/components/dashboard/widgets/BookingAlertWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const MaintenanceWidget = dynamic(() => import("@/components/dashboard/widgets/MaintenanceWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const DocumentWidget = dynamic(() => import("@/components/dashboard/widgets/DocumentWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 시계 위젯 임포트
|
||||
import { ClockWidget } from "./widgets/ClockWidget";
|
||||
// 달력 위젯 임포트
|
||||
import { CalendarWidget } from "./widgets/CalendarWidget";
|
||||
// 기사 관리 위젯 임포트
|
||||
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
|
||||
import { ListWidget } from "./widgets/ListWidget";
|
||||
|
||||
interface CanvasElementProps {
|
||||
element: DashboardElement;
|
||||
isSelected: boolean;
|
||||
|
|
@ -70,6 +128,11 @@ export function CanvasElement({
|
|||
return;
|
||||
}
|
||||
|
||||
// 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능
|
||||
if ((e.target as HTMLElement).closest(".widget-interactive-area")) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(element.id);
|
||||
setIsDragging(true);
|
||||
setDragStart({
|
||||
|
|
@ -109,9 +172,13 @@ export function CanvasElement({
|
|||
const deltaY = e.clientY - dragStart.y;
|
||||
|
||||
// 임시 위치 계산 (스냅 안 됨)
|
||||
const rawX = Math.max(0, dragStart.elementX + deltaX);
|
||||
let rawX = Math.max(0, dragStart.elementX + deltaX);
|
||||
const rawY = Math.max(0, dragStart.elementY + deltaY);
|
||||
|
||||
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한
|
||||
const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width;
|
||||
rawX = Math.min(rawX, maxX);
|
||||
|
||||
setTempPosition({ x: rawX, y: rawY });
|
||||
} else if (isResizing) {
|
||||
const deltaX = e.clientX - resizeStart.x;
|
||||
|
|
@ -122,46 +189,58 @@ export function CanvasElement({
|
|||
let newX = resizeStart.elementX;
|
||||
let newY = resizeStart.elementY;
|
||||
|
||||
const minSize = GRID_CONFIG.CELL_SIZE * 2; // 최소 2셀
|
||||
// 최소 크기 설정: 달력은 2x3, 나머지는 2x2
|
||||
const minWidthCells = 2;
|
||||
const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2;
|
||||
const minWidth = GRID_CONFIG.CELL_SIZE * minWidthCells;
|
||||
const minHeight = GRID_CONFIG.CELL_SIZE * minHeightCells;
|
||||
|
||||
switch (resizeStart.handle) {
|
||||
case "se": // 오른쪽 아래
|
||||
newWidth = Math.max(minSize, resizeStart.width + deltaX);
|
||||
newHeight = Math.max(minSize, resizeStart.height + deltaY);
|
||||
newWidth = Math.max(minWidth, resizeStart.width + deltaX);
|
||||
newHeight = Math.max(minHeight, resizeStart.height + deltaY);
|
||||
break;
|
||||
case "sw": // 왼쪽 아래
|
||||
newWidth = Math.max(minSize, resizeStart.width - deltaX);
|
||||
newHeight = Math.max(minSize, resizeStart.height + deltaY);
|
||||
newWidth = Math.max(minWidth, resizeStart.width - deltaX);
|
||||
newHeight = Math.max(minHeight, resizeStart.height + deltaY);
|
||||
newX = resizeStart.elementX + deltaX;
|
||||
break;
|
||||
case "ne": // 오른쪽 위
|
||||
newWidth = Math.max(minSize, resizeStart.width + deltaX);
|
||||
newHeight = Math.max(minSize, resizeStart.height - deltaY);
|
||||
newWidth = Math.max(minWidth, resizeStart.width + deltaX);
|
||||
newHeight = Math.max(minHeight, resizeStart.height - deltaY);
|
||||
newY = resizeStart.elementY + deltaY;
|
||||
break;
|
||||
case "nw": // 왼쪽 위
|
||||
newWidth = Math.max(minSize, resizeStart.width - deltaX);
|
||||
newHeight = Math.max(minSize, resizeStart.height - deltaY);
|
||||
newWidth = Math.max(minWidth, resizeStart.width - deltaX);
|
||||
newHeight = Math.max(minHeight, resizeStart.height - deltaY);
|
||||
newX = resizeStart.elementX + deltaX;
|
||||
newY = resizeStart.elementY + deltaY;
|
||||
break;
|
||||
}
|
||||
|
||||
// 가로 너비가 캔버스를 벗어나지 않도록 제한
|
||||
const maxWidth = GRID_CONFIG.CANVAS_WIDTH - newX;
|
||||
newWidth = Math.min(newWidth, maxWidth);
|
||||
|
||||
// 임시 크기/위치 저장 (스냅 안 됨)
|
||||
setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) });
|
||||
setTempSize({ width: newWidth, height: newHeight });
|
||||
}
|
||||
},
|
||||
[isDragging, isResizing, dragStart, resizeStart],
|
||||
[isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype],
|
||||
);
|
||||
|
||||
// 마우스 업 처리 (그리드 스냅 적용)
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (isDragging && tempPosition) {
|
||||
// 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용)
|
||||
const snappedX = snapToGrid(tempPosition.x, cellSize);
|
||||
let snappedX = snapToGrid(tempPosition.x, cellSize);
|
||||
const snappedY = snapToGrid(tempPosition.y, cellSize);
|
||||
|
||||
// X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
|
||||
const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width;
|
||||
snappedX = Math.min(snappedX, maxX);
|
||||
|
||||
onUpdate(element.id, {
|
||||
position: { x: snappedX, y: snappedY },
|
||||
});
|
||||
|
|
@ -173,9 +252,13 @@ export function CanvasElement({
|
|||
// 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용)
|
||||
const snappedX = snapToGrid(tempPosition.x, cellSize);
|
||||
const snappedY = snapToGrid(tempPosition.y, cellSize);
|
||||
const snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize);
|
||||
let snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize);
|
||||
const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize);
|
||||
|
||||
// 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
|
||||
const maxWidth = GRID_CONFIG.CANVAS_WIDTH - snappedX;
|
||||
snappedWidth = Math.min(snappedWidth, maxWidth);
|
||||
|
||||
onUpdate(element.id, {
|
||||
position: { x: snappedX, y: snappedY },
|
||||
size: { width: snappedWidth, height: snappedHeight },
|
||||
|
|
@ -187,7 +270,7 @@ export function CanvasElement({
|
|||
|
||||
setIsDragging(false);
|
||||
setIsResizing(false);
|
||||
}, [isDragging, isResizing, tempPosition, tempSize, element.id, onUpdate, cellSize]);
|
||||
}, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize]);
|
||||
|
||||
// 전역 마우스 이벤트 등록
|
||||
React.useEffect(() => {
|
||||
|
|
@ -210,27 +293,51 @@ export function CanvasElement({
|
|||
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
// console.log('🔄 쿼리 실행 시작:', element.dataSource.query);
|
||||
let result;
|
||||
|
||||
// 실제 API 호출
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
// 외부 DB vs 현재 DB 분기
|
||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(element.dataSource.externalConnectionId),
|
||||
element.dataSource.query,
|
||||
);
|
||||
|
||||
// console.log('✅ 쿼리 실행 결과:', result);
|
||||
if (!externalResult.success) {
|
||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
|
||||
setChartData({
|
||||
columns: result.columns || [],
|
||||
rows: result.rows || [],
|
||||
totalRows: result.rowCount || 0,
|
||||
executionTime: 0,
|
||||
});
|
||||
setChartData({
|
||||
columns: externalResult.data?.[0] ? Object.keys(externalResult.data[0]) : [],
|
||||
rows: externalResult.data || [],
|
||||
totalRows: externalResult.data?.length || 0,
|
||||
executionTime: 0,
|
||||
});
|
||||
} else {
|
||||
// 현재 DB
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
|
||||
setChartData({
|
||||
columns: result.columns || [],
|
||||
rows: result.rows || [],
|
||||
totalRows: result.rowCount || 0,
|
||||
executionTime: 0,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('❌ 데이터 로딩 오류:', error);
|
||||
console.error("Chart data loading error:", error);
|
||||
setChartData(null);
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
}
|
||||
}, [element.dataSource?.query, element.type, element.subtype]);
|
||||
}, [
|
||||
element.dataSource?.query,
|
||||
element.dataSource?.connectionType,
|
||||
element.dataSource?.externalConnectionId,
|
||||
element.type,
|
||||
]);
|
||||
|
||||
// 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩
|
||||
useEffect(() => {
|
||||
|
|
@ -271,6 +378,14 @@ export function CanvasElement({
|
|||
return "bg-gradient-to-br from-pink-400 to-yellow-400";
|
||||
case "weather":
|
||||
return "bg-gradient-to-br from-cyan-400 to-indigo-800";
|
||||
case "clock":
|
||||
return "bg-gradient-to-br from-teal-400 to-cyan-600";
|
||||
case "calendar":
|
||||
return "bg-gradient-to-br from-indigo-400 to-purple-600";
|
||||
case "driver-management":
|
||||
return "bg-gradient-to-br from-blue-400 to-indigo-600";
|
||||
case "list":
|
||||
return "bg-gradient-to-br from-cyan-400 to-blue-600";
|
||||
default:
|
||||
return "bg-gray-200";
|
||||
}
|
||||
|
|
@ -300,16 +415,20 @@ export function CanvasElement({
|
|||
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
|
||||
<span className="text-sm font-bold text-gray-800">{element.title}</span>
|
||||
<div className="flex gap-1">
|
||||
{/* 설정 버튼 */}
|
||||
{onConfigure && (
|
||||
<button
|
||||
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
||||
onClick={() => onConfigure(element)}
|
||||
title="설정"
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
)}
|
||||
{/* 설정 버튼 (시계, 달력, 기사관리 위젯은 자체 설정 UI 사용) */}
|
||||
{onConfigure &&
|
||||
!(
|
||||
element.type === "widget" &&
|
||||
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
|
||||
) && (
|
||||
<button
|
||||
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
||||
onClick={() => onConfigure(element)}
|
||||
title="설정"
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
)}
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
className="element-close hover:bg-destructive/100 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
||||
|
|
@ -336,7 +455,7 @@ export function CanvasElement({
|
|||
) : (
|
||||
<ChartRenderer
|
||||
element={element}
|
||||
data={chartData}
|
||||
data={chartData || undefined}
|
||||
width={element.size.width}
|
||||
height={element.size.height - 45}
|
||||
/>
|
||||
|
|
@ -344,18 +463,104 @@ export function CanvasElement({
|
|||
</div>
|
||||
) : element.type === "widget" && element.subtype === "weather" ? (
|
||||
// 날씨 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<WeatherWidget city={element.config?.city || "서울"} refreshInterval={600000} />
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<WeatherWidget city="서울" refreshInterval={600000} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "exchange" ? (
|
||||
// 환율 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<ExchangeWidget baseCurrency="KRW" targetCurrency="USD" refreshInterval={600000} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "clock" ? (
|
||||
// 시계 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<ExchangeWidget
|
||||
baseCurrency={element.config?.baseCurrency || "KRW"}
|
||||
targetCurrency={element.config?.targetCurrency || "USD"}
|
||||
refreshInterval={600000}
|
||||
<ClockWidget
|
||||
element={element}
|
||||
onConfigUpdate={(newConfig) => {
|
||||
onUpdate(element.id, { clockConfig: newConfig });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "calculator" ? (
|
||||
// 계산기 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<CalculatorWidget />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "vehicle-status" ? (
|
||||
// 차량 상태 현황 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<VehicleStatusWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "vehicle-list" ? (
|
||||
// 차량 목록 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<VehicleListWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "vehicle-map" ? (
|
||||
// 차량 위치 지도 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<VehicleMapOnlyWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "delivery-status" ? (
|
||||
// 배송/화물 현황 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<DeliveryStatusWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "risk-alert" ? (
|
||||
// 리스크/알림 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<RiskAlertWidget />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "calendar" ? (
|
||||
// 달력 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<CalendarWidget
|
||||
element={element}
|
||||
onConfigUpdate={(newConfig) => {
|
||||
onUpdate(element.id, { calendarConfig: newConfig });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "driver-management" ? (
|
||||
// 기사 관리 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<DriverManagementWidget
|
||||
element={element}
|
||||
onConfigUpdate={(newConfig) => {
|
||||
onUpdate(element.id, { driverManagementConfig: newConfig });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "list" ? (
|
||||
// 리스트 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<ListWidget
|
||||
element={element}
|
||||
onConfigUpdate={(newConfig) => {
|
||||
onUpdate(element.id, { listConfig: newConfig as any });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "todo" ? (
|
||||
// To-Do 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<TodoWidget />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "booking-alert" ? (
|
||||
// 예약 요청 알림 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<BookingAlertWidget />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "maintenance" ? (
|
||||
// 정비 일정 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<MaintenanceWidget />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "document" ? (
|
||||
// 문서 다운로드 위젯 렌더링
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<DocumentWidget />
|
||||
</div>
|
||||
) : (
|
||||
// 기타 위젯 렌더링
|
||||
<div
|
||||
|
|
@ -411,68 +616,3 @@ function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 데이터 생성 함수 (실제 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<string, any>[];
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,23 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { ChartConfig, QueryResult } from './types';
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ChartConfig, QueryResult } from "./types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { TrendingUp, AlertCircle } from "lucide-react";
|
||||
|
||||
interface ChartConfigPanelProps {
|
||||
config?: ChartConfig;
|
||||
queryResult?: QueryResult;
|
||||
onConfigChange: (config: ChartConfig) => void;
|
||||
chartType?: string;
|
||||
dataSourceType?: "database" | "api"; // 데이터 소스 타입
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -15,186 +26,340 @@ interface ChartConfigPanelProps {
|
|||
* - 차트 스타일 설정
|
||||
* - 실시간 미리보기
|
||||
*/
|
||||
export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartConfigPanelProps) {
|
||||
export function ChartConfigPanel({
|
||||
config,
|
||||
queryResult,
|
||||
onConfigChange,
|
||||
chartType,
|
||||
dataSourceType,
|
||||
}: ChartConfigPanelProps) {
|
||||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
||||
|
||||
// 설정 업데이트
|
||||
const updateConfig = useCallback((updates: Partial<ChartConfig>) => {
|
||||
const newConfig = { ...currentConfig, ...updates };
|
||||
setCurrentConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}, [currentConfig, onConfigChange]);
|
||||
// 원형/도넛 차트 또는 REST API는 Y축이 필수가 아님
|
||||
const isPieChart = chartType === "pie" || chartType === "donut";
|
||||
const isApiSource = dataSourceType === "api";
|
||||
|
||||
// 사용 가능한 컬럼 목록
|
||||
// 설정 업데이트
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<ChartConfig>) => {
|
||||
const newConfig = { ...currentConfig, ...updates };
|
||||
setCurrentConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
},
|
||||
[currentConfig, onConfigChange],
|
||||
);
|
||||
|
||||
// 사용 가능한 컬럼 목록 및 타입 정보
|
||||
const availableColumns = queryResult?.columns || [];
|
||||
const columnTypes = queryResult?.columnTypes || {};
|
||||
const sampleData = queryResult?.rows?.[0] || {};
|
||||
|
||||
// 차트에 사용 가능한 컬럼 필터링
|
||||
const simpleColumns = availableColumns.filter((col) => {
|
||||
const type = columnTypes[col];
|
||||
// number, string, boolean만 허용 (object, array는 제외)
|
||||
return !type || type === "number" || type === "string" || type === "boolean";
|
||||
});
|
||||
|
||||
// 숫자 타입 컬럼만 필터링 (Y축용)
|
||||
const numericColumns = availableColumns.filter((col) => columnTypes[col] === "number");
|
||||
|
||||
// 복잡한 타입의 컬럼 (경고 표시용)
|
||||
const complexColumns = availableColumns.filter((col) => {
|
||||
const type = columnTypes[col];
|
||||
return type === "object" || type === "array";
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-gray-800">⚙️ 차트 설정</h4>
|
||||
|
||||
{/* 쿼리 결과가 없을 때 */}
|
||||
{!queryResult && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-yellow-800 text-sm">
|
||||
💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 차트를 설정할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 데이터 필드 매핑 */}
|
||||
{queryResult && (
|
||||
<>
|
||||
{/* API 응답 미리보기 */}
|
||||
{queryResult.rows && queryResult.rows.length > 0 && (
|
||||
<Card className="border-blue-200 bg-blue-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-blue-600" />
|
||||
<h4 className="font-semibold text-blue-900">📋 API 응답 데이터 미리보기</h4>
|
||||
</div>
|
||||
<div className="rounded bg-white p-3 text-xs">
|
||||
<div className="mb-2 text-gray-600">총 {queryResult.totalRows}개 데이터 중 첫 번째 행:</div>
|
||||
<pre className="overflow-x-auto text-gray-800">{JSON.stringify(sampleData, null, 2)}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 복잡한 타입 경고 */}
|
||||
{complexColumns.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="font-semibold">⚠️ 차트에 사용할 수 없는 컬럼 감지</div>
|
||||
<div className="mt-1 text-sm">
|
||||
다음 컬럼은 객체 또는 배열 타입이라서 차트 축으로 선택할 수 없습니다:
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{complexColumns.map((col) => (
|
||||
<Badge key={col} variant="outline" className="bg-red-50">
|
||||
{col} ({columnTypes[col]})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
💡 <strong>해결 방법:</strong> JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요.
|
||||
<br />
|
||||
예: <code className="rounded bg-gray-100 px-1">main</code> 또는{" "}
|
||||
<code className="rounded bg-gray-100 px-1">data.items</code>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 차트 제목 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">차트 제목</label>
|
||||
<input
|
||||
<Label>차트 제목</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={currentConfig.title || ''}
|
||||
value={currentConfig.title || ""}
|
||||
onChange={(e) => updateConfig({ title: e.target.value })}
|
||||
placeholder="차트 제목을 입력하세요"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* X축 설정 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<Label>
|
||||
X축 (카테고리)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.xAxis || ''}
|
||||
onChange={(e) => updateConfig({ xAxis: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={currentConfig.xAxis || undefined} onValueChange={(value) => updateConfig({ xAxis: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{simpleColumns.map((col) => {
|
||||
const preview = sampleData[col];
|
||||
const previewText =
|
||||
preview !== undefined && preview !== null
|
||||
? typeof preview === "object"
|
||||
? JSON.stringify(preview).substring(0, 30)
|
||||
: String(preview).substring(0, 30)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<SelectItem key={col} value={col}>
|
||||
{col}
|
||||
{previewText && <span className="ml-2 text-xs text-gray-500">(예: {previewText})</span>}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{simpleColumns.length === 0 && (
|
||||
<p className="text-xs text-red-500">⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Y축 설정 (다중 선택 가능) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<Label>
|
||||
Y축 (값) - 여러 개 선택 가능
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto border border-gray-300 rounded-lg p-2 bg-white">
|
||||
{availableColumns.map((col) => {
|
||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis.includes(col)
|
||||
: currentConfig.yAxis === col;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={col}
|
||||
className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
const currentYAxis = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis
|
||||
: currentConfig.yAxis ? [currentConfig.yAxis] : [];
|
||||
|
||||
let newYAxis: string | string[];
|
||||
if (e.target.checked) {
|
||||
newYAxis = [...currentYAxis, col];
|
||||
} else {
|
||||
newYAxis = currentYAxis.filter(c => c !== col);
|
||||
}
|
||||
|
||||
// 단일 값이면 문자열로, 다중 값이면 배열로
|
||||
if (newYAxis.length === 1) {
|
||||
newYAxis = newYAxis[0];
|
||||
}
|
||||
|
||||
updateConfig({ yAxis: newYAxis });
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm flex-1">
|
||||
{col}
|
||||
{sampleData[col] && (
|
||||
<span className="text-gray-500 text-xs ml-2">
|
||||
(예: {sampleData[col]})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
💡 팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
||||
</div>
|
||||
{!isPieChart && !isApiSource && <span className="ml-1 text-red-500">*</span>}
|
||||
{(isPieChart || isApiSource) && (
|
||||
<span className="ml-2 text-xs text-gray-500">(선택사항 - 그룹핑+집계 사용 가능)</span>
|
||||
)}
|
||||
</Label>
|
||||
<Card className="max-h-60 overflow-y-auto p-3">
|
||||
<div className="space-y-2">
|
||||
{/* 숫자 타입 우선 표시 */}
|
||||
{numericColumns.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2 text-xs font-medium text-green-700">✅ 숫자 타입 (권장)</div>
|
||||
{numericColumns.map((col) => {
|
||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis.includes(col)
|
||||
: currentConfig.yAxis === col;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col}
|
||||
className="flex items-center gap-2 rounded border-l-2 border-green-500 bg-green-50 p-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
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 });
|
||||
}}
|
||||
/>
|
||||
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||||
<span className="font-medium">{col}</span>
|
||||
{sampleData[col] !== undefined && (
|
||||
<span className="ml-2 text-xs text-gray-600">(예: {sampleData[col]})</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 기타 간단한 타입 */}
|
||||
{simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && (
|
||||
<>
|
||||
{numericColumns.length > 0 && <div className="my-2 border-t"></div>}
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">📝 기타 타입</div>
|
||||
{simpleColumns
|
||||
.filter((col) => !numericColumns.includes(col))
|
||||
.map((col) => {
|
||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis.includes(col)
|
||||
: currentConfig.yAxis === col;
|
||||
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-2 rounded p-2 hover:bg-gray-50">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
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 });
|
||||
}}
|
||||
/>
|
||||
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||||
{col}
|
||||
{sampleData[col] !== undefined && (
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
(예: {String(sampleData[col]).substring(0, 30)})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
{simpleColumns.length === 0 && (
|
||||
<p className="text-xs text-red-500">⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 집계 함수 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<Label>
|
||||
집계 함수
|
||||
<span className="text-gray-500 text-xs ml-2">(데이터 처리 방식)</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.aggregation || 'sum'}
|
||||
onChange={(e) => updateConfig({ aggregation: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
<span className="ml-2 text-xs text-gray-500">(데이터 처리 방식)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={currentConfig.aggregation || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
aggregation: value === "none" ? undefined : (value as "sum" | "avg" | "count" | "max" | "min"),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="sum">합계 (SUM) - 모든 값을 더함</option>
|
||||
<option value="avg">평균 (AVG) - 평균값 계산</option>
|
||||
<option value="count">개수 (COUNT) - 데이터 개수</option>
|
||||
<option value="max">최대값 (MAX) - 가장 큰 값</option>
|
||||
<option value="min">최소값 (MIN) - 가장 작은 값</option>
|
||||
</select>
|
||||
<div className="text-xs text-gray-500">
|
||||
💡 집계 함수는 현재 쿼리 결과에 적용되지 않습니다.
|
||||
SQL 쿼리에서 직접 집계하는 것을 권장합니다.
|
||||
</div>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음 - SQL에서 집계됨</SelectItem>
|
||||
<SelectItem value="sum">합계 (SUM) - 모든 값을 더함</SelectItem>
|
||||
<SelectItem value="avg">평균 (AVG) - 평균값 계산</SelectItem>
|
||||
<SelectItem value="count">개수 (COUNT) - 데이터 개수</SelectItem>
|
||||
<SelectItem value="max">최대값 (MAX) - 가장 큰 값</SelectItem>
|
||||
<SelectItem value="min">최소값 (MIN) - 가장 작은 값</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
💡 그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 그룹핑 필드 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<Label>
|
||||
그룹핑 필드 (선택사항)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.groupBy || ''}
|
||||
onChange={(e) => updateConfig({ groupBy: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
<span className="ml-2 text-xs text-gray-500">(같은 값끼리 묶어서 집계)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={currentConfig.groupBy || undefined}
|
||||
onValueChange={(value) => updateConfig({ groupBy: value })}
|
||||
>
|
||||
<option value="">없음</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col} value={col}>
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 차트 색상 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">차트 색상</label>
|
||||
<Label>차트 색상</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
['#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) => (
|
||||
<button
|
||||
key={setIdx}
|
||||
type="button"
|
||||
onClick={() => updateConfig({ colors: colorSet })}
|
||||
className={`
|
||||
h-8 rounded border-2 flex
|
||||
${JSON.stringify(currentConfig.colors) === JSON.stringify(colorSet)
|
||||
? 'border-gray-800' : 'border-gray-300'}
|
||||
`}
|
||||
className={`flex h-8 rounded border-2 transition-colors ${
|
||||
JSON.stringify(currentConfig.colors) === JSON.stringify(colorSet)
|
||||
? "border-gray-800"
|
||||
: "border-gray-300 hover:border-gray-400"
|
||||
}`}
|
||||
>
|
||||
{colorSet.map((color, idx) => (
|
||||
<div
|
||||
|
|
@ -210,50 +375,75 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
|
||||
{/* 범례 표시 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
id="showLegend"
|
||||
checked={currentConfig.showLegend !== false}
|
||||
onChange={(e) => updateConfig({ showLegend: e.target.checked })}
|
||||
className="rounded"
|
||||
onCheckedChange={(checked) => updateConfig({ showLegend: checked as boolean })}
|
||||
/>
|
||||
<label htmlFor="showLegend" className="text-sm text-gray-700">
|
||||
<Label htmlFor="showLegend" className="cursor-pointer font-normal">
|
||||
범례 표시
|
||||
</label>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 설정 미리보기 */}
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div><strong>X축:</strong> {currentConfig.xAxis || '미설정'}</div>
|
||||
<div>
|
||||
<strong>Y축:</strong>{' '}
|
||||
{Array.isArray(currentConfig.yAxis)
|
||||
? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(', ')})`
|
||||
: currentConfig.yAxis || '미설정'
|
||||
}
|
||||
<Card className="bg-gray-50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
설정 미리보기
|
||||
</div>
|
||||
<div className="space-y-2 text-xs text-gray-600">
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">X축:</span>
|
||||
<span>{currentConfig.xAxis || "미설정"}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">Y축:</span>
|
||||
<span>
|
||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 0
|
||||
? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(", ")})`
|
||||
: currentConfig.yAxis || "미설정"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">집계:</span>
|
||||
<span>{currentConfig.aggregation || "없음"}</span>
|
||||
</div>
|
||||
<div><strong>집계:</strong> {currentConfig.aggregation || 'sum'}</div>
|
||||
{currentConfig.groupBy && (
|
||||
<div><strong>그룹핑:</strong> {currentConfig.groupBy}</div>
|
||||
)}
|
||||
<div><strong>데이터 행 수:</strong> {queryResult.rows.length}개</div>
|
||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && (
|
||||
<div className="text-primary mt-2">
|
||||
✨ 다중 시리즈 차트가 생성됩니다!
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">그룹핑:</span>
|
||||
<span>{currentConfig.groupBy}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">데이터 행 수:</span>
|
||||
<Badge variant="secondary">{queryResult.rows.length}개</Badge>
|
||||
</div>
|
||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && (
|
||||
<div className="mt-2 text-blue-600">✨ 다중 시리즈 차트가 생성됩니다!</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 필수 필드 확인 */}
|
||||
{(!currentConfig.xAxis || !currentConfig.yAxis) && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<div className="text-red-800 text-sm">
|
||||
⚠️ X축과 Y축을 모두 설정해야 차트가 표시됩니다.
|
||||
</div>
|
||||
</div>
|
||||
{!currentConfig.xAxis && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>X축은 필수입니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!isPieChart && !isApiSource && !currentConfig.yAxis && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Y축을 설정해야 차트가 표시됩니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{(isPieChart || isApiSource) && !currentConfig.yAxis && !currentConfig.aggregation && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>Y축 또는 집계 함수(COUNT 등)를 설정해야 차트가 표시됩니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
# 기사 관리 위젯 구현 계획
|
||||
|
||||
## 개요
|
||||
|
||||
대시보드에 추가할 수 있는 기사 관리 위젯을 구현합니다. 실시간으로 기사와 차량의 운행 상태를 확인하고 관리할 수 있는 기능을 제공합니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
### 1. 기사 정보 표시
|
||||
|
||||
- **차량 번호**: 예) 12가 3456
|
||||
- **기사 이름**: 예) 홍길동
|
||||
- **출발지**: 예) 서울시 강남구
|
||||
- **목적지**: 예) 경기도 성남시
|
||||
- **차량 유형**: 예) 1톤 트럭, 2.5톤 트럭, 5톤 트럭, 카고, 탑차, 냉동차 등
|
||||
- **운행 상태**: 대기중, 운행중, 휴식중, 점검중
|
||||
- **연락처**: 기사 전화번호
|
||||
- **운행 시작 시간**: 출발 시간
|
||||
- **예상 도착 시간**: 목적지 도착 예정 시간
|
||||
|
||||
### 2. 운행 상태 구분
|
||||
|
||||
- **대기중** (회색): 출발지/목적지가 없는 상태
|
||||
- **운행중** (초록색): 출발지/목적지가 있고 운행 중
|
||||
- **휴식중** (주황색): 휴게 중
|
||||
- **점검중** (빨간색): 차량 점검 또는 수리 중
|
||||
|
||||
### 3. 뷰 타입
|
||||
|
||||
- **리스트 뷰**: 테이블 형식으로 전체 기사 목록 표시
|
||||
- **맵 뷰** (향후 확장): 지도에 기사 위치 표시
|
||||
|
||||
### 4. 필터링 및 검색
|
||||
|
||||
- **상태별 필터**: 운행중, 대기중, 휴식중, 점검중
|
||||
- **차량 유형별 필터**: 1톤, 2.5톤, 5톤 등
|
||||
- **검색**: 기사 이름, 차량 번호로 검색
|
||||
|
||||
### 5. 정렬 기능
|
||||
|
||||
- 기사 이름순
|
||||
- 차량 번호순
|
||||
- 출발 시간순
|
||||
- 운행 상태별
|
||||
|
||||
### 6. 설정 옵션
|
||||
|
||||
- **뷰 타입**: 리스트
|
||||
- **자동 새로고침**: 실시간 데이터 갱신 (10초, 30초, 1분)
|
||||
- **표시 항목**: 사용자가 원하는 컬럼만 표시
|
||||
- **테마**: Light, Dark, 사용자 지정
|
||||
|
||||
## 데이터 구조
|
||||
|
||||
```typescript
|
||||
interface DriverInfo {
|
||||
id: string; // 기사 고유 ID
|
||||
name: string; // 기사 이름
|
||||
vehicleNumber: string; // 차량 번호
|
||||
vehicleType: string; // 차량 유형
|
||||
phone: string; // 연락처
|
||||
status: "standby" | "driving" | "resting" | "maintenance"; // 운행 상태
|
||||
departure?: string; // 출발지 (운행 중일 때)
|
||||
destination?: string; // 목적지 (운행 중일 때)
|
||||
departureTime?: string; // 출발 시간
|
||||
estimatedArrival?: string; // 예상 도착 시간
|
||||
progress?: number; // 운행 진행률 (0-100)
|
||||
}
|
||||
```
|
||||
|
||||
## 목업 데이터
|
||||
|
||||
```typescript
|
||||
const MOCK_DRIVERS: DriverInfo[] = [
|
||||
{
|
||||
id: "DRV001",
|
||||
name: "홍길동",
|
||||
vehicleNumber: "12가 3456",
|
||||
vehicleType: "1톤 트럭",
|
||||
phone: "010-1234-5678",
|
||||
status: "driving",
|
||||
departure: "서울시 강남구",
|
||||
destination: "경기도 성남시",
|
||||
departureTime: "2025-10-14T09:00:00",
|
||||
estimatedArrival: "2025-10-14T11:30:00",
|
||||
progress: 65,
|
||||
},
|
||||
{
|
||||
id: "DRV002",
|
||||
name: "김철수",
|
||||
vehicleNumber: "34나 7890",
|
||||
vehicleType: "2.5톤 트럭",
|
||||
phone: "010-2345-6789",
|
||||
status: "standby",
|
||||
},
|
||||
{
|
||||
id: "DRV003",
|
||||
name: "이영희",
|
||||
vehicleNumber: "56다 1234",
|
||||
vehicleType: "5톤 트럭",
|
||||
phone: "010-3456-7890",
|
||||
status: "driving",
|
||||
departure: "인천광역시",
|
||||
destination: "충청남도 천안시",
|
||||
departureTime: "2025-10-14T08:30:00",
|
||||
estimatedArrival: "2025-10-14T10:00:00",
|
||||
progress: 85,
|
||||
},
|
||||
{
|
||||
id: "DRV004",
|
||||
name: "박민수",
|
||||
vehicleNumber: "78라 5678",
|
||||
vehicleType: "카고",
|
||||
phone: "010-4567-8901",
|
||||
status: "resting",
|
||||
},
|
||||
{
|
||||
id: "DRV005",
|
||||
name: "정수진",
|
||||
vehicleNumber: "90마 9012",
|
||||
vehicleType: "냉동차",
|
||||
phone: "010-5678-9012",
|
||||
status: "maintenance",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## 구현 단계
|
||||
|
||||
### ✅ Step 1: 타입 정의
|
||||
|
||||
- [x] `DriverManagementConfig` 인터페이스 정의
|
||||
- [x] `DriverInfo` 인터페이스 정의
|
||||
- [x] `types.ts`에 기사 관리 설정 타입 추가
|
||||
- [x] 요소 타입에 'driver-management' subtype 추가
|
||||
|
||||
### ✅ Step 2: 목업 데이터 생성
|
||||
|
||||
- [x] `driverMockData.ts` - 기사 목업 데이터 생성
|
||||
- [x] 다양한 운행 상태의 샘플 데이터 (15개)
|
||||
- [x] 차량 유형별 샘플 데이터
|
||||
|
||||
### ✅ Step 3: 유틸리티 함수
|
||||
|
||||
- [x] `driverUtils.ts` - 기사 관리 유틸리티 함수
|
||||
- [x] 운행 상태별 색상 반환
|
||||
- [x] 진행률 계산
|
||||
- [x] 시간 포맷팅
|
||||
- [x] 필터링/정렬 로직
|
||||
|
||||
### ✅ Step 4: 리스트 뷰 컴포넌트
|
||||
|
||||
- [x] `DriverListView.tsx` - 테이블 형식 리스트 뷰
|
||||
- [x] 상태별 색상 구분
|
||||
- [x] 정렬 기능 (유틸리티에서 처리)
|
||||
- [x] 반응형 테이블 디자인 (컴팩트 모드 포함)
|
||||
|
||||
### ✅ Step 5: 카드 뷰 컴포넌트
|
||||
|
||||
- [x] 카드 뷰는 현재 구현하지 않음 (리스트 뷰만 사용)
|
||||
- [ ] `DriverCardView.tsx` - 향후 추가 예정
|
||||
|
||||
### ✅ Step 6: 메인 위젯 컴포넌트
|
||||
|
||||
- [x] `DriverManagementWidget.tsx` - 메인 위젯
|
||||
- [x] 리스트 뷰 표시
|
||||
- [x] 필터링 UI (상태별)
|
||||
- [x] 검색 기능
|
||||
- [x] 자동 새로고침 (시뮬레이션)
|
||||
|
||||
### ✅ Step 7: 설정 UI
|
||||
|
||||
- [x] `DriverManagementSettings.tsx` - 설정 컴포넌트
|
||||
- [x] 자동 새로고침 간격 설정
|
||||
- [x] 표시 컬럼 선택
|
||||
- [x] 테마 설정
|
||||
- [x] 정렬 기준 설정
|
||||
|
||||
### ✅ Step 8: 통합
|
||||
|
||||
- [x] `DashboardSidebar`에 기사 관리 위젯 추가
|
||||
- [x] `CanvasElement`에서 기사 관리 위젯 렌더링
|
||||
- [x] `DashboardDesigner`에 기본값 설정
|
||||
- [x] `ElementConfigModal`에 예외 처리 추가
|
||||
|
||||
### ✅ Step 9: 스타일링 및 최적화
|
||||
|
||||
- [ ] 반응형 디자인 (다양한 위젯 크기 대응)
|
||||
- [ ] 컴팩트 모드 (작은 크기)
|
||||
- [ ] 로딩 상태 처리
|
||||
- [ ] 빈 데이터 상태 처리
|
||||
|
||||
### ✅ Step 10: 향후 확장 기능
|
||||
|
||||
- [ ] 실제 REST API 연동
|
||||
- [ ] 웹소켓을 통한 실시간 업데이트
|
||||
- [ ] 맵 뷰 (지도에 기사 위치 표시)
|
||||
- [ ] 기사별 상세 정보 모달
|
||||
- [ ] 운행 이력 조회
|
||||
- [ ] 알림 기능 (지연, 긴급 상황 등)
|
||||
|
||||
## 위젯 크기별 최적화
|
||||
|
||||
### 2x2 (최소 크기)
|
||||
|
||||
- 요약 정보만 표시 (운행중 기사 수, 대기 기사 수)
|
||||
- 간단한 상태 표시
|
||||
|
||||
### 3x3
|
||||
|
||||
- 카드 뷰 (2-3개 기사 표시)
|
||||
- 기본 정보 표시
|
||||
|
||||
### 4x3 이상 (권장)
|
||||
|
||||
- 리스트 뷰 또는 카드 뷰 전체 표시
|
||||
- 필터링 및 검색 기능
|
||||
- 모든 정보 표시
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- [x] 기본 타입 정의 완료
|
||||
- [x] 목업 데이터 생성 완료
|
||||
- [x] 리스트 뷰 구현 완료
|
||||
- [ ] 카드 뷰 구현 완료 (향후 추가)
|
||||
- [x] 필터링/검색 기능 구현 완료
|
||||
- [x] 설정 UI 구현 완료
|
||||
- [x] 대시보드 통합 완료
|
||||
- [ ] 다양한 크기에서 테스트 완료 (사용자 테스트 필요)
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **성능 최적화**: 많은 기사 데이터를 처리할 때 가상 스크롤링 고려
|
||||
2. **실시간 업데이트**: 자동 새로고침 시 부드러운 전환 애니메이션
|
||||
3. **접근성**: 키보드 네비게이션 지원
|
||||
4. **에러 처리**: API 연동 시 에러 상태 처리
|
||||
5. **반응형**: 작은 크기에서도 정보가 잘 보이도록 디자인
|
||||
|
||||
## 추가 개선 사항 제안
|
||||
|
||||
### 1. 통계 정보
|
||||
|
||||
- 오늘 총 운행 건수
|
||||
- 평균 운행 시간
|
||||
- 차량 유형별 운행 통계
|
||||
|
||||
### 2. 긴급 상황 알림
|
||||
|
||||
- 운행 지연 알림 (예상 시간 초과)
|
||||
- 차량 점검 필요 알림
|
||||
- 기사 연락 두절 알림
|
||||
|
||||
### 3. 배차 관리 (고급 기능)
|
||||
|
||||
- 대기 중인 기사에게 배차
|
||||
- 운행 스케줄 관리
|
||||
- 경로 최적화 제안
|
||||
|
||||
### 4. 보고서 기능
|
||||
|
||||
- 일일 운행 보고서
|
||||
- 기사별 운행 실적
|
||||
- 차량별 가동률
|
||||
|
||||
---
|
||||
|
||||
## 🎯 구현 우선순위
|
||||
|
||||
1. **필수 (Phase 1)**
|
||||
- 타입 정의
|
||||
- 목업 데이터
|
||||
- 리스트 뷰
|
||||
- 기본 필터링
|
||||
|
||||
2. **중요 (Phase 2)**
|
||||
- 카드 뷰
|
||||
- 검색 기능
|
||||
- 설정 UI
|
||||
- 자동 새로고침
|
||||
|
||||
3. **추가 (Phase 3)**
|
||||
- 통계 정보
|
||||
- 상세 정보 모달
|
||||
- 운행 이력
|
||||
|
||||
4. **향후 (Phase 4)**
|
||||
- 맵 뷰
|
||||
- 실시간 위치 추적
|
||||
- 배차 관리
|
||||
- 보고서 기능
|
||||
|
||||
---
|
||||
|
||||
**구현 시작일**: 2025-10-14
|
||||
**구현 완료일**: 2025-10-14
|
||||
**현재 진행률**: 90% (카드 뷰 및 최종 테스트 제외)
|
||||
|
||||
## 🎉 구현 완료!
|
||||
|
||||
기사 관리 위젯의 핵심 기능이 모두 구현되었습니다!
|
||||
|
||||
### ✅ 구현된 기능
|
||||
|
||||
1. **데이터 구조**
|
||||
- DriverInfo, DriverManagementConfig 타입 정의
|
||||
- 15개의 다양한 목업 데이터
|
||||
- 6가지 차량 유형 지원
|
||||
|
||||
2. **리스트 뷰**
|
||||
- 테이블 형식의 깔끔한 UI
|
||||
- 상태별 색상 구분 (운행중/대기중/휴식중/점검중)
|
||||
- 컴팩트 모드 지원 (2x2 크기)
|
||||
|
||||
3. **필터링 및 검색**
|
||||
- 상태별 필터 (전체/운행중/대기중/휴식중/점검중)
|
||||
- 기사명, 차량번호 검색
|
||||
- 실시간 필터링
|
||||
|
||||
4. **정렬 기능**
|
||||
- 기사명, 차량번호, 운행상태, 출발시간 기준 정렬
|
||||
- 오름차순/내림차순 지원
|
||||
|
||||
5. **자동 새로고침**
|
||||
- 10초/30초/1분/5분 간격 설정 가능
|
||||
- 실시간 데이터 시뮬레이션
|
||||
|
||||
6. **설정 UI**
|
||||
- Popover 방식의 직관적인 설정
|
||||
- 표시 컬럼 선택 (9개 컬럼)
|
||||
- 테마 설정 (Light/Dark/Custom)
|
||||
- 정렬 기준 및 순서 설정
|
||||
|
||||
7. **대시보드 통합**
|
||||
- 사이드바에 드래그 가능한 위젯 추가
|
||||
- 캔버스에서 자유로운 배치 및 크기 조절
|
||||
- 설정 저장 및 불러오기
|
||||
|
||||
### 🚀 향후 개선 사항
|
||||
|
||||
- 카드 뷰 구현
|
||||
- 맵 뷰 (지도 연동)
|
||||
- 실제 REST API 연동
|
||||
- 웹소켓 실시간 업데이트
|
||||
- 통계 정보 추가
|
||||
- 배차 관리 기능
|
||||
|
|
@ -13,6 +13,7 @@ interface DashboardCanvasProps {
|
|||
onRemoveElement: (id: string) => void;
|
||||
onSelectElement: (id: string | null) => void;
|
||||
onConfigureElement?: (element: DashboardElement) => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -32,6 +33,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
onRemoveElement,
|
||||
onSelectElement,
|
||||
onConfigureElement,
|
||||
backgroundColor = "#f9fafb",
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
|
|
@ -70,9 +72,13 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
||||
|
||||
// 그리드에 스냅 (고정 셀 크기 사용)
|
||||
const snappedX = snapToGrid(rawX, GRID_CONFIG.CELL_SIZE);
|
||||
let snappedX = snapToGrid(rawX, GRID_CONFIG.CELL_SIZE);
|
||||
const snappedY = snapToGrid(rawY, GRID_CONFIG.CELL_SIZE);
|
||||
|
||||
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한
|
||||
const maxX = GRID_CONFIG.CANVAS_WIDTH - GRID_CONFIG.CELL_SIZE * 2; // 최소 2칸 너비 보장
|
||||
snappedX = Math.max(0, Math.min(snappedX, maxX));
|
||||
|
||||
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
|
||||
} catch (error) {
|
||||
// console.error('드롭 데이터 파싱 오류:', error);
|
||||
|
|
@ -104,8 +110,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative rounded-lg bg-gray-50 shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
|
||||
className={`relative rounded-lg shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
|
||||
style={{
|
||||
backgroundColor,
|
||||
width: `${GRID_CONFIG.CANVAS_WIDTH}px`,
|
||||
minHeight: `${minCanvasHeight}px`,
|
||||
// 12 컬럼 그리드 배경
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardCanvas } from "./DashboardCanvas";
|
||||
import { DashboardSidebar } from "./DashboardSidebar";
|
||||
import { DashboardToolbar } from "./DashboardToolbar";
|
||||
import { ElementConfigModal } from "./ElementConfigModal";
|
||||
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
|
||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||
import { GRID_CONFIG } from "./gridUtils";
|
||||
|
||||
|
|
@ -16,6 +18,7 @@ import { GRID_CONFIG } from "./gridUtils";
|
|||
* - 레이아웃 저장/불러오기 기능
|
||||
*/
|
||||
export default function DashboardDesigner() {
|
||||
const router = useRouter();
|
||||
const [elements, setElements] = useState<DashboardElement[]>([]);
|
||||
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
||||
const [elementCounter, setElementCounter] = useState(0);
|
||||
|
|
@ -23,6 +26,7 @@ export default function DashboardDesigner() {
|
|||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
||||
const [dashboardTitle, setDashboardTitle] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("#f9fafb");
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드
|
||||
|
|
@ -79,8 +83,15 @@ export default function DashboardDesigner() {
|
|||
// 새로운 요소 생성 (고정 그리드 기반 기본 크기)
|
||||
const createElement = useCallback(
|
||||
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
|
||||
// 기본 크기: 차트는 4x3 셀, 위젯은 2x2 셀
|
||||
const defaultCells = type === "chart" ? { width: 4, height: 3 } : { width: 2, height: 2 };
|
||||
// 기본 크기 설정
|
||||
let defaultCells = { width: 2, height: 2 }; // 기본 위젯 크기
|
||||
|
||||
if (type === "chart") {
|
||||
defaultCells = { width: 4, height: 3 }; // 차트
|
||||
} else if (type === "widget" && subtype === "calendar") {
|
||||
defaultCells = { width: 2, height: 3 }; // 달력 최소 크기
|
||||
}
|
||||
|
||||
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
|
||||
|
||||
const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP;
|
||||
|
|
@ -146,6 +157,16 @@ export default function DashboardDesigner() {
|
|||
[updateElement],
|
||||
);
|
||||
|
||||
// 리스트 위젯 설정 저장 (Partial 업데이트)
|
||||
const saveListWidgetConfig = useCallback(
|
||||
(updates: Partial<DashboardElement>) => {
|
||||
if (configModalElement) {
|
||||
updateElement(configModalElement.id, updates);
|
||||
}
|
||||
},
|
||||
[configModalElement, updateElement],
|
||||
);
|
||||
|
||||
// 레이아웃 저장
|
||||
const saveLayout = useCallback(async () => {
|
||||
if (elements.length === 0) {
|
||||
|
|
@ -173,15 +194,14 @@ export default function DashboardDesigner() {
|
|||
|
||||
if (dashboardId) {
|
||||
// 기존 대시보드 업데이트
|
||||
// console.log('🔄 대시보드 업데이트:', dashboardId);
|
||||
savedDashboard = await dashboardApi.updateDashboard(dashboardId, {
|
||||
elements: elementsData,
|
||||
});
|
||||
|
||||
alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`);
|
||||
|
||||
// 뷰어 페이지로 이동
|
||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
||||
// Next.js 라우터로 뷰어 페이지 이동
|
||||
router.push(`/dashboard/${savedDashboard.id}`);
|
||||
} else {
|
||||
// 새 대시보드 생성
|
||||
const title = prompt("대시보드 제목을 입력하세요:", "새 대시보드");
|
||||
|
|
@ -198,20 +218,17 @@ export default function DashboardDesigner() {
|
|||
|
||||
savedDashboard = await dashboardApi.createDashboard(dashboardData);
|
||||
|
||||
// console.log('✅ 대시보드 생성 완료:', savedDashboard);
|
||||
|
||||
const viewDashboard = confirm(`대시보드 "${title}"이 저장되었습니다!\n\n지금 확인해보시겠습니까?`);
|
||||
if (viewDashboard) {
|
||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
||||
// Next.js 라우터로 뷰어 페이지 이동
|
||||
router.push(`/dashboard/${savedDashboard.id}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('❌ 저장 오류:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
||||
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
|
||||
}
|
||||
}, [elements, dashboardId]);
|
||||
}, [elements, dashboardId, router]);
|
||||
|
||||
// 로딩 중이면 로딩 화면 표시
|
||||
if (isLoading) {
|
||||
|
|
@ -237,7 +254,12 @@ export default function DashboardDesigner() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<DashboardToolbar onClearCanvas={clearCanvas} onSaveLayout={saveLayout} />
|
||||
<DashboardToolbar
|
||||
onClearCanvas={clearCanvas}
|
||||
onSaveLayout={saveLayout}
|
||||
canvasBackgroundColor={canvasBackgroundColor}
|
||||
onCanvasBackgroundColorChange={setCanvasBackgroundColor}
|
||||
/>
|
||||
|
||||
{/* 캔버스 중앙 정렬 컨테이너 */}
|
||||
<div className="flex justify-center p-4">
|
||||
|
|
@ -250,6 +272,7 @@ export default function DashboardDesigner() {
|
|||
onRemoveElement={removeElement}
|
||||
onSelectElement={setSelectedElement}
|
||||
onConfigureElement={openConfigModal}
|
||||
backgroundColor={canvasBackgroundColor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -259,12 +282,23 @@ export default function DashboardDesigner() {
|
|||
|
||||
{/* 요소 설정 모달 */}
|
||||
{configModalElement && (
|
||||
<ElementConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveElementConfig}
|
||||
/>
|
||||
<>
|
||||
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
|
||||
<ListWidgetConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveListWidgetConfig}
|
||||
/>
|
||||
) : (
|
||||
<ElementConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveElementConfig}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -276,6 +310,8 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
|||
switch (subtype) {
|
||||
case "bar":
|
||||
return "📊 바 차트";
|
||||
case "horizontal-bar":
|
||||
return "📊 수평 바 차트";
|
||||
case "pie":
|
||||
return "🥧 원형 차트";
|
||||
case "line":
|
||||
|
|
@ -289,6 +325,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 +350,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
|||
switch (subtype) {
|
||||
case "bar":
|
||||
return "바 차트가 여기에 표시됩니다";
|
||||
case "horizontal-bar":
|
||||
return "수평 바 차트가 여기에 표시됩니다";
|
||||
case "pie":
|
||||
return "원형 차트가 여기에 표시됩니다";
|
||||
case "line":
|
||||
|
|
@ -315,6 +365,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 "위젯 내용이 여기에 표시됩니다";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ export function DashboardSidebar() {
|
|||
onDragStart={handleDragStart}
|
||||
className="border-primary border-l-4"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📊"
|
||||
title="수평 바 차트"
|
||||
type="chart"
|
||||
subtype="horizontal-bar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📚"
|
||||
title="누적 바 차트"
|
||||
|
|
@ -101,7 +109,127 @@ export function DashboardSidebar() {
|
|||
type="widget"
|
||||
subtype="weather"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-orange-500"
|
||||
className="border-l-4 border-cyan-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🧮"
|
||||
title="계산기 위젯"
|
||||
type="widget"
|
||||
subtype="calculator"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-green-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="⏰"
|
||||
title="시계 위젯"
|
||||
type="widget"
|
||||
subtype="clock"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-teal-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📊"
|
||||
title="차량 상태 현황"
|
||||
type="widget"
|
||||
subtype="vehicle-status"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-green-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📋"
|
||||
title="차량 목록"
|
||||
type="widget"
|
||||
subtype="vehicle-list"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🗺️"
|
||||
title="차량 위치 지도"
|
||||
type="widget"
|
||||
subtype="vehicle-map"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-red-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📦"
|
||||
title="배송/화물 현황"
|
||||
type="widget"
|
||||
subtype="delivery-status"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-amber-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="⚠️"
|
||||
title="리스크/알림 위젯"
|
||||
type="widget"
|
||||
subtype="risk-alert"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-rose-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📅"
|
||||
title="달력 위젯"
|
||||
type="widget"
|
||||
subtype="calendar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-indigo-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🚗"
|
||||
title="기사 관리 위젯"
|
||||
type="widget"
|
||||
subtype="driver-management"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 운영/작업 지원 섹션 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">📋 운영/작업 지원</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<DraggableItem
|
||||
icon="✅"
|
||||
title="To-Do / 긴급 지시"
|
||||
type="widget"
|
||||
subtype="todo"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🔔"
|
||||
title="예약 요청 알림"
|
||||
type="widget"
|
||||
subtype="booking-alert"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-rose-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🔧"
|
||||
title="정비 일정 관리"
|
||||
type="widget"
|
||||
subtype="maintenance"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-teal-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📂"
|
||||
title="문서 다운로드"
|
||||
type="widget"
|
||||
subtype="document"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-purple-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📋"
|
||||
title="리스트 위젯"
|
||||
type="widget"
|
||||
subtype="list"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="absolute top-5 left-5 bg-white p-3 rounded-lg shadow-lg z-50 flex gap-3">
|
||||
<button
|
||||
|
|
@ -37,6 +40,71 @@ export function DashboardToolbar({ onClearCanvas, onSaveLayout }: DashboardToolb
|
|||
>
|
||||
💾 레이아웃 저장
|
||||
</button>
|
||||
|
||||
{/* 캔버스 배경색 변경 버튼 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||
className="
|
||||
px-4 py-2 border border-gray-300 bg-white rounded-md
|
||||
text-sm font-medium text-gray-700
|
||||
hover:bg-gray-50 hover:border-gray-400
|
||||
transition-colors duration-200
|
||||
flex items-center gap-2
|
||||
"
|
||||
>
|
||||
🎨 캔버스 색상
|
||||
<div
|
||||
className="w-4 h-4 rounded border border-gray-300"
|
||||
style={{ backgroundColor: canvasBackgroundColor }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 색상 선택 패널 */}
|
||||
{showColorPicker && (
|
||||
<div className="absolute top-full left-0 mt-2 bg-white p-4 rounded-lg shadow-xl z-50 border border-gray-200 w-[280px]">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<input
|
||||
type="color"
|
||||
value={canvasBackgroundColor}
|
||||
onChange={(e) => onCanvasBackgroundColorChange(e.target.value)}
|
||||
className="h-10 w-16 border border-gray-300 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={canvasBackgroundColor}
|
||||
onChange={(e) => onCanvasBackgroundColorChange(e.target.value)}
|
||||
placeholder="#ffffff"
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 프리셋 색상 */}
|
||||
<div className="grid grid-cols-6 gap-2 mb-3">
|
||||
{[
|
||||
'#ffffff', '#f9fafb', '#f3f4f6', '#e5e7eb',
|
||||
'#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b',
|
||||
'#10b981', '#06b6d4', '#6366f1', '#84cc16',
|
||||
].map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => onCanvasBackgroundColorChange(color)}
|
||||
className={`h-8 rounded border-2 ${canvasBackgroundColor === color ? 'border-blue-500 ring-2 ring-blue-200' : 'border-gray-300'}`}
|
||||
style={{ backgroundColor: color }}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowColorPicker(false)}
|
||||
className="w-full px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded hover:bg-gray-50"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from './types';
|
||||
import { QueryEditor } from './QueryEditor';
|
||||
import { ChartConfigPanel } from './ChartConfigPanel';
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types";
|
||||
import { QueryEditor } from "./QueryEditor";
|
||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
||||
import { DataSourceSelector } from "./data-sources/DataSourceSelector";
|
||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { X, ChevronLeft, ChevronRight, Save } from "lucide-react";
|
||||
|
||||
interface ElementConfigModalProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -13,24 +20,52 @@ interface ElementConfigModalProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* 요소 설정 모달 컴포넌트
|
||||
* - 차트/위젯 데이터 소스 설정
|
||||
* - 쿼리 에디터 통합
|
||||
* - 차트 설정 패널 통합
|
||||
* 요소 설정 모달 컴포넌트 (리팩토링)
|
||||
* - 2단계 플로우: 데이터 소스 선택 → 데이터 설정 및 차트 설정
|
||||
* - 새로운 데이터 소스 컴포넌트 통합
|
||||
*/
|
||||
export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) {
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: 'database', refreshInterval: 30000 }
|
||||
);
|
||||
const [chartConfig, setChartConfig] = useState<ChartConfig>(
|
||||
element.chartConfig || {}
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
const [chartConfig, setChartConfig] = useState<ChartConfig>(element.chartConfig || {});
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'query' | 'chart'>('query');
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
|
||||
// 주석
|
||||
// 모달이 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
setChartConfig(element.chartConfig || {});
|
||||
setQueryResult(null);
|
||||
setCurrentStep(1);
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
// 데이터 소스 변경 처리
|
||||
const handleDataSourceChange = useCallback((newDataSource: ChartDataSource) => {
|
||||
setDataSource(newDataSource);
|
||||
// 데이터 소스 타입 변경
|
||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||
if (type === "database") {
|
||||
setDataSource({
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
} else {
|
||||
setDataSource({
|
||||
type: "api",
|
||||
method: "GET",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// 데이터 소스 변경 시 쿼리 결과와 차트 설정 초기화
|
||||
setQueryResult(null);
|
||||
setChartConfig({});
|
||||
}, []);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 차트 설정 변경 처리
|
||||
|
|
@ -41,12 +76,22 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
// 쿼리 테스트 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
// 쿼리 결과가 나오면 자동으로 차트 설정 탭으로 이동
|
||||
if (result.rows.length > 0) {
|
||||
setActiveTab('chart');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 다음 단계로 이동
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 1) {
|
||||
setCurrentStep(2);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// 이전 단계로 이동
|
||||
const handlePrev = useCallback(() => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((prev) => (prev - 1) as 1 | 2);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = useCallback(() => {
|
||||
const updatedElement: DashboardElement = {
|
||||
|
|
@ -61,106 +106,139 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
// 모달이 열려있지 않으면 렌더링하지 않음
|
||||
if (!isOpen) return null;
|
||||
|
||||
// 시계, 달력, 기사관리 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
|
||||
if (
|
||||
element.type === "widget" &&
|
||||
(element.subtype === "clock" || element.subtype === "calendar" || element.subtype === "driver-management")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 저장 가능 여부 확인
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
const isApiSource = dataSource.type === "api";
|
||||
|
||||
const canSave =
|
||||
currentStep === 2 &&
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(isPieChart || isApiSource
|
||||
? // 파이/도넛 차트 또는 REST API: Y축 또는 집계 함수 필요
|
||||
chartConfig.yAxis ||
|
||||
(Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0) ||
|
||||
chartConfig.aggregation === "count"
|
||||
: // 일반 차트 (DB): Y축 필수
|
||||
chartConfig.yAxis || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl h-[80vh] flex flex-col">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div
|
||||
className={`flex flex-col rounded-xl border bg-white shadow-2xl ${
|
||||
currentStep === 1 ? "h-auto max-h-[70vh] w-full max-w-3xl" : "h-[85vh] w-full max-w-5xl"
|
||||
}`}
|
||||
>
|
||||
{/* 모달 헤더 */}
|
||||
<div className="flex justify-between items-center p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between border-b p-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
{element.title} 설정
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
데이터 소스와 차트 설정을 구성하세요
|
||||
<h2 className="text-xl font-semibold text-gray-900">{element.title} 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{currentStep === 1 ? "데이터 소스를 선택하세요" : "쿼리를 실행하고 차트를 설정하세요"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-muted-foreground text-2xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('query')}
|
||||
className={`
|
||||
px-6 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === 'query'
|
||||
? 'border-primary text-primary bg-accent'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'}
|
||||
`}
|
||||
>
|
||||
📝 쿼리 & 데이터
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('chart')}
|
||||
className={`
|
||||
px-6 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === 'chart'
|
||||
? 'border-primary text-primary bg-accent'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'}
|
||||
`}
|
||||
>
|
||||
📊 차트 설정
|
||||
{queryResult && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
{queryResult.rows.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* 진행 상황 표시 */}
|
||||
<div className="border-b bg-gray-50 px-6 py-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"}
|
||||
</div>
|
||||
<Badge variant="secondary">{Math.round((currentStep / 2) * 100)}% 완료</Badge>
|
||||
</div>
|
||||
<Progress value={(currentStep / 2) * 100} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* 탭 내용 */}
|
||||
{/* 단계별 내용 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{activeTab === 'query' && (
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceChange}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
{currentStep === 1 && (
|
||||
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
|
||||
)}
|
||||
|
||||
{activeTab === 'chart' && (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
{currentStep === 2 && (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 왼쪽: 데이터 설정 */}
|
||||
<div className="space-y-6">
|
||||
{dataSource.type === "database" ? (
|
||||
<>
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 차트 설정 */}
|
||||
<div>
|
||||
{queryResult && queryResult.rows.length > 0 ? (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 차트 설정이 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="flex justify-between items-center p-6 border-t border-gray-200">
|
||||
<div className="text-sm text-gray-500">
|
||||
{dataSource.query && (
|
||||
<>
|
||||
💾 쿼리: {dataSource.query.length > 50
|
||||
? `${dataSource.query.substring(0, 50)}...`
|
||||
: dataSource.query}
|
||||
</>
|
||||
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
|
||||
<div>
|
||||
{queryResult && (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
📊 {queryResult.rows.length}개 데이터 로드됨
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-muted-foreground border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
{currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!dataSource.query || (!chartConfig.xAxis || !chartConfig.yAxis)}
|
||||
className="
|
||||
px-4 py-2 bg-accent0 text-white rounded-lg
|
||||
hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed
|
||||
"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</Button>
|
||||
{currentStep === 1 ? (
|
||||
<Button onClick={handleNext}>
|
||||
다음
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { ChartDataSource, QueryResult } from './types';
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ChartDataSource, QueryResult } from "./types";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Play, Loader2, Database, Code } from "lucide-react";
|
||||
|
||||
interface QueryEditorProps {
|
||||
dataSource?: ChartDataSource;
|
||||
|
|
@ -13,73 +24,88 @@ interface QueryEditorProps {
|
|||
* SQL 쿼리 에디터 컴포넌트
|
||||
* - SQL 쿼리 작성 및 편집
|
||||
* - 쿼리 실행 및 결과 미리보기
|
||||
* - 데이터 소스 설정
|
||||
* - 현재 DB / 외부 DB 분기 처리
|
||||
*/
|
||||
export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: QueryEditorProps) {
|
||||
const [query, setQuery] = useState(dataSource?.query || '');
|
||||
const [query, setQuery] = useState(dataSource?.query || "");
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 쿼리 실행
|
||||
const executeQuery = useCallback(async () => {
|
||||
console.log("🚀 executeQuery 호출됨!");
|
||||
console.log("📝 현재 쿼리:", query);
|
||||
console.log("✅ query.trim():", query.trim());
|
||||
|
||||
if (!query.trim()) {
|
||||
setError('쿼리를 입력해주세요.');
|
||||
setError("쿼리를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 외부 DB인 경우 커넥션 ID 확인
|
||||
if (dataSource?.connectionType === "external" && !dataSource?.externalConnectionId) {
|
||||
setError("외부 DB 커넥션을 선택해주세요.");
|
||||
console.log("❌ 쿼리가 비어있음!");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExecuting(true);
|
||||
setError(null);
|
||||
console.log("🔄 쿼리 실행 시작...");
|
||||
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const response = await fetch('http://localhost:8080/api/dashboards/execute-query', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token') || 'test-token'}` // JWT 토큰 사용
|
||||
},
|
||||
body: JSON.stringify({ query: query.trim() })
|
||||
});
|
||||
let apiResult: { columns: string[]; rows: any[]; rowCount: number };
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || '쿼리 실행에 실패했습니다.');
|
||||
// 현재 DB vs 외부 DB 분기
|
||||
if (dataSource?.connectionType === "external" && dataSource?.externalConnectionId) {
|
||||
// 외부 DB 쿼리 실행
|
||||
const result = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(dataSource.externalConnectionId),
|
||||
query.trim(),
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 DB 쿼리 실행에 실패했습니다.");
|
||||
}
|
||||
|
||||
// ExternalDbConnectionAPI의 응답을 통일된 형식으로 변환
|
||||
apiResult = {
|
||||
columns: result.data?.[0] ? Object.keys(result.data[0]) : [],
|
||||
rows: result.data || [],
|
||||
rowCount: result.data?.length || 0,
|
||||
};
|
||||
} else {
|
||||
// 현재 DB 쿼리 실행
|
||||
apiResult = await dashboardApi.executeQuery(query.trim());
|
||||
}
|
||||
|
||||
const apiResult = await response.json();
|
||||
|
||||
if (!apiResult.success) {
|
||||
throw new Error(apiResult.message || '쿼리 실행에 실패했습니다.');
|
||||
}
|
||||
|
||||
// API 결과를 QueryResult 형식으로 변환
|
||||
// 결과를 QueryResult 형식으로 변환
|
||||
const result: QueryResult = {
|
||||
columns: apiResult.data.columns,
|
||||
rows: apiResult.data.rows,
|
||||
totalRows: apiResult.data.rowCount,
|
||||
executionTime: 0 // API에서 실행 시간을 제공하지 않으므로 0으로 설정
|
||||
columns: apiResult.columns,
|
||||
rows: apiResult.rows,
|
||||
totalRows: apiResult.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
|
||||
|
||||
setQueryResult(result);
|
||||
onQueryTest?.(result);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
onDataSourceChange({
|
||||
type: 'database',
|
||||
...dataSource,
|
||||
type: "database",
|
||||
query: query.trim(),
|
||||
refreshInterval: dataSource?.refreshInterval || 30000,
|
||||
lastExecuted: new Date().toISOString()
|
||||
refreshInterval: dataSource?.refreshInterval ?? 0,
|
||||
lastExecuted: new Date().toISOString(),
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '쿼리 실행 중 오류가 발생했습니다.';
|
||||
const errorMessage = err instanceof Error ? err.message : "쿼리 실행 중 오류가 발생했습니다.";
|
||||
setError(errorMessage);
|
||||
// console.error('Query execution error:', err);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
}, [query, dataSource?.refreshInterval, onDataSourceChange, onQueryTest]);
|
||||
}, [query, dataSource, onDataSourceChange, onQueryTest]);
|
||||
|
||||
// 샘플 쿼리 삽입
|
||||
const insertSampleQuery = useCallback((sampleType: string) => {
|
||||
|
|
@ -105,7 +131,7 @@ FROM orders
|
|||
WHERE order_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||
GROUP BY DATE_TRUNC('month', order_date)
|
||||
ORDER BY month;`,
|
||||
|
||||
|
||||
users: `-- 사용자 가입 추이
|
||||
SELECT
|
||||
DATE_TRUNC('week', created_at) as week,
|
||||
|
|
@ -114,7 +140,7 @@ FROM users
|
|||
WHERE created_at >= CURRENT_DATE - INTERVAL '3 months'
|
||||
GROUP BY DATE_TRUNC('week', created_at)
|
||||
ORDER BY week;`,
|
||||
|
||||
|
||||
products: `-- 상품별 판매량
|
||||
SELECT
|
||||
product_name,
|
||||
|
|
@ -137,193 +163,166 @@ SELECT
|
|||
FROM regional_sales
|
||||
WHERE year = EXTRACT(YEAR FROM CURRENT_DATE)
|
||||
GROUP BY region
|
||||
ORDER BY Q4 DESC;`
|
||||
ORDER BY Q4 DESC;`,
|
||||
};
|
||||
|
||||
setQuery(samples[sampleType as keyof typeof samples] || '');
|
||||
setQuery(samples[sampleType as keyof typeof samples] || "");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{/* 쿼리 에디터 헤더 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="text-lg font-semibold text-gray-800">📝 SQL 쿼리 에디터</h4>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={executeQuery}
|
||||
disabled={isExecuting || !query.trim()}
|
||||
className="
|
||||
px-3 py-1 bg-accent0 text-white rounded text-sm
|
||||
hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed
|
||||
flex items-center gap-1
|
||||
"
|
||||
>
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<div className="w-3 h-3 border border-white border-t-transparent rounded-full animate-spin" />
|
||||
실행 중...
|
||||
</>
|
||||
) : (
|
||||
<>▶ 실행</>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
<h4 className="text-lg font-semibold text-gray-800">SQL 쿼리 에디터</h4>
|
||||
</div>
|
||||
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm">
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
실행 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
실행
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 샘플 쿼리 버튼들 */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground">샘플 쿼리:</span>
|
||||
<button
|
||||
onClick={() => insertSampleQuery('comparison')}
|
||||
className="px-2 py-1 text-xs bg-primary/20 hover:bg-blue-200 rounded font-medium"
|
||||
>
|
||||
🔥 제품 비교
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery('regional')}
|
||||
className="px-2 py-1 text-xs bg-green-100 hover:bg-green-200 rounded font-medium"
|
||||
>
|
||||
🌍 지역별 비교
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery('sales')}
|
||||
className="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
매출 데이터
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery('users')}
|
||||
className="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
사용자 추이
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery('products')}
|
||||
className="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
상품 판매량
|
||||
</button>
|
||||
</div>
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label className="text-sm text-gray-600">샘플 쿼리:</Label>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("comparison")}>
|
||||
<Code className="mr-2 h-3 w-3" />
|
||||
제품 비교
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("regional")}>
|
||||
<Code className="mr-2 h-3 w-3" />
|
||||
지역별 비교
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("sales")}>
|
||||
매출 데이터
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("users")}>
|
||||
사용자 추이
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("products")}>
|
||||
상품 판매량
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* SQL 쿼리 입력 영역 */}
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="SELECT * FROM your_table WHERE condition = 'value';"
|
||||
className="
|
||||
w-full h-40 p-3 border border-gray-300 rounded-lg
|
||||
font-mono text-sm resize-none
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
"
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 text-xs text-gray-400">
|
||||
Ctrl+Enter로 실행
|
||||
<div className="space-y-2">
|
||||
<Label>SQL 쿼리</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="SELECT * FROM your_table WHERE condition = 'value';"
|
||||
className="h-40 resize-none font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 새로고침 간격 설정 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-muted-foreground">자동 새로고침:</label>
|
||||
<select
|
||||
value={dataSource?.refreshInterval || 30000}
|
||||
onChange={(e) => onDataSourceChange({
|
||||
...dataSource,
|
||||
type: 'database',
|
||||
query,
|
||||
refreshInterval: parseInt(e.target.value)
|
||||
})}
|
||||
className="px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
<Label className="text-sm">자동 새로고침:</Label>
|
||||
<Select
|
||||
value={String(dataSource?.refreshInterval ?? 0)}
|
||||
onValueChange={(value) =>
|
||||
onDataSourceChange({
|
||||
...dataSource,
|
||||
type: "database",
|
||||
query,
|
||||
refreshInterval: parseInt(value),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value={0}>수동</option>
|
||||
<option value={10000}>10초</option>
|
||||
<option value={30000}>30초</option>
|
||||
<option value={60000}>1분</option>
|
||||
<option value={300000}>5분</option>
|
||||
<option value={600000}>10분</option>
|
||||
</select>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">수동</SelectItem>
|
||||
<SelectItem value="10000">10초</SelectItem>
|
||||
<SelectItem value="30000">30초</SelectItem>
|
||||
<SelectItem value="60000">1분</SelectItem>
|
||||
<SelectItem value="300000">5분</SelectItem>
|
||||
<SelectItem value="600000">10분</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<div className="text-red-800 text-sm font-medium">❌ 오류</div>
|
||||
<div className="text-red-700 text-sm mt-1">{error}</div>
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<div className="text-sm font-medium">오류</div>
|
||||
<div className="mt-1 text-sm">{error}</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 쿼리 결과 미리보기 */}
|
||||
{queryResult && (
|
||||
<div className="border border-gray-200 rounded-lg">
|
||||
<div className="bg-gray-50 px-3 py-2 border-b border-gray-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
📊 쿼리 결과 ({queryResult.rows.length}행)
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
실행 시간: {queryResult.executionTime}ms
|
||||
</span>
|
||||
<Card>
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">쿼리 결과</span>
|
||||
<Badge variant="secondary">{queryResult.rows.length}행</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">실행 시간: {queryResult.executionTime}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 max-h-60 overflow-auto">
|
||||
|
||||
<div className="p-3">
|
||||
{queryResult.rows.length > 0 ? (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
{queryResult.columns.map((col, idx) => (
|
||||
<th key={idx} className="text-left py-1 px-2 font-medium text-gray-700">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{queryResult.rows.slice(0, 10).map((row, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100">
|
||||
{queryResult.columns.map((col, colIdx) => (
|
||||
<td key={colIdx} className="py-1 px-2 text-muted-foreground">
|
||||
{String(row[col] ?? '')}
|
||||
</td>
|
||||
<div className="max-h-60 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{queryResult.columns.map((col, idx) => (
|
||||
<TableHead key={idx}>{col}</TableHead>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{queryResult.rows.slice(0, 10).map((row, idx) => (
|
||||
<TableRow key={idx}>
|
||||
{queryResult.columns.map((col, colIdx) => (
|
||||
<TableCell key={colIdx}>{String(row[col] ?? "")}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{queryResult.rows.length > 10 && (
|
||||
<div className="mt-3 text-center text-xs text-gray-500">
|
||||
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500 py-4">
|
||||
결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{queryResult.rows.length > 10 && (
|
||||
<div className="text-center text-xs text-gray-500 mt-2">
|
||||
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
|
||||
</div>
|
||||
<div className="py-8 text-center text-gray-500">결과가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 키보드 단축키 안내 */}
|
||||
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
|
||||
💡 <strong>단축키:</strong> Ctrl+Enter (쿼리 실행), Ctrl+/ (주석 토글)
|
||||
</div>
|
||||
<Card className="p-3">
|
||||
<div className="text-xs text-gray-600">
|
||||
<strong>단축키:</strong> Ctrl+Enter (쿼리 실행), Ctrl+/ (주석 토글)
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Ctrl+Enter로 쿼리 실행
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
executeQuery();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [executeQuery]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -332,18 +331,22 @@ ORDER BY Q4 DESC;`
|
|||
function generateSampleQueryResult(query: string): QueryResult {
|
||||
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
|
||||
// 디버깅용 로그
|
||||
// console.log('generateSampleQueryResult called with query:', query.substring(0, 100));
|
||||
|
||||
|
||||
// 가장 구체적인 조건부터 먼저 체크 (순서 중요!)
|
||||
const isComparison = queryLower.includes('galaxy') || queryLower.includes('갤럭시') || queryLower.includes('아이폰') || queryLower.includes('iphone');
|
||||
const isRegional = queryLower.includes('region') || queryLower.includes('지역');
|
||||
const isMonthly = queryLower.includes('month');
|
||||
const isSales = queryLower.includes('sales') || queryLower.includes('매출');
|
||||
const isUsers = queryLower.includes('users') || queryLower.includes('사용자');
|
||||
const isProducts = queryLower.includes('product') || queryLower.includes('상품');
|
||||
const isWeekly = queryLower.includes('week');
|
||||
const isComparison =
|
||||
queryLower.includes("galaxy") ||
|
||||
queryLower.includes("갤럭시") ||
|
||||
queryLower.includes("아이폰") ||
|
||||
queryLower.includes("iphone");
|
||||
const isRegional = queryLower.includes("region") || queryLower.includes("지역");
|
||||
const isMonthly = queryLower.includes("month");
|
||||
const isSales = queryLower.includes("sales") || queryLower.includes("매출");
|
||||
const isUsers = queryLower.includes("users") || queryLower.includes("사용자");
|
||||
const isProducts = queryLower.includes("product") || queryLower.includes("상품");
|
||||
const isWeekly = queryLower.includes("week");
|
||||
|
||||
// console.log('Sample data type detection:', {
|
||||
// isComparison,
|
||||
|
|
@ -363,25 +366,25 @@ function generateSampleQueryResult(query: string): QueryResult {
|
|||
if (isComparison) {
|
||||
// console.log('✅ Using COMPARISON data');
|
||||
// 제품 비교 데이터 (다중 시리즈)
|
||||
columns = ['month', 'galaxy_sales', 'iphone_sales', 'other_sales'];
|
||||
columns = ["month", "galaxy_sales", "iphone_sales", "other_sales"];
|
||||
rows = [
|
||||
{ month: '2024-01', galaxy_sales: 450000, iphone_sales: 620000, other_sales: 130000 },
|
||||
{ month: '2024-02', galaxy_sales: 520000, iphone_sales: 680000, other_sales: 150000 },
|
||||
{ month: '2024-03', galaxy_sales: 480000, iphone_sales: 590000, other_sales: 110000 },
|
||||
{ month: '2024-04', galaxy_sales: 610000, iphone_sales: 650000, other_sales: 160000 },
|
||||
{ month: '2024-05', galaxy_sales: 720000, iphone_sales: 780000, other_sales: 180000 },
|
||||
{ month: '2024-06', galaxy_sales: 680000, iphone_sales: 690000, other_sales: 170000 },
|
||||
{ month: '2024-07', galaxy_sales: 750000, iphone_sales: 800000, other_sales: 170000 },
|
||||
{ month: '2024-08', galaxy_sales: 690000, iphone_sales: 720000, other_sales: 170000 },
|
||||
{ month: '2024-09', galaxy_sales: 730000, iphone_sales: 750000, other_sales: 170000 },
|
||||
{ month: '2024-10', galaxy_sales: 800000, iphone_sales: 810000, other_sales: 170000 },
|
||||
{ month: '2024-11', galaxy_sales: 870000, iphone_sales: 880000, other_sales: 170000 },
|
||||
{ month: '2024-12', galaxy_sales: 950000, iphone_sales: 990000, other_sales: 160000 },
|
||||
{ month: "2024-01", galaxy_sales: 450000, iphone_sales: 620000, other_sales: 130000 },
|
||||
{ month: "2024-02", galaxy_sales: 520000, iphone_sales: 680000, other_sales: 150000 },
|
||||
{ month: "2024-03", galaxy_sales: 480000, iphone_sales: 590000, other_sales: 110000 },
|
||||
{ month: "2024-04", galaxy_sales: 610000, iphone_sales: 650000, other_sales: 160000 },
|
||||
{ month: "2024-05", galaxy_sales: 720000, iphone_sales: 780000, other_sales: 180000 },
|
||||
{ month: "2024-06", galaxy_sales: 680000, iphone_sales: 690000, other_sales: 170000 },
|
||||
{ month: "2024-07", galaxy_sales: 750000, iphone_sales: 800000, other_sales: 170000 },
|
||||
{ month: "2024-08", galaxy_sales: 690000, iphone_sales: 720000, other_sales: 170000 },
|
||||
{ month: "2024-09", galaxy_sales: 730000, iphone_sales: 750000, other_sales: 170000 },
|
||||
{ month: "2024-10", galaxy_sales: 800000, iphone_sales: 810000, other_sales: 170000 },
|
||||
{ month: "2024-11", galaxy_sales: 870000, iphone_sales: 880000, other_sales: 170000 },
|
||||
{ month: "2024-12", galaxy_sales: 950000, iphone_sales: 990000, other_sales: 160000 },
|
||||
];
|
||||
// COMPARISON 데이터를 반환하고 함수 종료
|
||||
// console.log('COMPARISON data generated:', {
|
||||
// columns,
|
||||
// rowCount: rows.length,
|
||||
// console.log('COMPARISON data generated:', {
|
||||
// columns,
|
||||
// rowCount: rows.length,
|
||||
// sampleRow: rows[0],
|
||||
// allRows: rows,
|
||||
// fieldTypes: {
|
||||
|
|
@ -402,81 +405,81 @@ function generateSampleQueryResult(query: string): QueryResult {
|
|||
} else if (isRegional) {
|
||||
// console.log('✅ Using REGIONAL data');
|
||||
// 지역별 분기별 매출
|
||||
columns = ['지역', 'Q1', 'Q2', 'Q3', 'Q4'];
|
||||
columns = ["지역", "Q1", "Q2", "Q3", "Q4"];
|
||||
rows = [
|
||||
{ 지역: '서울', Q1: 1200000, Q2: 1350000, Q3: 1420000, Q4: 1580000 },
|
||||
{ 지역: '경기', Q1: 980000, Q2: 1120000, Q3: 1180000, Q4: 1290000 },
|
||||
{ 지역: '부산', Q1: 650000, Q2: 720000, Q3: 780000, Q4: 850000 },
|
||||
{ 지역: '대구', Q1: 450000, Q2: 490000, Q3: 520000, Q4: 580000 },
|
||||
{ 지역: '인천', Q1: 520000, Q2: 580000, Q3: 620000, Q4: 690000 },
|
||||
{ 지역: '광주', Q1: 380000, Q2: 420000, Q3: 450000, Q4: 490000 },
|
||||
{ 지역: '대전', Q1: 410000, Q2: 460000, Q3: 490000, Q4: 530000 },
|
||||
{ 지역: "서울", Q1: 1200000, Q2: 1350000, Q3: 1420000, Q4: 1580000 },
|
||||
{ 지역: "경기", Q1: 980000, Q2: 1120000, Q3: 1180000, Q4: 1290000 },
|
||||
{ 지역: "부산", Q1: 650000, Q2: 720000, Q3: 780000, Q4: 850000 },
|
||||
{ 지역: "대구", Q1: 450000, Q2: 490000, Q3: 520000, Q4: 580000 },
|
||||
{ 지역: "인천", Q1: 520000, Q2: 580000, Q3: 620000, Q4: 690000 },
|
||||
{ 지역: "광주", Q1: 380000, Q2: 420000, Q3: 450000, Q4: 490000 },
|
||||
{ 지역: "대전", Q1: 410000, Q2: 460000, Q3: 490000, Q4: 530000 },
|
||||
];
|
||||
} else if (isWeekly && isUsers) {
|
||||
// console.log('✅ Using USERS data');
|
||||
// 사용자 가입 추이
|
||||
columns = ['week', 'new_users'];
|
||||
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 },
|
||||
{ week: '2024-W16', new_users: 45 },
|
||||
{ week: '2024-W17', new_users: 52 },
|
||||
{ week: '2024-W18', new_users: 48 },
|
||||
{ week: '2024-W19', new_users: 55 },
|
||||
{ week: '2024-W20', new_users: 61 },
|
||||
{ week: '2024-W21', new_users: 58 },
|
||||
{ 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 },
|
||||
{ week: "2024-W16", new_users: 45 },
|
||||
{ week: "2024-W17", new_users: 52 },
|
||||
{ week: "2024-W18", new_users: 48 },
|
||||
{ week: "2024-W19", new_users: 55 },
|
||||
{ week: "2024-W20", new_users: 61 },
|
||||
{ week: "2024-W21", new_users: 58 },
|
||||
];
|
||||
} else if (isProducts && !isComparison) {
|
||||
// console.log('✅ Using PRODUCTS data');
|
||||
// 상품별 판매량
|
||||
columns = ['product_name', 'total_sold', 'revenue'];
|
||||
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 },
|
||||
{ product_name: '키보드', total_sold: 78, revenue: 15600000 },
|
||||
{ product_name: '마우스', total_sold: 145, revenue: 8700000 },
|
||||
{ product_name: '모니터', total_sold: 67, revenue: 134000000 },
|
||||
{ product_name: '프린터', total_sold: 34, revenue: 17000000 },
|
||||
{ product_name: '웹캠', total_sold: 89, revenue: 8900000 },
|
||||
{ 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 },
|
||||
{ product_name: "키보드", total_sold: 78, revenue: 15600000 },
|
||||
{ product_name: "마우스", total_sold: 145, revenue: 8700000 },
|
||||
{ product_name: "모니터", total_sold: 67, revenue: 134000000 },
|
||||
{ product_name: "프린터", total_sold: 34, revenue: 17000000 },
|
||||
{ product_name: "웹캠", total_sold: 89, revenue: 8900000 },
|
||||
];
|
||||
} else if (isMonthly && isSales && !isComparison) {
|
||||
// console.log('✅ Using MONTHLY SALES data');
|
||||
// 월별 매출 데이터
|
||||
columns = ['month', 'sales', 'order_count'];
|
||||
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 },
|
||||
{ month: '2024-07', sales: 1720000, order_count: 71 },
|
||||
{ month: '2024-08', sales: 1580000, order_count: 63 },
|
||||
{ month: '2024-09', sales: 1650000, order_count: 68 },
|
||||
{ month: '2024-10', sales: 1780000, order_count: 75 },
|
||||
{ month: '2024-11', sales: 1920000, order_count: 82 },
|
||||
{ month: '2024-12', sales: 2100000, order_count: 89 },
|
||||
{ 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 },
|
||||
{ month: "2024-07", sales: 1720000, order_count: 71 },
|
||||
{ month: "2024-08", sales: 1580000, order_count: 63 },
|
||||
{ month: "2024-09", sales: 1650000, order_count: 68 },
|
||||
{ month: "2024-10", sales: 1780000, order_count: 75 },
|
||||
{ month: "2024-11", sales: 1920000, order_count: 82 },
|
||||
{ month: "2024-12", sales: 2100000, order_count: 89 },
|
||||
];
|
||||
} else {
|
||||
// console.log('⚠️ Using DEFAULT data');
|
||||
// 기본 샘플 데이터
|
||||
columns = ['category', 'value', 'count'];
|
||||
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 },
|
||||
{ category: 'F', value: 200, count: 20 },
|
||||
{ category: 'G', value: 110, count: 11 },
|
||||
{ category: 'H', value: 160, count: 16 },
|
||||
{ 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 },
|
||||
{ category: "F", value: 200, count: 20 },
|
||||
{ category: "G", value: 110, count: 11 },
|
||||
{ category: "H", value: 160, count: 16 },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { ChartConfig, QueryResult } from './types';
|
||||
|
||||
interface VehicleMapConfigPanelProps {
|
||||
config?: ChartConfig;
|
||||
queryResult?: QueryResult;
|
||||
onConfigChange: (config: ChartConfig) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 차량 위치 지도 설정 패널
|
||||
* - 위도/경도 컬럼 매핑
|
||||
* - 라벨/상태 컬럼 설정
|
||||
*/
|
||||
export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: VehicleMapConfigPanelProps) {
|
||||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
||||
|
||||
// 설정 업데이트
|
||||
const updateConfig = useCallback((updates: Partial<ChartConfig>) => {
|
||||
const newConfig = { ...currentConfig, ...updates };
|
||||
setCurrentConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}, [currentConfig, onConfigChange]);
|
||||
|
||||
// 사용 가능한 컬럼 목록
|
||||
const availableColumns = queryResult?.columns || [];
|
||||
const sampleData = queryResult?.rows?.[0] || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-gray-800">🗺️ 지도 설정</h4>
|
||||
|
||||
{/* 쿼리 결과가 없을 때 */}
|
||||
{!queryResult && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-yellow-800 text-sm">
|
||||
💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 지도를 설정할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 필드 매핑 */}
|
||||
{queryResult && (
|
||||
<>
|
||||
{/* 지도 제목 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">지도 제목</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentConfig.title || ''}
|
||||
onChange={(e) => updateConfig({ title: e.target.value })}
|
||||
placeholder="차량 위치 지도"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 위도 컬럼 설정 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
위도 컬럼 (Latitude)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.latitudeColumn || ''}
|
||||
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 경도 컬럼 설정 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
경도 컬럼 (Longitude)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.longitudeColumn || ''}
|
||||
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 라벨 컬럼 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
라벨 컬럼 (마커 표시명)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.labelColumn || ''}
|
||||
onChange={(e) => updateConfig({ labelColumn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 상태 컬럼 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
상태 컬럼 (마커 색상)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.statusColumn || ''}
|
||||
onChange={(e) => updateConfig({ statusColumn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 설정 미리보기 */}
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div><strong>위도:</strong> {currentConfig.latitudeColumn || '미설정'}</div>
|
||||
<div><strong>경도:</strong> {currentConfig.longitudeColumn || '미설정'}</div>
|
||||
<div><strong>라벨:</strong> {currentConfig.labelColumn || '없음'}</div>
|
||||
<div><strong>상태:</strong> {currentConfig.statusColumn || '없음'}</div>
|
||||
<div><strong>데이터 개수:</strong> {queryResult.rows.length}개</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필수 필드 확인 */}
|
||||
{(!currentConfig.latitudeColumn || !currentConfig.longitudeColumn) && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="text-red-800 text-sm">
|
||||
⚠️ 위도와 경도 컬럼을 반드시 선택해야 지도에 표시할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface AreaChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 영역 차트 컴포넌트
|
||||
*/
|
||||
export function AreaChart({ data, config, width = 600, height = 400 }: AreaChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// X축 스케일
|
||||
const xScale = d3.scalePoint().domain(data.labels).range([0, chartWidth]).padding(0.5);
|
||||
|
||||
// Y축 스케일
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 영역 생성기
|
||||
const areaGenerator = d3
|
||||
.area<number>()
|
||||
.x((_, i) => xScale(data.labels[i]) || 0)
|
||||
.y0(chartHeight)
|
||||
.y1((d) => yScale(d));
|
||||
|
||||
// 선 생성기
|
||||
const lineGenerator = d3
|
||||
.line<number>()
|
||||
.x((_, i) => xScale(data.labels[i]) || 0)
|
||||
.y((d) => yScale(d));
|
||||
|
||||
// 부드러운 곡선 적용
|
||||
if (config.lineStyle === "smooth") {
|
||||
areaGenerator.curve(d3.curveMonotoneX);
|
||||
lineGenerator.curve(d3.curveMonotoneX);
|
||||
}
|
||||
|
||||
// 각 데이터셋에 대해 영역 그리기
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const color = dataset.color || colors[i % colors.length];
|
||||
const opacity = config.areaOpacity !== undefined ? config.areaOpacity : 0.3;
|
||||
|
||||
// 영역 그리기
|
||||
const area = g.append("path").datum(dataset.data).attr("fill", color).attr("opacity", 0).attr("d", areaGenerator);
|
||||
|
||||
// 경계선 그리기
|
||||
const line = g
|
||||
.append("path")
|
||||
.datum(dataset.data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", color)
|
||||
.attr("stroke-width", 2.5)
|
||||
.attr("d", lineGenerator);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
area
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("opacity", opacity);
|
||||
|
||||
const totalLength = line.node()?.getTotalLength() || 0;
|
||||
line
|
||||
.attr("stroke-dasharray", `${totalLength} ${totalLength}`)
|
||||
.attr("stroke-dashoffset", totalLength)
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("stroke-dashoffset", 0);
|
||||
} else {
|
||||
area.attr("opacity", opacity);
|
||||
}
|
||||
|
||||
// 데이터 포인트 (점) 그리기
|
||||
const circles = g
|
||||
.selectAll(`.circle-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("class", `circle-${i}`)
|
||||
.attr("cx", (_, j) => xScale(data.labels[j]) || 0)
|
||||
.attr("cy", (d) => yScale(d))
|
||||
.attr("r", 0)
|
||||
.attr("fill", color)
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
circles
|
||||
.transition()
|
||||
.delay((_, j) => j * 50)
|
||||
.duration(300)
|
||||
.attr("r", 4);
|
||||
} else {
|
||||
circles.attr("r", 4);
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
circles
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("r", 6);
|
||||
|
||||
const [x, y] = d3.pointer(event, g.node());
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${x},${y - 10})`);
|
||||
|
||||
tooltip
|
||||
.append("rect")
|
||||
.attr("x", -40)
|
||||
.attr("y", -30)
|
||||
.attr("width", 80)
|
||||
.attr("height", 25)
|
||||
.attr("fill", "rgba(0,0,0,0.8)")
|
||||
.attr("rx", 4);
|
||||
|
||||
tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.text(`${dataset.label}: ${d}`);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("r", 4);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 1) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("opacity", config.areaOpacity !== undefined ? config.areaOpacity : 0.3)
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { ChartConfig } from '../types';
|
||||
|
||||
interface AreaChartComponentProps {
|
||||
data: any[];
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영역 차트 컴포넌트
|
||||
* - Recharts AreaChart 사용
|
||||
* - 추세 파악에 적합
|
||||
* - 다중 시리즈 지원
|
||||
*/
|
||||
export function AreaChartComponent({ data, config, width = 250, height = 200 }: AreaChartComponentProps) {
|
||||
const {
|
||||
xAxis = 'x',
|
||||
yAxis = 'y',
|
||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'],
|
||||
title,
|
||||
showLegend = true
|
||||
} = config;
|
||||
|
||||
// Y축 필드들 (단일 또는 다중)
|
||||
const yFields = Array.isArray(yAxis) ? yAxis : [yAxis];
|
||||
const yKeys = yFields.filter(field => field && field !== 'y');
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
{yKeys.map((key, index) => (
|
||||
<linearGradient key={key} id={`color${index}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={colors[index % colors.length]} stopOpacity={0.8}/>
|
||||
<stop offset="95%" stopColor={colors[index % colors.length]} stopOpacity={0.1}/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey={xAxis}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#666"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#666"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
formatter={(value: any, name: string) => [
|
||||
typeof value === 'number' ? value.toLocaleString() : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
{showLegend && yKeys.length > 1 && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{yKeys.map((key, index) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={colors[index % colors.length]}
|
||||
fill={`url(#color${index})`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface BarChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 막대 차트 컴포넌트
|
||||
*/
|
||||
export function BarChart({ data, config, width = 600, height = 400 }: BarChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// X축 스케일 (카테고리)
|
||||
const xScale = d3.scaleBand().domain(data.labels).range([0, chartWidth]).padding(0.2);
|
||||
|
||||
// Y축 스케일 (값)
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 막대 그리기
|
||||
const barWidth = xScale.bandwidth() / data.datasets.length;
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const bars = g
|
||||
.selectAll(`.bar-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("class", `bar-${i}`)
|
||||
.attr("x", (_, j) => (xScale(data.labels[j]) || 0) + barWidth * i)
|
||||
.attr("y", chartHeight)
|
||||
.attr("width", barWidth)
|
||||
.attr("height", 0)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 4);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
bars
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("y", (d) => yScale(d))
|
||||
.attr("height", (d) => chartHeight - yScale(d));
|
||||
} else {
|
||||
bars.attr("y", (d) => yScale(d)).attr("height", (d) => chartHeight - yScale(d));
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
bars
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("opacity", 0.7);
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${dataset.label}: ${d}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("opacity", 1);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 1) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
|
||||
interface BarChartComponentProps {
|
||||
data: any[];
|
||||
config: any;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 바 차트 컴포넌트 (Recharts SimpleBarChart 기반)
|
||||
* - 실제 데이터를 받아서 단순하게 표시
|
||||
* - 복잡한 변환 로직 없음
|
||||
*/
|
||||
export function BarChartComponent({ data, config, width = 600, height = 300 }: BarChartComponentProps) {
|
||||
// console.log('🎨 BarChartComponent - 전체 데이터:', {
|
||||
// dataLength: data?.length,
|
||||
// fullData: data,
|
||||
// dataType: typeof data,
|
||||
// isArray: Array.isArray(data),
|
||||
// config,
|
||||
// xAxisField: config?.xAxis,
|
||||
// yAxisFields: config?.yAxis
|
||||
// });
|
||||
|
||||
// 데이터가 없으면 메시지 표시
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">📊</div>
|
||||
<div>데이터가 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터의 첫 번째 아이템에서 사용 가능한 키 확인
|
||||
const firstItem = data[0];
|
||||
const availableKeys = Object.keys(firstItem);
|
||||
// console.log('📊 사용 가능한 데이터 키:', availableKeys);
|
||||
// console.log('📊 첫 번째 데이터 아이템:', firstItem);
|
||||
|
||||
// Y축 필드 추출 (배열이면 모두 사용, 아니면 단일 값)
|
||||
const yFields = Array.isArray(config.yAxis) ? config.yAxis : [config.yAxis];
|
||||
|
||||
// 색상 배열
|
||||
const colors = ['#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1'];
|
||||
|
||||
// 한글 레이블 매핑
|
||||
const labelMapping: Record<string, string> = {
|
||||
'total_users': '전체 사용자',
|
||||
'active_users': '활성 사용자',
|
||||
'name': '부서'
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey={config.xAxis}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
{config.showLegend !== false && <Legend />}
|
||||
|
||||
{/* Y축 필드마다 Bar 생성 */}
|
||||
{yFields.map((field: string, index: number) => (
|
||||
<Bar
|
||||
key={field}
|
||||
dataKey={field}
|
||||
fill={colors[index % colors.length]}
|
||||
name={labelMapping[field] || field}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { BarChart } from "./BarChart";
|
||||
import { HorizontalBarChart } from "./HorizontalBarChart";
|
||||
import { LineChart } from "./LineChart";
|
||||
import { AreaChart } from "./AreaChart";
|
||||
import { PieChart } from "./PieChart";
|
||||
import { StackedBarChart } from "./StackedBarChart";
|
||||
import { ComboChart } from "./ComboChart";
|
||||
import { ChartConfig, ChartData, ElementSubtype } from "../types";
|
||||
|
||||
interface ChartProps {
|
||||
chartType: ElementSubtype;
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 차트 컴포넌트
|
||||
* - 차트 타입에 따라 적절한 D3 차트 컴포넌트를 렌더링
|
||||
*/
|
||||
export function Chart({ chartType, data, config, width, height }: ChartProps) {
|
||||
// 데이터가 없으면 placeholder 표시
|
||||
if (!data || !data.labels.length || !data.datasets.length) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50"
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📊</div>
|
||||
<div className="text-sm font-medium text-gray-600">데이터를 설정하세요</div>
|
||||
<div className="mt-1 text-xs text-gray-500">차트 설정에서 데이터 소스와 축을 설정하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 차트 타입에 따라 렌더링
|
||||
switch (chartType) {
|
||||
case "bar":
|
||||
return <BarChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "horizontal-bar":
|
||||
return <HorizontalBarChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "line":
|
||||
return <LineChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "area":
|
||||
return <AreaChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "pie":
|
||||
return <PieChart data={data} config={config} width={width} height={height} isDonut={false} />;
|
||||
|
||||
case "donut":
|
||||
return <PieChart data={data} config={config} width={width} height={height} isDonut={true} />;
|
||||
|
||||
case "stacked-bar":
|
||||
return <StackedBarChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
case "combo":
|
||||
return <ComboChart data={data} config={config} width={width} height={height} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50"
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">❓</div>
|
||||
<div className="text-sm font-medium text-gray-600">지원하지 않는 차트 타입</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{chartType}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { DashboardElement, QueryResult } from '../types';
|
||||
import { BarChartComponent } from './BarChartComponent';
|
||||
import { PieChartComponent } from './PieChartComponent';
|
||||
import { LineChartComponent } from './LineChartComponent';
|
||||
import { AreaChartComponent } from './AreaChartComponent';
|
||||
import { StackedBarChartComponent } from './StackedBarChartComponent';
|
||||
import { DonutChartComponent } from './DonutChartComponent';
|
||||
import { ComboChartComponent } from './ComboChartComponent';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { DashboardElement, QueryResult, ChartData } from "../types";
|
||||
import { Chart } from "./Chart";
|
||||
import { transformQueryResultToChartData } from "../utils/chartDataTransform";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
|
||||
interface ChartRendererProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -18,85 +15,207 @@ interface ChartRendererProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* 차트 렌더러 컴포넌트 (단순 버전)
|
||||
* - 데이터를 받아서 차트에 그대로 전달
|
||||
* - 복잡한 변환 로직 제거
|
||||
* 차트 렌더러 컴포넌트 (D3 기반)
|
||||
* - 데이터 소스에서 데이터 페칭
|
||||
* - QueryResult를 ChartData로 변환
|
||||
* - D3 Chart 컴포넌트에 전달
|
||||
*/
|
||||
export function ChartRenderer({ element, data, width = 250, height = 200 }: ChartRendererProps) {
|
||||
// console.log('🎬 ChartRenderer:', {
|
||||
// elementId: element.id,
|
||||
// hasData: !!data,
|
||||
// dataRows: data?.rows?.length,
|
||||
// xAxis: element.chartConfig?.xAxis,
|
||||
// yAxis: element.chartConfig?.yAxis
|
||||
// });
|
||||
const [chartData, setChartData] = useState<ChartData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 데이터나 설정이 없으면 메시지 표시
|
||||
if (!data || !element.chartConfig?.xAxis || !element.chartConfig?.yAxis) {
|
||||
// 데이터 페칭
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
// 이미 data가 전달된 경우 사용
|
||||
if (data) {
|
||||
const transformed = transformQueryResultToChartData(data, element.chartConfig || {});
|
||||
setChartData(transformed);
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 소스가 설정되어 있으면 페칭
|
||||
if (element.dataSource && element.chartConfig) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let queryResult: QueryResult;
|
||||
|
||||
// REST API vs Database 분기
|
||||
if (element.dataSource.type === "api" && element.dataSource.endpoint) {
|
||||
// REST API - 백엔드 프록시를 통한 호출 (CORS 우회)
|
||||
const params = new URLSearchParams();
|
||||
if (element.dataSource.queryParams) {
|
||||
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: element.dataSource.endpoint,
|
||||
method: "GET",
|
||||
headers: element.dataSource.headers || {},
|
||||
queryParams: Object.fromEntries(params),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
const apiData = result.data;
|
||||
|
||||
// JSON Path 처리
|
||||
let processedData = apiData;
|
||||
if (element.dataSource.jsonPath) {
|
||||
const paths = element.dataSource.jsonPath.split(".");
|
||||
for (const path of paths) {
|
||||
if (processedData && typeof processedData === "object" && path in processedData) {
|
||||
processedData = processedData[path];
|
||||
} else {
|
||||
throw new Error(`JSON Path "${element.dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(processedData) ? processedData : [processedData];
|
||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||
|
||||
queryResult = {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else if (element.dataSource.query) {
|
||||
// Database (현재 DB 또는 외부 DB)
|
||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const result = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(element.dataSource.externalConnectionId),
|
||||
element.dataSource.query,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
|
||||
queryResult = {
|
||||
columns: result.data?.[0] ? Object.keys(result.data[0]) : [],
|
||||
rows: result.data || [],
|
||||
totalRows: result.data?.length || 0,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else {
|
||||
// 현재 DB
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
queryResult = {
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
totalRows: result.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw new Error("데이터 소스가 올바르게 설정되지 않았습니다");
|
||||
}
|
||||
|
||||
// ChartData로 변환
|
||||
const transformed = transformQueryResultToChartData(queryResult, element.chartConfig);
|
||||
setChartData(transformed);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "데이터 로딩 실패";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
// 자동 새로고침 설정 (0이면 수동이므로 interval 설정 안 함)
|
||||
const refreshInterval = element.dataSource?.refreshInterval;
|
||||
if (refreshInterval && refreshInterval > 0) {
|
||||
const interval = setInterval(fetchData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [
|
||||
element.dataSource?.query,
|
||||
element.dataSource?.connectionType,
|
||||
element.dataSource?.externalConnectionId,
|
||||
element.dataSource?.refreshInterval,
|
||||
element.chartConfig,
|
||||
data,
|
||||
]);
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm">
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">📊</div>
|
||||
<div>데이터를 설정해주세요</div>
|
||||
<div className="text-xs mt-1">⚙️ 버튼을 클릭하여 설정</div>
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
<div className="text-sm">데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터가 비어있으면
|
||||
if (!data.rows || data.rows.length === 0) {
|
||||
// 에러
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-red-500 text-sm">
|
||||
<div className="flex h-full w-full items-center justify-center text-red-500">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">⚠️</div>
|
||||
<div>데이터가 없습니다</div>
|
||||
<div className="mb-2 text-2xl">⚠️</div>
|
||||
<div className="text-sm font-medium">오류 발생</div>
|
||||
<div className="mt-1 text-xs">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터를 그대로 전달 (변환 없음!)
|
||||
const chartData = data.rows;
|
||||
// 데이터나 설정이 없으면
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
const isApiSource = element.dataSource?.type === "api";
|
||||
const needsYAxis = !(isPieChart || isApiSource) || (!element.chartConfig?.aggregation && !element.chartConfig?.yAxis);
|
||||
|
||||
// console.log('📊 Chart Data:', {
|
||||
// dataLength: chartData.length,
|
||||
// firstRow: chartData[0],
|
||||
// columns: Object.keys(chartData[0] || {})
|
||||
// });
|
||||
|
||||
// 차트 공통 props
|
||||
const chartProps = {
|
||||
data: chartData,
|
||||
config: element.chartConfig,
|
||||
width: width - 20,
|
||||
height: height - 60,
|
||||
};
|
||||
|
||||
// 차트 타입에 따른 렌더링
|
||||
switch (element.subtype) {
|
||||
case 'bar':
|
||||
return <BarChartComponent {...chartProps} />;
|
||||
case 'pie':
|
||||
return <PieChartComponent {...chartProps} />;
|
||||
case 'line':
|
||||
return <LineChartComponent {...chartProps} />;
|
||||
case 'area':
|
||||
return <AreaChartComponent {...chartProps} />;
|
||||
case 'stacked-bar':
|
||||
return <StackedBarChartComponent {...chartProps} />;
|
||||
case 'donut':
|
||||
return <DonutChartComponent {...chartProps} />;
|
||||
case 'combo':
|
||||
return <ComboChartComponent {...chartProps} />;
|
||||
default:
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">❓</div>
|
||||
<div>지원하지 않는 차트 타입</div>
|
||||
</div>
|
||||
if (!chartData || !element.chartConfig?.xAxis || (needsYAxis && !element.chartConfig?.yAxis)) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-2xl">📊</div>
|
||||
<div className="text-sm">데이터를 설정해주세요</div>
|
||||
<div className="mt-1 text-xs">⚙️ 버튼을 클릭하여 설정</div>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// D3 차트 렌더링
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-white p-2">
|
||||
<Chart
|
||||
chartType={element.subtype}
|
||||
data={chartData}
|
||||
config={element.chartConfig}
|
||||
width={width - 20}
|
||||
height={height - 20}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,323 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface ComboChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 콤보 차트 컴포넌트 (막대 + 선)
|
||||
* - 첫 번째 데이터셋: 막대 차트
|
||||
* - 나머지 데이터셋: 선 차트
|
||||
*/
|
||||
export function ComboChart({ data, config, width = 600, height = 400 }: ComboChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// X축 스케일 (카테고리)
|
||||
const xScale = d3.scaleBand().domain(data.labels).range([0, chartWidth]).padding(0.2);
|
||||
|
||||
// Y축 스케일 (값)
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 첫 번째 데이터셋: 막대 차트
|
||||
if (data.datasets.length > 0) {
|
||||
const barDataset = data.datasets[0];
|
||||
const bars = g
|
||||
.selectAll(".bar")
|
||||
.data(barDataset.data)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("class", "bar")
|
||||
.attr("x", (_, j) => xScale(data.labels[j]) || 0)
|
||||
.attr("y", chartHeight)
|
||||
.attr("width", xScale.bandwidth())
|
||||
.attr("height", 0)
|
||||
.attr("fill", barDataset.color || colors[0])
|
||||
.attr("rx", 4);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
bars
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("y", (d) => yScale(d))
|
||||
.attr("height", (d) => chartHeight - yScale(d));
|
||||
} else {
|
||||
bars.attr("y", (d) => yScale(d)).attr("height", (d) => chartHeight - yScale(d));
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
bars
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("opacity", 0.7);
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${barDataset.label}: ${d}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("opacity", 1);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 나머지 데이터셋: 선 차트
|
||||
for (let i = 1; i < data.datasets.length; i++) {
|
||||
const dataset = data.datasets[i];
|
||||
const lineColor = dataset.color || colors[i % colors.length];
|
||||
|
||||
// 라인 생성기
|
||||
const line = d3
|
||||
.line<number>()
|
||||
.x((_, j) => (xScale(data.labels[j]) || 0) + xScale.bandwidth() / 2)
|
||||
.y((d) => yScale(d))
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
// 라인 그리기
|
||||
const path = g
|
||||
.append("path")
|
||||
.datum(dataset.data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", lineColor)
|
||||
.attr("stroke-width", 2)
|
||||
.attr("d", line);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
const totalLength = path.node()?.getTotalLength() || 0;
|
||||
path
|
||||
.attr("stroke-dasharray", `${totalLength} ${totalLength}`)
|
||||
.attr("stroke-dashoffset", totalLength)
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("stroke-dashoffset", 0);
|
||||
}
|
||||
|
||||
// 포인트 그리기
|
||||
const circles = g
|
||||
.selectAll(`.point-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("class", `point-${i}`)
|
||||
.attr("cx", (_, j) => (xScale(data.labels[j]) || 0) + xScale.bandwidth() / 2)
|
||||
.attr("cy", (d) => yScale(d))
|
||||
.attr("r", 0)
|
||||
.attr("fill", lineColor)
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
circles
|
||||
.transition()
|
||||
.delay((_, j) => j * 50)
|
||||
.duration(300)
|
||||
.attr("r", 4);
|
||||
} else {
|
||||
circles.attr("r", 4);
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
circles
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("r", 6);
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${dataset.label}: ${d}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("r", 4);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 0) {
|
||||
const legend = svg.append("g").attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
// 범례 아이콘 (첫 번째는 사각형, 나머지는 라인)
|
||||
if (i === 0) {
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
} else {
|
||||
legendRow
|
||||
.append("line")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", 7)
|
||||
.attr("x2", 15)
|
||||
.attr("y2", 7)
|
||||
.attr("stroke", dataset.color || colors[i % colors.length])
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
legendRow
|
||||
.append("circle")
|
||||
.attr("cx", 7.5)
|
||||
.attr("cy", 7)
|
||||
.attr("r", 3)
|
||||
.attr("fill", dataset.color || colors[i % colors.length]);
|
||||
}
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "11px")
|
||||
.style("fill", "#666")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} />;
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { ChartConfig } from '../types';
|
||||
|
||||
interface DonutChartComponentProps {
|
||||
data: any[];
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도넛 차트 컴포넌트
|
||||
* - Recharts PieChart (innerRadius 사용) 사용
|
||||
* - 비율 표시에 적합 (중앙 공간 활용 가능)
|
||||
*/
|
||||
export function DonutChartComponent({ data, config, width = 250, height = 200 }: DonutChartComponentProps) {
|
||||
const {
|
||||
xAxis = 'x',
|
||||
yAxis = 'y',
|
||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'],
|
||||
title,
|
||||
showLegend = true
|
||||
} = config;
|
||||
|
||||
// 파이 차트용 데이터 변환
|
||||
const pieData = data.map(item => ({
|
||||
name: String(item[xAxis] || ''),
|
||||
value: typeof item[yAxis as string] === 'number' ? item[yAxis as string] : 0
|
||||
}));
|
||||
|
||||
// 총합 계산
|
||||
const total = pieData.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
// 커스텀 라벨 (퍼센트 표시)
|
||||
const renderLabel = (entry: any) => {
|
||||
const percent = ((entry.value / total) * 100).toFixed(1);
|
||||
return `${percent}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2 flex flex-col">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderLabel}
|
||||
outerRadius={80}
|
||||
innerRadius={50}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
formatter={(value: any) => [
|
||||
typeof value === 'number' ? value.toLocaleString() : value,
|
||||
'값'
|
||||
]}
|
||||
/>
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
layout="vertical"
|
||||
align="right"
|
||||
verticalAlign="middle"
|
||||
/>
|
||||
)}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* 중앙 총합 표시 */}
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500">Total</div>
|
||||
<div className="text-sm font-bold text-gray-800">
|
||||
{total.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface HorizontalBarChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 수평 막대 차트 컴포넌트
|
||||
*/
|
||||
export function HorizontalBarChart({ data, config, width = 600, height = 400 }: HorizontalBarChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 120 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// Y축 스케일 (카테고리) - 수평이므로 Y축이 카테고리
|
||||
const yScale = d3.scaleBand().domain(data.labels).range([0, chartHeight]).padding(0.2);
|
||||
|
||||
// X축 스케일 (값) - 수평이므로 X축이 값
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const xScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([0, chartWidth])
|
||||
.nice();
|
||||
|
||||
// Y축 그리기 (카테고리)
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px").selectAll("text").style("text-anchor", "end");
|
||||
|
||||
// X축 그리기 (값)
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisBottom(xScale)
|
||||
.tickSize(chartHeight)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 막대 그리기
|
||||
const barHeight = yScale.bandwidth() / data.datasets.length;
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const bars = g
|
||||
.selectAll(`.bar-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("class", `bar-${i}`)
|
||||
.attr("x", 0)
|
||||
.attr("y", (_, j) => (yScale(data.labels[j]) || 0) + barHeight * i)
|
||||
.attr("width", 0)
|
||||
.attr("height", barHeight)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("ry", 4);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
bars
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("width", (d) => xScale(d));
|
||||
} else {
|
||||
bars.attr("width", (d) => xScale(d));
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
bars
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("opacity", 0.7);
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${dataset.label}: ${d}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("opacity", 1);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 1) {
|
||||
const legend = svg.append("g").attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "11px")
|
||||
.style("fill", "#666")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface LineChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 선 차트 컴포넌트
|
||||
*/
|
||||
export function LineChart({ data, config, width = 600, height = 400 }: LineChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// X축 스케일 (카테고리 → 연속형으로 변환)
|
||||
const xScale = d3.scalePoint().domain(data.labels).range([0, chartWidth]).padding(0.5);
|
||||
|
||||
// Y축 스케일
|
||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 선 생성기
|
||||
const lineGenerator = d3
|
||||
.line<number>()
|
||||
.x((_, i) => xScale(data.labels[i]) || 0)
|
||||
.y((d) => yScale(d));
|
||||
|
||||
// 부드러운 곡선 적용
|
||||
if (config.lineStyle === "smooth") {
|
||||
lineGenerator.curve(d3.curveMonotoneX);
|
||||
}
|
||||
|
||||
// 각 데이터셋에 대해 선 그리기
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const color = dataset.color || colors[i % colors.length];
|
||||
|
||||
// 선 그리기
|
||||
const path = g
|
||||
.append("path")
|
||||
.datum(dataset.data)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", color)
|
||||
.attr("stroke-width", 2.5)
|
||||
.attr("d", lineGenerator);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
const totalLength = path.node()?.getTotalLength() || 0;
|
||||
path
|
||||
.attr("stroke-dasharray", `${totalLength} ${totalLength}`)
|
||||
.attr("stroke-dashoffset", totalLength)
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("stroke-dashoffset", 0);
|
||||
}
|
||||
|
||||
// 데이터 포인트 (점) 그리기
|
||||
const circles = g
|
||||
.selectAll(`.circle-${i}`)
|
||||
.data(dataset.data)
|
||||
.enter()
|
||||
.append("circle")
|
||||
.attr("class", `circle-${i}`)
|
||||
.attr("cx", (_, j) => xScale(data.labels[j]) || 0)
|
||||
.attr("cy", (d) => yScale(d))
|
||||
.attr("r", 0)
|
||||
.attr("fill", color)
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
circles
|
||||
.transition()
|
||||
.delay((_, j) => j * 50)
|
||||
.duration(300)
|
||||
.attr("r", 4);
|
||||
} else {
|
||||
circles.attr("r", 4);
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
circles
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("r", 6);
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${dataset.label}: ${d}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("r", 4);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false && data.datasets.length > 1) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("line")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", 7)
|
||||
.attr("x2", 15)
|
||||
.attr("y2", 7)
|
||||
.attr("stroke", dataset.color || colors[i % colors.length])
|
||||
.attr("stroke-width", 3);
|
||||
|
||||
legendRow
|
||||
.append("circle")
|
||||
.attr("cx", 7.5)
|
||||
.attr("cy", 7)
|
||||
.attr("r", 4)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { ChartConfig } from '../types';
|
||||
|
||||
interface LineChartComponentProps {
|
||||
data: any[];
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 꺾은선 차트 컴포넌트
|
||||
* - Recharts LineChart 사용
|
||||
* - 다중 라인 지원
|
||||
*/
|
||||
export function LineChartComponent({ data, config, width = 250, height = 200 }: LineChartComponentProps) {
|
||||
const {
|
||||
xAxis = 'x',
|
||||
yAxis = 'y',
|
||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'],
|
||||
title,
|
||||
showLegend = true
|
||||
} = config;
|
||||
|
||||
// Y축 필드들 (단일 또는 다중)
|
||||
const yFields = Array.isArray(yAxis) ? yAxis : [yAxis];
|
||||
|
||||
// 사용할 Y축 키들 결정
|
||||
const yKeys = yFields.filter(field => field && field !== 'y');
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey={xAxis}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#666"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#666"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
formatter={(value: any, name: string) => [
|
||||
typeof value === 'number' ? value.toLocaleString() : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
{showLegend && yKeys.length > 1 && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{yKeys.map((key, index) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface PieChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
isDonut?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 원형 차트 / 도넛 차트 컴포넌트
|
||||
*/
|
||||
export function PieChart({ data, config, width = 500, height = 500, isDonut = false }: PieChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 120, bottom: 40, left: 120 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
const radius = Math.min(chartWidth, chartHeight) / 2;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${width / 2},${height / 2})`);
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899"];
|
||||
|
||||
// 첫 번째 데이터셋 사용
|
||||
const dataset = data.datasets[0];
|
||||
const pieData = data.labels.map((label, i) => ({
|
||||
label,
|
||||
value: dataset.data[i],
|
||||
}));
|
||||
|
||||
// 파이 생성기
|
||||
const pie = d3
|
||||
.pie<{ label: string; value: number }>()
|
||||
.value((d) => d.value)
|
||||
.sort(null);
|
||||
|
||||
// 아크 생성기
|
||||
const innerRadius = isDonut ? radius * (config.pieInnerRadius || 0.5) : 0;
|
||||
const arc = d3.arc<d3.PieArcDatum<{ label: string; value: number }>>().innerRadius(innerRadius).outerRadius(radius);
|
||||
|
||||
// 툴팁용 확대 아크
|
||||
const arcHover = d3
|
||||
.arc<d3.PieArcDatum<{ label: string; value: number }>>()
|
||||
.innerRadius(innerRadius)
|
||||
.outerRadius(radius + 10);
|
||||
|
||||
// 파이 조각 그리기
|
||||
const arcs = g.selectAll(".arc").data(pie(pieData)).enter().append("g").attr("class", "arc");
|
||||
|
||||
const paths = arcs
|
||||
.append("path")
|
||||
.attr("fill", (d, i) => colors[i % colors.length])
|
||||
.attr("stroke", "white")
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
paths
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attrTween("d", function (d) {
|
||||
const interpolate = d3.interpolate({ startAngle: 0, endAngle: 0 }, d);
|
||||
return function (t) {
|
||||
return arc(interpolate(t)) || "";
|
||||
};
|
||||
});
|
||||
} else {
|
||||
paths.attr("d", arc);
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
paths
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).transition().duration(200).attr("d", arcHover);
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${arc.centroid(d)[0]},${arc.centroid(d)[1]})`);
|
||||
|
||||
tooltip
|
||||
.append("rect")
|
||||
.attr("x", -50)
|
||||
.attr("y", -40)
|
||||
.attr("width", 100)
|
||||
.attr("height", 35)
|
||||
.attr("fill", "rgba(0,0,0,0.8)")
|
||||
.attr("rx", 4);
|
||||
|
||||
tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("y", -25)
|
||||
.text(d.data.label);
|
||||
|
||||
tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "14px")
|
||||
.attr("font-weight", "bold")
|
||||
.attr("y", -10)
|
||||
.text(`${d.data.value} (${((d.data.value / d3.sum(dataset.data)) * 100).toFixed(1)}%)`);
|
||||
})
|
||||
.on("mouseout", function (event, d) {
|
||||
d3.select(this).transition().duration(200).attr("d", arc);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
pieData.forEach((d, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(`${d.label} (${d.value})`);
|
||||
});
|
||||
}
|
||||
|
||||
// 도넛 차트 중앙 텍스트
|
||||
if (isDonut) {
|
||||
const total = d3.sum(dataset.data);
|
||||
g.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", -10)
|
||||
.style("font-size", "24px")
|
||||
.style("font-weight", "bold")
|
||||
.style("fill", "#333")
|
||||
.text(total.toLocaleString());
|
||||
|
||||
g.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("y", 15)
|
||||
.style("font-size", "14px")
|
||||
.style("fill", "#666")
|
||||
.text("Total");
|
||||
}
|
||||
}, [data, config, width, height, isDonut]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { ChartConfig } from '../types';
|
||||
|
||||
interface PieChartComponentProps {
|
||||
data: any[];
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원형 차트 컴포넌트
|
||||
* - Recharts PieChart 사용
|
||||
* - 자동 색상 배치 및 레이블
|
||||
*/
|
||||
export function PieChartComponent({ data, config, width = 250, height = 200 }: PieChartComponentProps) {
|
||||
const {
|
||||
xAxis = 'x',
|
||||
yAxis = 'y',
|
||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'],
|
||||
title,
|
||||
showLegend = true
|
||||
} = config;
|
||||
|
||||
// 파이 차트용 데이터 변환
|
||||
const pieData = data.map((item, index) => ({
|
||||
name: String(item[xAxis] || `항목 ${index + 1}`),
|
||||
value: Number(item[yAxis]) || 0,
|
||||
color: colors[index % colors.length]
|
||||
})).filter(item => item.value > 0); // 0보다 큰 값만 표시
|
||||
|
||||
// 커스텀 레이블 함수
|
||||
const renderLabel = (entry: any) => {
|
||||
const percent = ((entry.value / pieData.reduce((sum, item) => sum + item.value, 0)) * 100).toFixed(1);
|
||||
return `${percent}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderLabel}
|
||||
outerRadius={Math.min(width, height) * 0.3}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
formatter={(value: any, name: string) => [
|
||||
typeof value === 'number' ? value.toLocaleString() : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
iconType="circle"
|
||||
/>
|
||||
)}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import * as d3 from "d3";
|
||||
import { ChartConfig, ChartData } from "../types";
|
||||
|
||||
interface StackedBarChartProps {
|
||||
data: ChartData;
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* D3 누적 막대 차트 컴포넌트
|
||||
*/
|
||||
export function StackedBarChart({ data, config, width = 600, height = 400 }: StackedBarChartProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !data.labels.length || !data.datasets.length) return;
|
||||
|
||||
const svg = d3.select(svgRef.current);
|
||||
svg.selectAll("*").remove();
|
||||
|
||||
const margin = { top: 40, right: 80, bottom: 60, left: 60 };
|
||||
const chartWidth = width - margin.left - margin.right;
|
||||
const chartHeight = height - margin.top - margin.bottom;
|
||||
|
||||
const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
|
||||
|
||||
// 데이터 변환 (스택 데이터 생성)
|
||||
const stackData = data.labels.map((label, i) => {
|
||||
const obj: any = { label };
|
||||
data.datasets.forEach((dataset, j) => {
|
||||
obj[`series${j}`] = dataset.data[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
const series = data.datasets.map((_, i) => `series${i}`);
|
||||
|
||||
// 스택 레이아웃
|
||||
const stack = d3.stack().keys(series);
|
||||
const stackedData = stack(stackData as any);
|
||||
|
||||
// X축 스케일
|
||||
const xScale = d3.scaleBand().domain(data.labels).range([0, chartWidth]).padding(0.3);
|
||||
|
||||
// Y축 스케일
|
||||
const maxValue =
|
||||
config.stackMode === "percent" ? 100 : d3.max(stackedData[stackedData.length - 1], (d) => d[1] as number) || 0;
|
||||
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, maxValue * 1.1])
|
||||
.range([chartHeight, 0])
|
||||
.nice();
|
||||
|
||||
// X축 그리기
|
||||
g.append("g")
|
||||
.attr("transform", `translate(0,${chartHeight})`)
|
||||
.call(d3.axisBottom(xScale))
|
||||
.selectAll("text")
|
||||
.attr("transform", "rotate(-45)")
|
||||
.style("text-anchor", "end")
|
||||
.style("font-size", "12px");
|
||||
|
||||
// Y축 그리기
|
||||
const yAxis = config.stackMode === "percent" ? d3.axisLeft(yScale).tickFormat((d) => `${d}%`) : d3.axisLeft(yScale);
|
||||
g.append("g").call(yAxis).style("font-size", "12px");
|
||||
|
||||
// 그리드 라인
|
||||
if (config.showGrid !== false) {
|
||||
g.append("g")
|
||||
.attr("class", "grid")
|
||||
.call(
|
||||
d3
|
||||
.axisLeft(yScale)
|
||||
.tickSize(-chartWidth)
|
||||
.tickFormat(() => ""),
|
||||
)
|
||||
.style("stroke-dasharray", "3,3")
|
||||
.style("stroke", "#e0e0e0")
|
||||
.style("opacity", 0.5);
|
||||
}
|
||||
|
||||
// 색상 팔레트
|
||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||
|
||||
// 퍼센트 모드인 경우 데이터 정규화
|
||||
if (config.stackMode === "percent") {
|
||||
stackData.forEach((label) => {
|
||||
const total = d3.sum(series.map((s) => (label as any)[s]));
|
||||
series.forEach((s) => {
|
||||
(label as any)[s] = total > 0 ? ((label as any)[s] / total) * 100 : 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 누적 막대 그리기
|
||||
const layers = g
|
||||
.selectAll(".layer")
|
||||
.data(stackedData)
|
||||
.enter()
|
||||
.append("g")
|
||||
.attr("class", "layer")
|
||||
.attr("fill", (_, i) => data.datasets[i].color || colors[i % colors.length]);
|
||||
|
||||
const bars = layers
|
||||
.selectAll("rect")
|
||||
.data((d) => d)
|
||||
.enter()
|
||||
.append("rect")
|
||||
.attr("x", (d) => xScale((d.data as any).label) || 0)
|
||||
.attr("y", chartHeight)
|
||||
.attr("width", xScale.bandwidth())
|
||||
.attr("height", 0)
|
||||
.attr("rx", 4);
|
||||
|
||||
// 애니메이션
|
||||
if (config.enableAnimation !== false) {
|
||||
bars
|
||||
.transition()
|
||||
.duration(config.animationDuration || 750)
|
||||
.attr("y", (d) => yScale(d[1] as number))
|
||||
.attr("height", (d) => yScale(d[0] as number) - yScale(d[1] as number));
|
||||
} else {
|
||||
bars
|
||||
.attr("y", (d) => yScale(d[1] as number))
|
||||
.attr("height", (d) => yScale(d[0] as number) - yScale(d[1] as number));
|
||||
}
|
||||
|
||||
// 툴팁
|
||||
if (config.showTooltip !== false) {
|
||||
bars
|
||||
.on("mouseover", function (event, d) {
|
||||
d3.select(this).attr("opacity", 0.7);
|
||||
|
||||
const seriesIndex = stackedData.findIndex((s) => s.includes(d as any));
|
||||
const value = (d[1] as number) - (d[0] as number);
|
||||
const label = data.datasets[seriesIndex].label;
|
||||
|
||||
const [mouseX, mouseY] = d3.pointer(event, g.node());
|
||||
const tooltipText = `${label}: ${value.toFixed(config.stackMode === "percent" ? 1 : 0)}${config.stackMode === "percent" ? "%" : ""}`;
|
||||
|
||||
const tooltip = g
|
||||
.append("g")
|
||||
.attr("class", "tooltip")
|
||||
.attr("transform", `translate(${mouseX},${mouseY - 10})`);
|
||||
|
||||
const text = tooltip
|
||||
.append("text")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "white")
|
||||
.attr("font-size", "12px")
|
||||
.attr("dy", "-0.5em")
|
||||
.text(tooltipText);
|
||||
|
||||
const bbox = (text.node() as SVGTextElement).getBBox();
|
||||
const padding = 8;
|
||||
|
||||
tooltip
|
||||
.insert("rect", "text")
|
||||
.attr("x", bbox.x - padding)
|
||||
.attr("y", bbox.y - padding)
|
||||
.attr("width", bbox.width + padding * 2)
|
||||
.attr("height", bbox.height + padding * 2)
|
||||
.attr("fill", "rgba(0,0,0,0.85)")
|
||||
.attr("rx", 6);
|
||||
})
|
||||
.on("mouseout", function () {
|
||||
d3.select(this).attr("opacity", 1);
|
||||
g.selectAll(".tooltip").remove();
|
||||
});
|
||||
}
|
||||
|
||||
// 차트 제목
|
||||
if (config.title) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", 20)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "16px")
|
||||
.style("font-weight", "bold")
|
||||
.text(config.title);
|
||||
}
|
||||
|
||||
// X축 라벨
|
||||
if (config.xAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", width / 2)
|
||||
.attr("y", height - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.xAxisLabel);
|
||||
}
|
||||
|
||||
// Y축 라벨
|
||||
if (config.yAxisLabel) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("transform", "rotate(-90)")
|
||||
.attr("x", -height / 2)
|
||||
.attr("y", 15)
|
||||
.attr("text-anchor", "middle")
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#666")
|
||||
.text(config.yAxisLabel);
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (config.showLegend !== false) {
|
||||
const legend = svg
|
||||
.append("g")
|
||||
.attr("class", "legend")
|
||||
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`);
|
||||
|
||||
data.datasets.forEach((dataset, i) => {
|
||||
const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`);
|
||||
|
||||
legendRow
|
||||
.append("rect")
|
||||
.attr("width", 15)
|
||||
.attr("height", 15)
|
||||
.attr("fill", dataset.color || colors[i % colors.length])
|
||||
.attr("rx", 3);
|
||||
|
||||
legendRow
|
||||
.append("text")
|
||||
.attr("x", 20)
|
||||
.attr("y", 12)
|
||||
.style("font-size", "12px")
|
||||
.style("fill", "#333")
|
||||
.text(dataset.label);
|
||||
});
|
||||
}
|
||||
}, [data, config, width, height]);
|
||||
|
||||
return <svg ref={svgRef} width={width} height={height} style={{ fontFamily: "sans-serif" }} />;
|
||||
}
|
||||
|
|
@ -1,12 +1,8 @@
|
|||
/**
|
||||
* 차트 컴포넌트 인덱스
|
||||
*/
|
||||
|
||||
export { ChartRenderer } from './ChartRenderer';
|
||||
export { BarChartComponent } from './BarChartComponent';
|
||||
export { PieChartComponent } from './PieChartComponent';
|
||||
export { LineChartComponent } from './LineChartComponent';
|
||||
export { AreaChartComponent } from './AreaChartComponent';
|
||||
export { StackedBarChartComponent } from './StackedBarChartComponent';
|
||||
export { DonutChartComponent } from './DonutChartComponent';
|
||||
export { ComboChartComponent } from './ComboChartComponent';
|
||||
export { Chart } from "./Chart";
|
||||
export { BarChart } from "./BarChart";
|
||||
export { HorizontalBarChart } from "./HorizontalBarChart";
|
||||
export { LineChart } from "./LineChart";
|
||||
export { AreaChart } from "./AreaChart";
|
||||
export { PieChart } from "./PieChart";
|
||||
export { StackedBarChart } from "./StackedBarChart";
|
||||
export { ComboChart } from "./ComboChart";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,370 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ChartDataSource, QueryResult, ApiResponse } 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 { Plus, X, Play, AlertCircle } from "lucide-react";
|
||||
|
||||
interface ApiConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
onTestResult?: (result: QueryResult) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 설정 컴포넌트
|
||||
* - API 엔드포인트 설정
|
||||
* - 헤더 및 쿼리 파라미터 추가
|
||||
* - JSON Path 설정
|
||||
*/
|
||||
export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<QueryResult | null>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
|
||||
// 헤더 추가
|
||||
const addHeader = () => {
|
||||
const headers = dataSource.headers || {};
|
||||
const newKey = `header_${Object.keys(headers).length + 1}`;
|
||||
onChange({ headers: { ...headers, [newKey]: "" } });
|
||||
};
|
||||
|
||||
// 헤더 제거
|
||||
const removeHeader = (key: string) => {
|
||||
const headers = { ...dataSource.headers };
|
||||
delete headers[key];
|
||||
onChange({ headers });
|
||||
};
|
||||
|
||||
// 헤더 업데이트
|
||||
const updateHeader = (oldKey: string, newKey: string, value: string) => {
|
||||
const headers = { ...dataSource.headers };
|
||||
delete headers[oldKey];
|
||||
headers[newKey] = value;
|
||||
onChange({ headers });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
const addQueryParam = () => {
|
||||
const queryParams = dataSource.queryParams || {};
|
||||
const newKey = `param_${Object.keys(queryParams).length + 1}`;
|
||||
onChange({ queryParams: { ...queryParams, [newKey]: "" } });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 제거
|
||||
const removeQueryParam = (key: string) => {
|
||||
const queryParams = { ...dataSource.queryParams };
|
||||
delete queryParams[key];
|
||||
onChange({ queryParams });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 업데이트
|
||||
const updateQueryParam = (oldKey: string, newKey: string, value: string) => {
|
||||
const queryParams = { ...dataSource.queryParams };
|
||||
delete queryParams[oldKey];
|
||||
queryParams[newKey] = value;
|
||||
onChange({ queryParams });
|
||||
};
|
||||
|
||||
// API 테스트
|
||||
const testApi = async () => {
|
||||
if (!dataSource.endpoint) {
|
||||
setTestError("API URL을 입력하세요");
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setTestError(null);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
// 쿼리 파라미터 구성
|
||||
const params = new URLSearchParams();
|
||||
if (dataSource.queryParams) {
|
||||
Object.entries(dataSource.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: dataSource.endpoint,
|
||||
method: "GET",
|
||||
headers: dataSource.headers || {},
|
||||
queryParams: Object.fromEntries(params),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const apiResponse = await response.json();
|
||||
|
||||
if (!apiResponse.success) {
|
||||
throw new Error(apiResponse.message || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
const apiData = apiResponse.data;
|
||||
|
||||
// JSON Path 처리
|
||||
let data = apiData;
|
||||
if (dataSource.jsonPath) {
|
||||
const paths = dataSource.jsonPath.split(".");
|
||||
for (const path of paths) {
|
||||
if (data && typeof data === "object" && path in data) {
|
||||
data = data[path];
|
||||
} else {
|
||||
throw new Error(`JSON Path "${dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 배열이 아니면 배열로 변환
|
||||
const rows = Array.isArray(data) ? data : [data];
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new Error("API 응답에 데이터가 없습니다");
|
||||
}
|
||||
|
||||
// 컬럼 추출 및 타입 분석
|
||||
const firstRow = rows[0];
|
||||
const columns = Object.keys(firstRow);
|
||||
|
||||
// 각 컬럼의 타입 분석
|
||||
const columnTypes: Record<string, string> = {};
|
||||
columns.forEach((col) => {
|
||||
const value = firstRow[col];
|
||||
if (value === null || value === undefined) {
|
||||
columnTypes[col] = "null";
|
||||
} else if (Array.isArray(value)) {
|
||||
columnTypes[col] = "array";
|
||||
} else if (typeof value === "object") {
|
||||
columnTypes[col] = "object";
|
||||
} else if (typeof value === "number") {
|
||||
columnTypes[col] = "number";
|
||||
} else if (typeof value === "boolean") {
|
||||
columnTypes[col] = "boolean";
|
||||
} else {
|
||||
columnTypes[col] = "string";
|
||||
}
|
||||
});
|
||||
|
||||
const queryResult: QueryResult = {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: 0,
|
||||
columnTypes, // 타입 정보 추가
|
||||
};
|
||||
|
||||
setTestResult(queryResult);
|
||||
onTestResult?.(queryResult);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다";
|
||||
setTestError(errorMessage);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">2단계: REST API 설정</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">외부 API에서 데이터를 가져올 설정을 입력하세요</p>
|
||||
</div>
|
||||
|
||||
{/* API URL */}
|
||||
<Card className="space-y-4 p-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700">API URL *</Label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://api.example.com/data"
|
||||
value={dataSource.endpoint || ""}
|
||||
onChange={(e) => onChange({ endpoint: e.target.value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">GET 요청을 보낼 API 엔드포인트</p>
|
||||
</div>
|
||||
|
||||
{/* HTTP 메서드 (고정) */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700">HTTP 메서드</Label>
|
||||
<div className="mt-2 rounded border border-gray-300 bg-gray-100 p-2 text-sm text-gray-700">GET (고정)</div>
|
||||
<p className="mt-1 text-xs text-gray-500">데이터 조회는 GET 메서드만 지원합니다</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 쿼리 파라미터 */}
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-gray-700">URL 쿼리 파라미터</Label>
|
||||
<Button variant="outline" size="sm" onClick={addQueryParam}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dataSource.queryParams && Object.keys(dataSource.queryParams).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dataSource.queryParams).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="key"
|
||||
value={key}
|
||||
onChange={(e) => updateQueryParam(key, e.target.value, value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={value}
|
||||
onChange={(e) => updateQueryParam(key, key, e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeQueryParam(key)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-sm text-gray-500">추가된 파라미터가 없습니다</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">예: category=electronics, limit=10</p>
|
||||
</Card>
|
||||
|
||||
{/* 헤더 */}
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-gray-700">요청 헤더</Label>
|
||||
<Button variant="outline" size="sm" onClick={addHeader}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 빠른 헤더 템플릿 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
headers: { ...dataSource.headers, Authorization: "Bearer YOUR_TOKEN" },
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ Authorization
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
headers: { ...dataSource.headers, "Content-Type": "application/json" },
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ Content-Type
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dataSource.headers && Object.keys(dataSource.headers).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dataSource.headers).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Header Name"
|
||||
value={key}
|
||||
onChange={(e) => updateHeader(key, e.target.value, value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Header Value"
|
||||
value={value}
|
||||
onChange={(e) => updateHeader(key, key, e.target.value)}
|
||||
className="flex-1"
|
||||
type={key.toLowerCase().includes("auth") ? "password" : "text"}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeHeader(key)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-sm text-gray-500">추가된 헤더가 없습니다</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* JSON Path */}
|
||||
<Card className="space-y-2 p-4">
|
||||
<Label className="text-sm font-medium text-gray-700">JSON Path (선택)</Label>
|
||||
<Input
|
||||
placeholder="data.results"
|
||||
value={dataSource.jsonPath || ""}
|
||||
onChange={(e) => onChange({ jsonPath: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
JSON 응답에서 데이터 배열의 경로 (예: data.results, items, response.data)
|
||||
<br />
|
||||
비워두면 전체 응답을 사용합니다
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={testApi} disabled={!dataSource.endpoint || testing}>
|
||||
{testing ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
테스트 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
API 테스트
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테스트 오류 */}
|
||||
{testError && (
|
||||
<Card className="border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-600" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-red-800">API 호출 실패</div>
|
||||
<div className="mt-1 text-sm text-red-700">{testError}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 테스트 결과 */}
|
||||
{testResult && (
|
||||
<Card className="border-green-200 bg-green-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-green-800">✅ API 호출 성공</div>
|
||||
<div className="space-y-1 text-xs text-green-700">
|
||||
<div>총 {testResult.rows.length}개의 데이터를 불러왔습니다</div>
|
||||
<div>컬럼: {testResult.columns.join(", ")}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ChartDataSource } from "../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Database, Globe } from "lucide-react";
|
||||
|
||||
interface DataSourceSelectorProps {
|
||||
dataSource: ChartDataSource;
|
||||
onTypeChange: (type: "database" | "api") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 소스 선택 컴포넌트
|
||||
* - DB vs API 선택
|
||||
* - 큰 카드 UI로 직관적인 선택
|
||||
*/
|
||||
export function DataSourceSelector({ dataSource, onTypeChange }: DataSourceSelectorProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">1단계: 데이터 소스 선택</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">차트에 표시할 데이터를 어디서 가져올지 선택하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* 데이터베이스 옵션 */}
|
||||
<Card
|
||||
className={`cursor-pointer p-6 transition-all ${
|
||||
dataSource.type === "database"
|
||||
? "border-2 border-blue-500 bg-blue-50"
|
||||
: "border-2 border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => onTypeChange("database")}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-3 text-center">
|
||||
<div className={`rounded-full p-4 ${dataSource.type === "database" ? "bg-blue-100" : "bg-gray-100"}`}>
|
||||
<Database className={`h-8 w-8 ${dataSource.type === "database" ? "text-blue-600" : "text-gray-600"}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">데이터베이스</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">SQL 쿼리로 데이터 조회</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<div>✓ 현재 DB 또는 외부 DB</div>
|
||||
<div>✓ SELECT 쿼리 지원</div>
|
||||
<div>✓ 실시간 데이터 조회</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* REST API 옵션 */}
|
||||
<Card
|
||||
className={`cursor-pointer p-6 transition-all ${
|
||||
dataSource.type === "api"
|
||||
? "border-2 border-green-500 bg-green-50"
|
||||
: "border-2 border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => onTypeChange("api")}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-3 text-center">
|
||||
<div className={`rounded-full p-4 ${dataSource.type === "api" ? "bg-green-100" : "bg-gray-100"}`}>
|
||||
<Globe className={`h-8 w-8 ${dataSource.type === "api" ? "text-green-600" : "text-gray-600"}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">REST API</h4>
|
||||
<p className="mt-1 text-sm text-gray-600">외부 API에서 데이터 가져오기</p>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<div>✓ GET 요청 지원</div>
|
||||
<div>✓ JSON 응답 파싱</div>
|
||||
<div>✓ 커스텀 헤더 설정</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 선택된 타입 표시 */}
|
||||
{dataSource.type && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-gray-700">선택됨:</span>
|
||||
<span className="text-gray-900">{dataSource.type === "database" ? "🗄️ 데이터베이스" : "🌐 REST API"}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChartDataSource } from "../types";
|
||||
import { ExternalDbConnectionAPI, ExternalDbConnection } from "@/lib/api/externalDbConnection";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink, Database, Server } from "lucide-react";
|
||||
|
||||
interface DatabaseConfigProps {
|
||||
dataSource: ChartDataSource;
|
||||
onChange: (updates: Partial<ChartDataSource>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 설정 컴포넌트
|
||||
* - 현재 DB / 외부 DB 선택
|
||||
* - 외부 커넥션 목록 불러오기
|
||||
*/
|
||||
export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||
const [connections, setConnections] = useState<ExternalDbConnection[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 외부 커넥션 목록 불러오기
|
||||
useEffect(() => {
|
||||
if (dataSource.connectionType === "external") {
|
||||
loadExternalConnections();
|
||||
}
|
||||
}, [dataSource.connectionType]);
|
||||
|
||||
const loadExternalConnections = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const activeConnections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
||||
setConnections(activeConnections);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 현재 선택된 커넥션 찾기
|
||||
const selectedConnection = connections.find((conn) => String(conn.id) === dataSource.externalConnectionId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">2단계: 데이터베이스 설정</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">데이터를 조회할 데이터베이스를 선택하세요</p>
|
||||
</div>
|
||||
|
||||
{/* 현재 DB vs 외부 DB 선택 */}
|
||||
<Card className="p-4">
|
||||
<Label className="mb-3 block text-sm font-medium text-gray-700">데이터베이스 선택</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant={dataSource.connectionType === "current" ? "default" : "outline"}
|
||||
className="h-auto justify-start py-3"
|
||||
onClick={() => {
|
||||
onChange({ connectionType: "current", externalConnectionId: undefined });
|
||||
}}
|
||||
>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">현재 데이터베이스</div>
|
||||
<div className="text-xs opacity-80">애플리케이션 기본 DB</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={dataSource.connectionType === "external" ? "default" : "outline"}
|
||||
className="h-auto justify-start py-3"
|
||||
onClick={() => {
|
||||
onChange({ connectionType: "external" });
|
||||
}}
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">외부 데이터베이스</div>
|
||||
<div className="text-xs opacity-80">등록된 외부 커넥션</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 외부 DB 선택 시 커넥션 목록 */}
|
||||
{dataSource.connectionType === "external" && (
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-gray-700">외부 커넥션 선택</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open("/admin/external-connections", "_blank");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
커넥션 관리
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
|
||||
<span className="ml-2 text-sm text-gray-600">커넥션 목록 불러오는 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="text-sm text-red-800">❌ {error}</div>
|
||||
<Button variant="ghost" size="sm" onClick={loadExternalConnections} className="mt-2 text-xs">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && connections.length === 0 && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-center">
|
||||
<div className="mb-2 text-sm text-yellow-800">등록된 외부 커넥션이 없습니다</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open("/admin/external-connections", "_blank");
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
커넥션 등록하기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && connections.length > 0 && (
|
||||
<>
|
||||
<Select
|
||||
value={dataSource.externalConnectionId || undefined}
|
||||
onValueChange={(value) => {
|
||||
onChange({ externalConnectionId: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={String(conn.id)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">({conn.db_type.toUpperCase()})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedConnection && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">커넥션명:</span> {selectedConnection.connection_name}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">타입:</span> {selectedConnection.db_type.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 다음 단계 안내 */}
|
||||
{(dataSource.connectionType === "current" ||
|
||||
(dataSource.connectionType === "external" && dataSource.externalConnectionId)) && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="text-sm text-blue-800">✅ 데이터베이스가 선택되었습니다. 아래에서 SQL 쿼리를 작성하세요.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import { QueryResult } from "../types";
|
||||
|
||||
/**
|
||||
* JSON Path를 사용하여 객체에서 데이터 추출
|
||||
* @param obj JSON 객체
|
||||
* @param path 경로 (예: "data.results", "items")
|
||||
* @returns 추출된 데이터
|
||||
*/
|
||||
export function extractDataFromJsonPath(obj: any, path: string): any {
|
||||
if (!path || path.trim() === "") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const keys = path.split(".");
|
||||
let result = obj;
|
||||
|
||||
for (const key of keys) {
|
||||
if (result === null || result === undefined) {
|
||||
return null;
|
||||
}
|
||||
result = result[key];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답을 QueryResult 형식으로 변환
|
||||
* @param data API 응답 데이터
|
||||
* @param jsonPath JSON Path (선택)
|
||||
* @returns QueryResult
|
||||
*/
|
||||
export function transformApiResponseToQueryResult(data: any, jsonPath?: string): QueryResult {
|
||||
try {
|
||||
// JSON Path가 있으면 데이터 추출
|
||||
let extractedData = jsonPath ? extractDataFromJsonPath(data, jsonPath) : data;
|
||||
|
||||
// 배열이 아니면 배열로 변환
|
||||
if (!Array.isArray(extractedData)) {
|
||||
// 객체인 경우 키-값 쌍을 배열로 변환
|
||||
if (typeof extractedData === "object" && extractedData !== null) {
|
||||
extractedData = Object.entries(extractedData).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
} else {
|
||||
throw new Error("데이터가 배열 또는 객체 형식이 아닙니다");
|
||||
}
|
||||
}
|
||||
|
||||
if (extractedData.length === 0) {
|
||||
return {
|
||||
columns: [],
|
||||
rows: [],
|
||||
totalRows: 0,
|
||||
executionTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 첫 번째 행에서 컬럼 추출
|
||||
const firstRow = extractedData[0];
|
||||
const columns = Object.keys(firstRow);
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows: extractedData,
|
||||
totalRows: extractedData.length,
|
||||
executionTime: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`API 응답 변환 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 소스가 유효한지 검증
|
||||
* @param type 데이터 소스 타입
|
||||
* @param connectionType 커넥션 타입 (DB일 때)
|
||||
* @param externalConnectionId 외부 커넥션 ID (외부 DB일 때)
|
||||
* @param query SQL 쿼리 (DB일 때)
|
||||
* @param endpoint API URL (API일 때)
|
||||
* @returns 유효성 검증 결과
|
||||
*/
|
||||
export function validateDataSource(
|
||||
type: "database" | "api",
|
||||
connectionType?: "current" | "external",
|
||||
externalConnectionId?: string,
|
||||
query?: string,
|
||||
endpoint?: string,
|
||||
): { valid: boolean; message?: string } {
|
||||
if (type === "database") {
|
||||
// DB 검증
|
||||
if (!connectionType) {
|
||||
return { valid: false, message: "데이터베이스 타입을 선택하세요" };
|
||||
}
|
||||
|
||||
if (connectionType === "external" && !externalConnectionId) {
|
||||
return { valid: false, message: "외부 커넥션을 선택하세요" };
|
||||
}
|
||||
|
||||
if (!query || query.trim() === "") {
|
||||
return { valid: false, message: "SQL 쿼리를 입력하세요" };
|
||||
}
|
||||
|
||||
// SELECT 쿼리인지 검증 (간단한 검증)
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
if (!trimmedQuery.startsWith("select")) {
|
||||
return { valid: false, message: "SELECT 쿼리만 허용됩니다" };
|
||||
}
|
||||
|
||||
// 위험한 키워드 체크
|
||||
const dangerousKeywords = ["drop", "delete", "insert", "update", "truncate", "alter", "create", "exec", "execute"];
|
||||
for (const keyword of dangerousKeywords) {
|
||||
if (trimmedQuery.includes(keyword)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `보안상 ${keyword.toUpperCase()} 명령은 사용할 수 없습니다`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
} else if (type === "api") {
|
||||
// API 검증
|
||||
if (!endpoint || endpoint.trim() === "") {
|
||||
return { valid: false, message: "API URL을 입력하세요" };
|
||||
}
|
||||
|
||||
// URL 형식 검증
|
||||
try {
|
||||
new URL(endpoint);
|
||||
} catch {
|
||||
return { valid: false, message: "올바른 URL 형식이 아닙니다" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
return { valid: false, message: "알 수 없는 데이터 소스 타입입니다" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 파라미터를 URL에 추가
|
||||
* @param baseUrl 기본 URL
|
||||
* @param params 쿼리 파라미터 객체
|
||||
* @returns 쿼리 파라미터가 추가된 URL
|
||||
*/
|
||||
export function buildUrlWithParams(baseUrl: string, params?: Record<string, string>): string {
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
const url = new URL(baseUrl);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 데이터 타입 추론
|
||||
* @param rows 데이터 행
|
||||
* @param columnName 컬럼명
|
||||
* @returns 데이터 타입 ('string' | 'number' | 'date' | 'boolean')
|
||||
*/
|
||||
export function inferColumnType(rows: Record<string, any>[], columnName: string): string {
|
||||
if (rows.length === 0) {
|
||||
return "string";
|
||||
}
|
||||
|
||||
const sampleValue = rows[0][columnName];
|
||||
|
||||
if (typeof sampleValue === "number") {
|
||||
return "number";
|
||||
}
|
||||
|
||||
if (typeof sampleValue === "boolean") {
|
||||
return "boolean";
|
||||
}
|
||||
|
||||
if (typeof sampleValue === "string") {
|
||||
// 날짜 형식인지 확인
|
||||
if (!isNaN(Date.parse(sampleValue))) {
|
||||
return "date";
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
|
||||
return "string";
|
||||
}
|
||||
|
|
@ -2,11 +2,33 @@
|
|||
* 대시보드 관리 시스템 타입 정의
|
||||
*/
|
||||
|
||||
export type ElementType = 'chart' | 'widget';
|
||||
export type ElementType = "chart" | "widget";
|
||||
|
||||
export type ElementSubtype =
|
||||
| 'bar' | 'pie' | 'line' | 'area' | 'stacked-bar' | 'donut' | 'combo' // 차트 타입
|
||||
| 'exchange' | 'weather'; // 위젯 타입
|
||||
export type ElementSubtype =
|
||||
| "bar"
|
||||
| "horizontal-bar"
|
||||
| "pie"
|
||||
| "line"
|
||||
| "area"
|
||||
| "stacked-bar"
|
||||
| "donut"
|
||||
| "combo" // 차트 타입
|
||||
| "exchange"
|
||||
| "weather"
|
||||
| "clock"
|
||||
| "calendar"
|
||||
| "calculator"
|
||||
| "vehicle-status"
|
||||
| "vehicle-list"
|
||||
| "vehicle-map"
|
||||
| "delivery-status"
|
||||
| "risk-alert"
|
||||
| "driver-management"
|
||||
| "todo"
|
||||
| "booking-alert"
|
||||
| "maintenance"
|
||||
| "document"
|
||||
| "list"; // 위젯 타입
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
|
|
@ -26,8 +48,12 @@ export interface DashboardElement {
|
|||
size: Size;
|
||||
title: string;
|
||||
content: string;
|
||||
dataSource?: ChartDataSource; // 데이터 소스 설정
|
||||
chartConfig?: ChartConfig; // 차트 설정
|
||||
dataSource?: ChartDataSource; // 데이터 소스 설정
|
||||
chartConfig?: ChartConfig; // 차트 설정
|
||||
clockConfig?: ClockConfig; // 시계 설정
|
||||
calendarConfig?: CalendarConfig; // 달력 설정
|
||||
driverManagementConfig?: DriverManagementConfig; // 기사 관리 설정
|
||||
listConfig?: ListWidgetConfig; // 리스트 위젯 설정
|
||||
}
|
||||
|
||||
export interface DragData {
|
||||
|
|
@ -36,33 +62,181 @@ export interface DragData {
|
|||
}
|
||||
|
||||
export interface ResizeHandle {
|
||||
direction: 'nw' | 'ne' | 'sw' | 'se';
|
||||
direction: "nw" | "ne" | "sw" | "se";
|
||||
cursor: string;
|
||||
}
|
||||
|
||||
export interface ChartDataSource {
|
||||
type: 'api' | 'database' | 'static';
|
||||
endpoint?: string; // API 엔드포인트
|
||||
query?: string; // SQL 쿼리
|
||||
refreshInterval?: number; // 자동 새로고침 간격 (ms)
|
||||
filters?: any[]; // 필터 조건
|
||||
lastExecuted?: string; // 마지막 실행 시간
|
||||
type: "database" | "api"; // 데이터 소스 타입
|
||||
|
||||
// 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<string, string>; // 커스텀 헤더
|
||||
queryParams?: Record<string, string>; // URL 쿼리 파라미터
|
||||
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")
|
||||
|
||||
// 공통
|
||||
refreshInterval?: number; // 자동 새로고침 (초, 0이면 수동)
|
||||
lastExecuted?: string; // 마지막 실행 시간
|
||||
lastError?: string; // 마지막 오류 메시지
|
||||
}
|
||||
|
||||
export interface ChartConfig {
|
||||
xAxis?: string; // X축 데이터 필드
|
||||
yAxis?: string | string[]; // Y축 데이터 필드 (단일 또는 다중)
|
||||
groupBy?: string; // 그룹핑 필드
|
||||
aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min';
|
||||
colors?: string[]; // 차트 색상
|
||||
title?: string; // 차트 제목
|
||||
showLegend?: boolean; // 범례 표시 여부
|
||||
// 축 매핑
|
||||
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"; // 누적 모드
|
||||
|
||||
// 지도 관련 설정
|
||||
latitudeColumn?: string; // 위도 컬럼
|
||||
longitudeColumn?: string; // 경도 컬럼
|
||||
labelColumn?: string; // 라벨 컬럼
|
||||
statusColumn?: string; // 상태 컬럼
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
columns: string[]; // 컬럼명 배열
|
||||
columns: string[]; // 컬럼명 배열
|
||||
rows: Record<string, any>[]; // 데이터 행 배열
|
||||
totalRows: number; // 전체 행 수
|
||||
executionTime: number; // 실행 시간 (ms)
|
||||
error?: string; // 오류 메시지
|
||||
totalRows: number; // 전체 행 수
|
||||
executionTime: number; // 실행 시간 (ms)
|
||||
error?: string; // 오류 메시지
|
||||
columnTypes?: Record<string, string>; // 각 컬럼의 타입 정보 (number, string, object, array 등)
|
||||
}
|
||||
|
||||
// 시계 위젯 설정
|
||||
export interface ClockConfig {
|
||||
style: "analog" | "digital" | "both"; // 시계 스타일
|
||||
timezone: string; // 타임존 (예: 'Asia/Seoul')
|
||||
showDate: boolean; // 날짜 표시 여부
|
||||
showSeconds: boolean; // 초 표시 여부 (디지털)
|
||||
format24h: boolean; // 24시간 형식 (true) vs 12시간 형식 (false)
|
||||
theme: "light" | "dark" | "custom"; // 테마
|
||||
customColor?: string; // 사용자 지정 색상 (custom 테마일 때)
|
||||
}
|
||||
|
||||
// 달력 위젯 설정
|
||||
export interface CalendarConfig {
|
||||
view: "month" | "week" | "day"; // 뷰 타입
|
||||
startWeekOn: "monday" | "sunday"; // 주 시작 요일
|
||||
highlightWeekends: boolean; // 주말 강조
|
||||
highlightToday: boolean; // 오늘 강조
|
||||
showHolidays: boolean; // 공휴일 표시
|
||||
theme: "light" | "dark" | "custom"; // 테마
|
||||
customColor?: string; // 사용자 지정 색상
|
||||
showWeekNumbers?: boolean; // 주차 표시 (선택)
|
||||
}
|
||||
|
||||
// 기사 관리 위젯 설정
|
||||
export interface DriverManagementConfig {
|
||||
viewType: "list"; // 뷰 타입 (현재는 리스트만)
|
||||
autoRefreshInterval: number; // 자동 새로고침 간격 (초)
|
||||
visibleColumns: string[]; // 표시할 컬럼 목록
|
||||
theme: "light" | "dark" | "custom"; // 테마
|
||||
customColor?: string; // 사용자 지정 색상
|
||||
statusFilter: "all" | "driving" | "standby" | "resting" | "maintenance"; // 상태 필터
|
||||
sortBy: "name" | "vehicleNumber" | "status" | "departureTime"; // 정렬 기준
|
||||
sortOrder: "asc" | "desc"; // 정렬 순서
|
||||
}
|
||||
|
||||
// 기사 정보
|
||||
export interface DriverInfo {
|
||||
id: string; // 기사 고유 ID
|
||||
name: string; // 기사 이름
|
||||
vehicleNumber: string; // 차량 번호
|
||||
vehicleType: string; // 차량 유형
|
||||
phone: string; // 연락처
|
||||
status: "standby" | "driving" | "resting" | "maintenance"; // 운행 상태
|
||||
departure?: string; // 출발지
|
||||
destination?: string; // 목적지
|
||||
departureTime?: string; // 출발 시간
|
||||
estimatedArrival?: string; // 예상 도착 시간
|
||||
progress?: number; // 운행 진행률 (0-100)
|
||||
}
|
||||
|
||||
// 외부 DB 커넥션 정보 (기존 외부 커넥션 관리에서 가져옴)
|
||||
export interface ExternalConnection {
|
||||
id: string;
|
||||
name: string; // 사용자 지정 이름 (표시용)
|
||||
type: "postgresql" | "mysql" | "mssql" | "oracle";
|
||||
}
|
||||
|
||||
// API 응답 구조
|
||||
export interface ApiResponse<T = any> {
|
||||
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; // 색상
|
||||
}
|
||||
|
||||
// 리스트 위젯 설정
|
||||
export interface ListWidgetConfig {
|
||||
columnMode: "auto" | "manual"; // 컬럼 설정 방식 (자동 or 수동)
|
||||
viewMode: "table" | "card"; // 뷰 모드 (테이블 or 카드) (기본: table)
|
||||
columns: ListColumn[]; // 컬럼 정의
|
||||
pageSize: number; // 페이지당 행 수 (기본: 10)
|
||||
enablePagination: boolean; // 페이지네이션 활성화 (기본: true)
|
||||
showHeader: boolean; // 헤더 표시 (기본: true, 테이블 모드에만 적용)
|
||||
stripedRows: boolean; // 줄무늬 행 (기본: true, 테이블 모드에만 적용)
|
||||
compactMode: boolean; // 압축 모드 (기본: false)
|
||||
cardColumns: number; // 카드 뷰 컬럼 수 (기본: 3)
|
||||
}
|
||||
|
||||
// 리스트 컬럼
|
||||
export interface ListColumn {
|
||||
id: string; // 고유 ID
|
||||
label: string; // 표시될 컬럼명
|
||||
field: string; // 데이터 필드명
|
||||
width?: number; // 너비 (px)
|
||||
align?: "left" | "center" | "right"; // 정렬
|
||||
visible?: boolean; // 표시 여부 (기본: true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
import { QueryResult, ChartConfig, ChartData, ChartDataset } from "../types";
|
||||
|
||||
/**
|
||||
* 쿼리 결과를 차트 데이터로 변환
|
||||
*/
|
||||
export function transformQueryResultToChartData(queryResult: QueryResult, config: ChartConfig): ChartData | null {
|
||||
if (!queryResult || !queryResult.rows.length || !config.xAxis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rows = queryResult.rows;
|
||||
|
||||
// 그룹핑 처리
|
||||
if (config.groupBy && config.groupBy !== "__none__") {
|
||||
rows = applyGrouping(rows, config.groupBy, config.aggregation, config.yAxis);
|
||||
}
|
||||
|
||||
// X축 라벨 추출
|
||||
const labels = rows.map((row) => String(row[config.xAxis!] || ""));
|
||||
|
||||
// Y축 데이터 추출
|
||||
const yAxisFields = Array.isArray(config.yAxis) ? config.yAxis : config.yAxis ? [config.yAxis] : [];
|
||||
|
||||
// 집계 함수가 COUNT이고 Y축이 없으면 자동으로 count 필드 추가
|
||||
if (config.aggregation === "count" && yAxisFields.length === 0) {
|
||||
const datasets: ChartDataset[] = [
|
||||
{
|
||||
label: "개수",
|
||||
data: rows.map((row) => {
|
||||
const value = row["count"];
|
||||
return typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||
}),
|
||||
color: config.colors?.[0],
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
};
|
||||
}
|
||||
|
||||
if (yAxisFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 각 Y축 필드에 대해 데이터셋 생성
|
||||
const datasets: ChartDataset[] = yAxisFields.map((field, index) => {
|
||||
const data = rows.map((row) => {
|
||||
const value = row[field];
|
||||
return typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||
});
|
||||
|
||||
return {
|
||||
label: field,
|
||||
data,
|
||||
color: config.colors?.[index],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹핑 및 집계 처리
|
||||
*/
|
||||
function applyGrouping(
|
||||
rows: Record<string, any>[],
|
||||
groupByField: string,
|
||||
aggregation?: "sum" | "avg" | "count" | "max" | "min",
|
||||
yAxis?: string | string[],
|
||||
): Record<string, any>[] {
|
||||
// 그룹별로 데이터 묶기
|
||||
const groups = new Map<string, Record<string, any>[]>();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const key = String(row[groupByField] || "");
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)!.push(row);
|
||||
});
|
||||
|
||||
// 각 그룹에 대해 집계 수행
|
||||
const aggregatedRows: Record<string, any>[] = [];
|
||||
|
||||
groups.forEach((groupRows, key) => {
|
||||
const aggregatedRow: Record<string, any> = {
|
||||
[groupByField]: key,
|
||||
};
|
||||
|
||||
// Y축 필드에 대해 집계
|
||||
const yAxisFields = Array.isArray(yAxis) ? yAxis : yAxis ? [yAxis] : [];
|
||||
|
||||
if (aggregation === "count") {
|
||||
// COUNT: 그룹의 행 개수
|
||||
aggregatedRow["count"] = groupRows.length;
|
||||
} else if (yAxisFields.length > 0) {
|
||||
yAxisFields.forEach((field) => {
|
||||
const values = groupRows.map((row) => {
|
||||
const value = row[field];
|
||||
return typeof value === "number" ? value : parseFloat(String(value)) || 0;
|
||||
});
|
||||
|
||||
switch (aggregation) {
|
||||
case "sum":
|
||||
aggregatedRow[field] = values.reduce((a, b) => a + b, 0);
|
||||
break;
|
||||
case "avg":
|
||||
aggregatedRow[field] = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
break;
|
||||
case "max":
|
||||
aggregatedRow[field] = Math.max(...values);
|
||||
break;
|
||||
case "min":
|
||||
aggregatedRow[field] = Math.min(...values);
|
||||
break;
|
||||
default:
|
||||
// 집계 없으면 첫 번째 값 사용
|
||||
aggregatedRow[field] = values[0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
aggregatedRows.push(aggregatedRow);
|
||||
});
|
||||
|
||||
return aggregatedRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답을 차트 데이터로 변환
|
||||
*/
|
||||
export function transformApiResponseToChartData(
|
||||
apiData: Record<string, unknown>[],
|
||||
config: ChartConfig,
|
||||
): ChartData | null {
|
||||
// API 응답을 QueryResult 형식으로 변환
|
||||
if (!apiData || apiData.length === 0 || !config.xAxis) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryResult: QueryResult = {
|
||||
columns: Object.keys(apiData[0]),
|
||||
rows: apiData,
|
||||
totalRows: apiData.length,
|
||||
executionTime: 0,
|
||||
};
|
||||
|
||||
return transformQueryResultToChartData(queryResult, config);
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
"use client";
|
||||
|
||||
interface AnalogClockProps {
|
||||
time: Date;
|
||||
theme: "light" | "dark" | "custom";
|
||||
timezone?: string;
|
||||
customColor?: string; // 사용자 지정 색상
|
||||
}
|
||||
|
||||
/**
|
||||
* 아날로그 시계 컴포넌트
|
||||
* - SVG 기반 아날로그 시계
|
||||
* - 시침, 분침, 초침 애니메이션
|
||||
* - 테마별 색상 지원
|
||||
* - 타임존 표시
|
||||
*/
|
||||
export function AnalogClock({ time, theme, timezone, customColor }: AnalogClockProps) {
|
||||
const hours = time.getHours() % 12;
|
||||
const minutes = time.getMinutes();
|
||||
const seconds = time.getSeconds();
|
||||
|
||||
// 각도 계산 (12시 방향을 0도로, 시계방향으로 회전)
|
||||
const secondAngle = seconds * 6 - 90; // 6도씩 회전 (360/60)
|
||||
const minuteAngle = minutes * 6 + seconds * 0.1 - 90; // 6도씩 + 초당 0.1도
|
||||
const hourAngle = hours * 30 + minutes * 0.5 - 90; // 30도씩 + 분당 0.5도
|
||||
|
||||
// 테마별 색상
|
||||
const colors = getThemeColors(theme, customColor);
|
||||
|
||||
// 타임존 라벨
|
||||
const timezoneLabel = timezone ? getTimezoneLabel(timezone) : "";
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-2">
|
||||
<svg viewBox="0 0 200 200" className="h-full max-h-[200px] w-full max-w-[200px]">
|
||||
{/* 시계판 배경 */}
|
||||
<circle cx="100" cy="100" r="98" fill={colors.background} stroke={colors.border} strokeWidth="2" />
|
||||
|
||||
{/* 눈금 표시 */}
|
||||
{[...Array(60)].map((_, i) => {
|
||||
const angle = (i * 6 - 90) * (Math.PI / 180);
|
||||
const isHour = i % 5 === 0;
|
||||
const startRadius = isHour ? 85 : 90;
|
||||
const endRadius = 95;
|
||||
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={100 + startRadius * Math.cos(angle)}
|
||||
y1={100 + startRadius * Math.sin(angle)}
|
||||
x2={100 + endRadius * Math.cos(angle)}
|
||||
y2={100 + endRadius * Math.sin(angle)}
|
||||
stroke={colors.tick}
|
||||
strokeWidth={isHour ? 2 : 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 숫자 표시 (12시, 3시, 6시, 9시) */}
|
||||
{[12, 3, 6, 9].map((num, idx) => {
|
||||
const angle = (idx * 90 - 90) * (Math.PI / 180);
|
||||
const radius = 70;
|
||||
const x = 100 + radius * Math.cos(angle);
|
||||
const y = 100 + radius * Math.sin(angle);
|
||||
|
||||
return (
|
||||
<text
|
||||
key={num}
|
||||
x={x}
|
||||
y={y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="20"
|
||||
fontWeight="bold"
|
||||
fill={colors.number}
|
||||
>
|
||||
{num}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 시침 (짧고 굵음) */}
|
||||
<line
|
||||
x1="100"
|
||||
y1="100"
|
||||
x2={100 + 40 * Math.cos((hourAngle * Math.PI) / 180)}
|
||||
y2={100 + 40 * Math.sin((hourAngle * Math.PI) / 180)}
|
||||
stroke={colors.hourHand}
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* 분침 (중간 길이) */}
|
||||
<line
|
||||
x1="100"
|
||||
y1="100"
|
||||
x2={100 + 60 * Math.cos((minuteAngle * Math.PI) / 180)}
|
||||
y2={100 + 60 * Math.sin((minuteAngle * Math.PI) / 180)}
|
||||
stroke={colors.minuteHand}
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* 초침 (가늘고 긴) */}
|
||||
<line
|
||||
x1="100"
|
||||
y1="100"
|
||||
x2={100 + 75 * Math.cos((secondAngle * Math.PI) / 180)}
|
||||
y2={100 + 75 * Math.sin((secondAngle * Math.PI) / 180)}
|
||||
stroke={colors.secondHand}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* 중심점 */}
|
||||
<circle cx="100" cy="100" r="6" fill={colors.center} />
|
||||
<circle cx="100" cy="100" r="3" fill={colors.background} />
|
||||
</svg>
|
||||
|
||||
{/* 타임존 표시 */}
|
||||
{timezoneLabel && (
|
||||
<div className="mt-1 text-center text-xs font-medium" style={{ color: colors.number }}>
|
||||
{timezoneLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임존 라벨 반환
|
||||
*/
|
||||
function getTimezoneLabel(timezone: string): string {
|
||||
const timezoneLabels: Record<string, string> = {
|
||||
"Asia/Seoul": "서울 (KST)",
|
||||
"Asia/Tokyo": "도쿄 (JST)",
|
||||
"Asia/Shanghai": "베이징 (CST)",
|
||||
"America/New_York": "뉴욕 (EST)",
|
||||
"America/Los_Angeles": "LA (PST)",
|
||||
"Europe/London": "런던 (GMT)",
|
||||
"Europe/Paris": "파리 (CET)",
|
||||
"Australia/Sydney": "시드니 (AEDT)",
|
||||
};
|
||||
|
||||
return timezoneLabels[timezone] || timezone.split("/")[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 테마별 색상 반환
|
||||
*/
|
||||
function getThemeColors(theme: string, customColor?: string) {
|
||||
if (theme === "custom" && customColor) {
|
||||
// 사용자 지정 색상 사용 (약간 밝게/어둡게 조정)
|
||||
const lighterColor = adjustColor(customColor, 40);
|
||||
const darkerColor = adjustColor(customColor, -40);
|
||||
|
||||
return {
|
||||
background: lighterColor,
|
||||
border: customColor,
|
||||
tick: customColor,
|
||||
number: darkerColor,
|
||||
hourHand: darkerColor,
|
||||
minuteHand: customColor,
|
||||
secondHand: "#ef4444",
|
||||
center: darkerColor,
|
||||
};
|
||||
}
|
||||
|
||||
const themes = {
|
||||
light: {
|
||||
background: "#ffffff",
|
||||
border: "#d1d5db",
|
||||
tick: "#9ca3af",
|
||||
number: "#374151",
|
||||
hourHand: "#1f2937",
|
||||
minuteHand: "#4b5563",
|
||||
secondHand: "#ef4444",
|
||||
center: "#1f2937",
|
||||
},
|
||||
dark: {
|
||||
background: "#1f2937",
|
||||
border: "#4b5563",
|
||||
tick: "#6b7280",
|
||||
number: "#f9fafb",
|
||||
hourHand: "#f9fafb",
|
||||
minuteHand: "#d1d5db",
|
||||
secondHand: "#ef4444",
|
||||
center: "#f9fafb",
|
||||
},
|
||||
custom: {
|
||||
background: "#e0e7ff",
|
||||
border: "#6366f1",
|
||||
tick: "#818cf8",
|
||||
number: "#4338ca",
|
||||
hourHand: "#4338ca",
|
||||
minuteHand: "#6366f1",
|
||||
secondHand: "#ef4444",
|
||||
center: "#4338ca",
|
||||
},
|
||||
};
|
||||
|
||||
return themes[theme as keyof typeof themes] || themes.light;
|
||||
}
|
||||
|
||||
/**
|
||||
* 색상 밝기 조정
|
||||
*/
|
||||
function adjustColor(color: string, amount: number): string {
|
||||
const clamp = (num: number) => Math.min(255, Math.max(0, num));
|
||||
|
||||
const hex = color.replace("#", "");
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
const newR = clamp(r + amount);
|
||||
const newG = clamp(g + amount);
|
||||
const newB = clamp(b + amount);
|
||||
|
||||
return `#${newR.toString(16).padStart(2, "0")}${newG.toString(16).padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CalendarConfig } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface CalendarSettingsProps {
|
||||
config: CalendarConfig;
|
||||
onSave: (config: CalendarConfig) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 달력 위젯 설정 UI (Popover 내부용)
|
||||
*/
|
||||
export function CalendarSettings({ config, onSave, onClose }: CalendarSettingsProps) {
|
||||
const [localConfig, setLocalConfig] = useState<CalendarConfig>(config);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(localConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[600px] flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b p-4">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<span>📅</span>
|
||||
달력 설정
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 내용 - 스크롤 가능 */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* 뷰 타입 선택 (현재는 month만) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">뷰 타입</Label>
|
||||
<Select
|
||||
value={localConfig.view}
|
||||
onValueChange={(value) => setLocalConfig({ ...localConfig, view: value as any })}
|
||||
>
|
||||
<SelectTrigger className="w-full" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="month">월간 뷰</SelectItem>
|
||||
{/* <SelectItem value="week">주간 뷰 (준비 중)</SelectItem>
|
||||
<SelectItem value="day">일간 뷰 (준비 중)</SelectItem> */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 시작 요일 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">주 시작 요일</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={localConfig.startWeekOn === "sunday" ? "default" : "outline"}
|
||||
onClick={() => setLocalConfig({ ...localConfig, startWeekOn: "sunday" })}
|
||||
size="sm"
|
||||
>
|
||||
일요일
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={localConfig.startWeekOn === "monday" ? "default" : "outline"}
|
||||
onClick={() => setLocalConfig({ ...localConfig, startWeekOn: "monday" })}
|
||||
size="sm"
|
||||
>
|
||||
월요일
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 테마 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">테마</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
gradient: "bg-gradient-to-br from-white to-gray-100",
|
||||
text: "text-gray-900",
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
gradient: "bg-gradient-to-br from-gray-800 to-gray-900",
|
||||
text: "text-white",
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: "사용자",
|
||||
gradient: "bg-gradient-to-br from-blue-400 to-purple-600",
|
||||
text: "text-white",
|
||||
},
|
||||
].map((theme) => (
|
||||
<Button
|
||||
key={theme.value}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setLocalConfig({ ...localConfig, theme: theme.value as any })}
|
||||
className={`relative h-auto overflow-hidden p-0 ${
|
||||
localConfig.theme === theme.value ? "ring-primary ring-2 ring-offset-2" : ""
|
||||
}`}
|
||||
size="sm"
|
||||
>
|
||||
<div className={`${theme.gradient} ${theme.text} w-full rounded px-3 py-2 text-xs font-medium`}>
|
||||
{theme.label}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 사용자 지정 색상 */}
|
||||
{localConfig.theme === "custom" && (
|
||||
<Card className="mt-2 border p-3">
|
||||
<Label className="mb-2 block text-xs font-medium">강조 색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={localConfig.customColor || "#3b82f6"}
|
||||
onChange={(e) => setLocalConfig({ ...localConfig, customColor: e.target.value })}
|
||||
className="h-10 w-16 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={localConfig.customColor || "#3b82f6"}
|
||||
onChange={(e) => setLocalConfig({ ...localConfig, customColor: e.target.value })}
|
||||
placeholder="#3b82f6"
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 표시 옵션 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">표시 옵션</Label>
|
||||
<div className="space-y-2">
|
||||
{/* 오늘 강조 */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📍</span>
|
||||
<Label className="cursor-pointer text-sm">오늘 날짜 강조</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={localConfig.highlightToday}
|
||||
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, highlightToday: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 주말 강조 */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🎨</span>
|
||||
<Label className="cursor-pointer text-sm">주말 색상 강조</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={localConfig.highlightWeekends}
|
||||
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, highlightWeekends: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 공휴일 표시 */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🎉</span>
|
||||
<Label className="cursor-pointer text-sm">공휴일 표시</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={localConfig.showHolidays}
|
||||
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, showHolidays: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex justify-end gap-2 border-t p-4">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { DashboardElement, CalendarConfig } from "../types";
|
||||
import { MonthView } from "./MonthView";
|
||||
import { CalendarSettings } from "./CalendarSettings";
|
||||
import { generateCalendarDays, getMonthName, navigateMonth } from "./calendarUtils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings, ChevronLeft, ChevronRight, Calendar } from "lucide-react";
|
||||
|
||||
interface CalendarWidgetProps {
|
||||
element: DashboardElement;
|
||||
onConfigUpdate?: (config: CalendarConfig) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 달력 위젯 메인 컴포넌트
|
||||
* - 월간/주간/일간 뷰 지원
|
||||
* - 네비게이션 (이전/다음 월, 오늘)
|
||||
* - 내장 설정 UI
|
||||
*/
|
||||
export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps) {
|
||||
// 현재 표시 중인 년/월
|
||||
const today = new Date();
|
||||
const [currentYear, setCurrentYear] = useState(today.getFullYear());
|
||||
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
// 기본 설정값
|
||||
const config = element.calendarConfig || {
|
||||
view: "month",
|
||||
startWeekOn: "sunday",
|
||||
highlightWeekends: true,
|
||||
highlightToday: true,
|
||||
showHolidays: true,
|
||||
theme: "light",
|
||||
};
|
||||
|
||||
// 설정 저장 핸들러
|
||||
const handleSaveSettings = (newConfig: CalendarConfig) => {
|
||||
onConfigUpdate?.(newConfig);
|
||||
setSettingsOpen(false);
|
||||
};
|
||||
|
||||
// 이전 월로 이동
|
||||
const handlePrevMonth = () => {
|
||||
const { year, month } = navigateMonth(currentYear, currentMonth, "prev");
|
||||
setCurrentYear(year);
|
||||
setCurrentMonth(month);
|
||||
};
|
||||
|
||||
// 다음 월로 이동
|
||||
const handleNextMonth = () => {
|
||||
const { year, month } = navigateMonth(currentYear, currentMonth, "next");
|
||||
setCurrentYear(year);
|
||||
setCurrentMonth(month);
|
||||
};
|
||||
|
||||
// 오늘로 돌아가기
|
||||
const handleToday = () => {
|
||||
setCurrentYear(today.getFullYear());
|
||||
setCurrentMonth(today.getMonth());
|
||||
};
|
||||
|
||||
// 달력 날짜 생성
|
||||
const calendarDays = generateCalendarDays(currentYear, currentMonth, config.startWeekOn);
|
||||
|
||||
// 크기에 따른 컴팩트 모드 판단
|
||||
const isCompact = element.size.width < 400 || element.size.height < 400;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col">
|
||||
{/* 헤더 - 네비게이션 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-2">
|
||||
{/* 이전 월 버튼 */}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePrevMonth}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 현재 년월 표시 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">
|
||||
{currentYear}년 {getMonthName(currentMonth)}
|
||||
</span>
|
||||
{!isCompact && (
|
||||
<Button variant="outline" size="sm" className="h-6 px-2 text-xs" onClick={handleToday}>
|
||||
오늘
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 다음 월 버튼 */}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleNextMonth}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 달력 콘텐츠 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{config.view === "month" && <MonthView days={calendarDays} config={config} isCompact={isCompact} />}
|
||||
{/* 추후 WeekView, DayView 추가 가능 */}
|
||||
</div>
|
||||
|
||||
{/* 설정 버튼 - 우측 하단 */}
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 bg-white/80 hover:bg-white">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[450px] p-0" align="end">
|
||||
<CalendarSettings config={config} onSave={handleSaveSettings} onClose={() => setSettingsOpen(false)} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ClockConfig } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface ClockSettingsProps {
|
||||
config: ClockConfig;
|
||||
onSave: (config: ClockConfig) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시계 위젯 설정 UI (Popover 내부용)
|
||||
* - 모달 없이 순수 설정 폼만 제공
|
||||
*/
|
||||
export function ClockSettings({ config, onSave, onClose }: ClockSettingsProps) {
|
||||
const [localConfig, setLocalConfig] = useState<ClockConfig>(config);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(localConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[600px] flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b p-4">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<span>⏰</span>
|
||||
시계 설정
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 내용 - 스크롤 가능 */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* 스타일 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">시계 스타일</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: "digital", label: "디지털", icon: "🔢" },
|
||||
{ value: "analog", label: "아날로그", icon: "🕐" },
|
||||
{ value: "both", label: "둘 다", icon: "⏰" },
|
||||
].map((style) => (
|
||||
<Button
|
||||
key={style.value}
|
||||
type="button"
|
||||
variant={localConfig.style === style.value ? "default" : "outline"}
|
||||
onClick={() => setLocalConfig({ ...localConfig, style: style.value as any })}
|
||||
className="flex h-auto flex-col items-center gap-1 py-3"
|
||||
size="sm"
|
||||
>
|
||||
<span className="text-2xl">{style.icon}</span>
|
||||
<span className="text-xs">{style.label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 타임존 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">타임존</Label>
|
||||
<Select
|
||||
value={localConfig.timezone}
|
||||
onValueChange={(value) => setLocalConfig({ ...localConfig, timezone: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Asia/Seoul">🇰🇷 서울 (KST)</SelectItem>
|
||||
<SelectItem value="Asia/Tokyo">🇯🇵 도쿄 (JST)</SelectItem>
|
||||
<SelectItem value="Asia/Shanghai">🇨🇳 베이징 (CST)</SelectItem>
|
||||
<SelectItem value="America/New_York">🇺🇸 뉴욕 (EST)</SelectItem>
|
||||
<SelectItem value="America/Los_Angeles">🇺🇸 LA (PST)</SelectItem>
|
||||
<SelectItem value="Europe/London">🇬🇧 런던 (GMT)</SelectItem>
|
||||
<SelectItem value="Europe/Paris">🇫🇷 파리 (CET)</SelectItem>
|
||||
<SelectItem value="Australia/Sydney">🇦🇺 시드니 (AEDT)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 테마 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">테마</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
gradient: "bg-gradient-to-br from-white to-gray-100",
|
||||
text: "text-gray-900",
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
gradient: "bg-gradient-to-br from-gray-800 to-gray-900",
|
||||
text: "text-white",
|
||||
},
|
||||
{
|
||||
value: "custom",
|
||||
label: "사용자",
|
||||
gradient: "bg-gradient-to-br from-blue-400 to-purple-600",
|
||||
text: "text-white",
|
||||
},
|
||||
].map((theme) => (
|
||||
<Button
|
||||
key={theme.value}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setLocalConfig({ ...localConfig, theme: theme.value as any })}
|
||||
className={`relative h-auto overflow-hidden p-0 ${
|
||||
localConfig.theme === theme.value ? "ring-primary ring-2 ring-offset-2" : ""
|
||||
}`}
|
||||
size="sm"
|
||||
>
|
||||
<div className={`${theme.gradient} ${theme.text} w-full rounded px-3 py-2 text-xs font-medium`}>
|
||||
{theme.label}
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 사용자 지정 색상 */}
|
||||
{localConfig.theme === "custom" && (
|
||||
<Card className="mt-2 border p-3">
|
||||
<Label className="mb-2 block text-xs font-medium">배경 색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={localConfig.customColor || "#3b82f6"}
|
||||
onChange={(e) => setLocalConfig({ ...localConfig, customColor: e.target.value })}
|
||||
className="h-10 w-16 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={localConfig.customColor || "#3b82f6"}
|
||||
onChange={(e) => setLocalConfig({ ...localConfig, customColor: e.target.value })}
|
||||
placeholder="#3b82f6"
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 옵션 토글 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">표시 옵션</Label>
|
||||
<div className="space-y-2">
|
||||
{/* 날짜 표시 */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">📅</span>
|
||||
<Label className="cursor-pointer text-sm">날짜 표시</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={localConfig.showDate}
|
||||
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, showDate: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 초 표시 */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">⏱️</span>
|
||||
<Label className="cursor-pointer text-sm">초 표시</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={localConfig.showSeconds}
|
||||
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, showSeconds: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 24시간 형식 */}
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🕐</span>
|
||||
<Label className="cursor-pointer text-sm">24시간 형식</Label>
|
||||
</div>
|
||||
<Switch
|
||||
checked={localConfig.format24h}
|
||||
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, format24h: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex justify-end gap-2 border-t p-4">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { DashboardElement, ClockConfig } from "../types";
|
||||
import { AnalogClock } from "./AnalogClock";
|
||||
import { DigitalClock } from "./DigitalClock";
|
||||
import { ClockSettings } from "./ClockSettings";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
interface ClockWidgetProps {
|
||||
element: DashboardElement;
|
||||
onConfigUpdate?: (config: ClockConfig) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시계 위젯 메인 컴포넌트
|
||||
* - 실시간으로 1초마다 업데이트
|
||||
* - 아날로그/디지털/둘다 스타일 지원
|
||||
* - 타임존 지원
|
||||
* - 내장 설정 UI
|
||||
*/
|
||||
export function ClockWidget({ element, onConfigUpdate }: ClockWidgetProps) {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
// 기본 설정값
|
||||
const config = element.clockConfig || {
|
||||
style: "digital",
|
||||
timezone: "Asia/Seoul",
|
||||
showDate: true,
|
||||
showSeconds: true,
|
||||
format24h: true,
|
||||
theme: "light",
|
||||
customColor: "#3b82f6",
|
||||
};
|
||||
|
||||
// 설정 저장 핸들러
|
||||
const handleSaveSettings = (newConfig: ClockConfig) => {
|
||||
onConfigUpdate?.(newConfig);
|
||||
setSettingsOpen(false);
|
||||
};
|
||||
|
||||
// 1초마다 시간 업데이트
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
|
||||
// cleanup: 컴포넌트 unmount 시 타이머 정리
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// 시계 콘텐츠 렌더링
|
||||
const renderClockContent = () => {
|
||||
if (config.style === "analog") {
|
||||
return (
|
||||
<AnalogClock
|
||||
time={currentTime}
|
||||
theme={config.theme}
|
||||
timezone={config.timezone}
|
||||
customColor={config.customColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.style === "digital") {
|
||||
return (
|
||||
<DigitalClock
|
||||
time={currentTime}
|
||||
timezone={config.timezone}
|
||||
showDate={config.showDate}
|
||||
showSeconds={config.showSeconds}
|
||||
format24h={config.format24h}
|
||||
theme={config.theme}
|
||||
customColor={config.customColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 'both' - 아날로그 + 디지털
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="flex-[55] overflow-hidden">
|
||||
<AnalogClock
|
||||
time={currentTime}
|
||||
theme={config.theme}
|
||||
timezone={config.timezone}
|
||||
customColor={config.customColor}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-[45] overflow-hidden">
|
||||
<DigitalClock
|
||||
time={currentTime}
|
||||
timezone={config.timezone}
|
||||
showDate={false}
|
||||
showSeconds={config.showSeconds}
|
||||
format24h={config.format24h}
|
||||
theme={config.theme}
|
||||
customColor={config.customColor}
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* 시계 콘텐츠 */}
|
||||
{renderClockContent()}
|
||||
|
||||
{/* 설정 버튼 - 우측 상단 */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 bg-white/80 hover:bg-white">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[500px] p-0" align="end">
|
||||
<ClockSettings config={config} onSave={handleSaveSettings} onClose={() => setSettingsOpen(false)} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
"use client";
|
||||
|
||||
interface DigitalClockProps {
|
||||
time: Date;
|
||||
timezone: string;
|
||||
showDate: boolean;
|
||||
showSeconds: boolean;
|
||||
format24h: boolean;
|
||||
theme: "light" | "dark" | "custom";
|
||||
compact?: boolean; // 작은 크기에서 사용
|
||||
customColor?: string; // 사용자 지정 색상
|
||||
}
|
||||
|
||||
/**
|
||||
* 디지털 시계 컴포넌트
|
||||
* - 실시간 시간 표시
|
||||
* - 타임존 지원
|
||||
* - 날짜/초 표시 옵션
|
||||
* - 12/24시간 형식 지원
|
||||
*/
|
||||
export function DigitalClock({
|
||||
time,
|
||||
timezone,
|
||||
showDate,
|
||||
showSeconds,
|
||||
format24h,
|
||||
theme,
|
||||
compact = false,
|
||||
customColor,
|
||||
}: DigitalClockProps) {
|
||||
// 시간 포맷팅 (타임존 적용)
|
||||
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;
|
||||
|
||||
// 타임존 라벨
|
||||
const timezoneLabel = getTimezoneLabel(timezone);
|
||||
|
||||
// 테마별 스타일
|
||||
const themeClasses = getThemeClasses(theme, customColor);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full flex-col items-center justify-center ${compact ? "p-1" : "p-4"} text-center ${themeClasses.container}`}
|
||||
style={themeClasses.style}
|
||||
>
|
||||
{/* 날짜 표시 (compact 모드에서는 숨김) */}
|
||||
{!compact && showDate && dateString && (
|
||||
<div className={`mb-3 text-sm font-medium ${themeClasses.date}`}>{dateString}</div>
|
||||
)}
|
||||
|
||||
{/* 시간 표시 */}
|
||||
<div className={`font-bold tabular-nums ${themeClasses.time} ${compact ? "text-xl" : "text-5xl"}`}>
|
||||
{timeString}
|
||||
</div>
|
||||
|
||||
{/* 타임존 표시 */}
|
||||
<div className={`${compact ? "mt-0.5" : "mt-3"} text-xs font-medium ${themeClasses.timezone}`}>
|
||||
{timezoneLabel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 타임존 라벨 반환
|
||||
*/
|
||||
function getTimezoneLabel(timezone: string): string {
|
||||
const timezoneLabels: Record<string, string> = {
|
||||
"Asia/Seoul": "서울 (KST)",
|
||||
"Asia/Tokyo": "도쿄 (JST)",
|
||||
"Asia/Shanghai": "베이징 (CST)",
|
||||
"America/New_York": "뉴욕 (EST)",
|
||||
"America/Los_Angeles": "LA (PST)",
|
||||
"Europe/London": "런던 (GMT)",
|
||||
"Europe/Paris": "파리 (CET)",
|
||||
"Australia/Sydney": "시드니 (AEDT)",
|
||||
};
|
||||
|
||||
return timezoneLabels[timezone] || timezone;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테마별 클래스 반환
|
||||
*/
|
||||
function getThemeClasses(theme: string, customColor?: string) {
|
||||
if (theme === "custom" && customColor) {
|
||||
// 사용자 지정 색상 사용
|
||||
return {
|
||||
container: "text-white",
|
||||
date: "text-white/80",
|
||||
time: "text-white",
|
||||
timezone: "text-white/70",
|
||||
style: { backgroundColor: customColor },
|
||||
};
|
||||
}
|
||||
|
||||
const themes = {
|
||||
light: {
|
||||
container: "bg-white text-gray-900",
|
||||
date: "text-gray-600",
|
||||
time: "text-gray-900",
|
||||
timezone: "text-gray-500",
|
||||
},
|
||||
dark: {
|
||||
container: "bg-gray-900 text-white",
|
||||
date: "text-gray-300",
|
||||
time: "text-white",
|
||||
timezone: "text-gray-400",
|
||||
},
|
||||
custom: {
|
||||
container: "bg-gradient-to-br from-blue-400 to-purple-600 text-white",
|
||||
date: "text-blue-100",
|
||||
time: "text-white",
|
||||
timezone: "text-blue-200",
|
||||
},
|
||||
};
|
||||
|
||||
return themes[theme as keyof typeof themes] || themes.light;
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
|
||||
import { DriverInfo, DriverManagementConfig } from "../types";
|
||||
import { getStatusColor, getStatusLabel, formatTime, COLUMN_LABELS } from "./driverUtils";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface DriverListViewProps {
|
||||
drivers: DriverInfo[];
|
||||
config: DriverManagementConfig;
|
||||
isCompact?: boolean; // 작은 크기 (2x2 등)
|
||||
}
|
||||
|
||||
export function DriverListView({ drivers, config, isCompact = false }: DriverListViewProps) {
|
||||
const { visibleColumns } = config;
|
||||
|
||||
// 컴팩트 모드: 요약 정보만 표시
|
||||
if (isCompact) {
|
||||
const stats = {
|
||||
driving: drivers.filter((d) => d.status === "driving").length,
|
||||
standby: drivers.filter((d) => d.status === "standby").length,
|
||||
resting: drivers.filter((d) => d.status === "resting").length,
|
||||
maintenance: drivers.filter((d) => d.status === "maintenance").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-3 p-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900">{drivers.length}</div>
|
||||
<div className="text-sm text-gray-600">전체 기사</div>
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-2 gap-2 text-center text-xs">
|
||||
<div className="rounded-lg bg-green-100 p-2">
|
||||
<div className="font-semibold text-green-800">{stats.driving}</div>
|
||||
<div className="text-green-600">운행중</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-100 p-2">
|
||||
<div className="font-semibold text-gray-800">{stats.standby}</div>
|
||||
<div className="text-gray-600">대기중</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-orange-100 p-2">
|
||||
<div className="font-semibold text-orange-800">{stats.resting}</div>
|
||||
<div className="text-orange-600">휴식중</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-red-100 p-2">
|
||||
<div className="font-semibold text-red-800">{stats.maintenance}</div>
|
||||
<div className="text-red-600">점검중</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 빈 데이터 처리
|
||||
if (drivers.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-gray-500">조회된 기사 정보가 없습니다</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="sticky top-0 z-10 bg-gray-50">
|
||||
<tr>
|
||||
{visibleColumns.includes("status") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.status}</th>
|
||||
)}
|
||||
{visibleColumns.includes("name") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.name}</th>
|
||||
)}
|
||||
{visibleColumns.includes("vehicleNumber") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.vehicleNumber}</th>
|
||||
)}
|
||||
{visibleColumns.includes("vehicleType") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.vehicleType}</th>
|
||||
)}
|
||||
{visibleColumns.includes("departure") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.departure}</th>
|
||||
)}
|
||||
{visibleColumns.includes("destination") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.destination}</th>
|
||||
)}
|
||||
{visibleColumns.includes("departureTime") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.departureTime}</th>
|
||||
)}
|
||||
{visibleColumns.includes("estimatedArrival") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">
|
||||
{COLUMN_LABELS.estimatedArrival}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.includes("phone") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.phone}</th>
|
||||
)}
|
||||
{visibleColumns.includes("progress") && (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-700">{COLUMN_LABELS.progress}</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{drivers.map((driver) => {
|
||||
const statusColors = getStatusColor(driver.status);
|
||||
return (
|
||||
<tr key={driver.id} className="transition-colors hover:bg-gray-50">
|
||||
{visibleColumns.includes("status") && (
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${statusColors.bg} ${statusColors.text}`}
|
||||
>
|
||||
{getStatusLabel(driver.status)}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.includes("name") && (
|
||||
<td className="px-3 py-2 text-sm font-medium text-gray-900">{driver.name}</td>
|
||||
)}
|
||||
{visibleColumns.includes("vehicleNumber") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-700">{driver.vehicleNumber}</td>
|
||||
)}
|
||||
{visibleColumns.includes("vehicleType") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-600">{driver.vehicleType}</td>
|
||||
)}
|
||||
{visibleColumns.includes("departure") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-700">
|
||||
{driver.departure || <span className="text-gray-400">-</span>}
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.includes("destination") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-700">
|
||||
{driver.destination || <span className="text-gray-400">-</span>}
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.includes("departureTime") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-600">{formatTime(driver.departureTime)}</td>
|
||||
)}
|
||||
{visibleColumns.includes("estimatedArrival") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-600">{formatTime(driver.estimatedArrival)}</td>
|
||||
)}
|
||||
{visibleColumns.includes("phone") && (
|
||||
<td className="px-3 py-2 text-sm text-gray-600">{driver.phone}</td>
|
||||
)}
|
||||
{visibleColumns.includes("progress") && (
|
||||
<td className="px-3 py-2">
|
||||
{driver.progress !== undefined ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Progress value={driver.progress} className="h-2 w-16" />
|
||||
<span className="text-xs text-gray-600">{driver.progress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { DriverManagementConfig } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { COLUMN_LABELS, DEFAULT_VISIBLE_COLUMNS } from "./driverUtils";
|
||||
|
||||
interface DriverManagementSettingsProps {
|
||||
config: DriverManagementConfig;
|
||||
onSave: (config: DriverManagementConfig) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DriverManagementSettings({ config, onSave, onClose }: DriverManagementSettingsProps) {
|
||||
const [localConfig, setLocalConfig] = useState<DriverManagementConfig>(config);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(localConfig);
|
||||
};
|
||||
|
||||
// 컬럼 토글
|
||||
const toggleColumn = (column: string) => {
|
||||
const newColumns = localConfig.visibleColumns.includes(column)
|
||||
? localConfig.visibleColumns.filter((c) => c !== column)
|
||||
: [...localConfig.visibleColumns, column];
|
||||
setLocalConfig({ ...localConfig, visibleColumns: newColumns });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full max-h-[600px] flex-col overflow-hidden">
|
||||
<div className="flex-1 space-y-6 overflow-y-auto p-6">
|
||||
{/* 자동 새로고침 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">자동 새로고침</Label>
|
||||
<Select
|
||||
value={String(localConfig.autoRefreshInterval)}
|
||||
onValueChange={(value) => setLocalConfig({ ...localConfig, autoRefreshInterval: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
<SelectItem value="0">사용 안 함</SelectItem>
|
||||
<SelectItem value="10">10초마다</SelectItem>
|
||||
<SelectItem value="30">30초마다</SelectItem>
|
||||
<SelectItem value="60">1분마다</SelectItem>
|
||||
<SelectItem value="300">5분마다</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 정렬 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">정렬 기준</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Select
|
||||
value={localConfig.sortBy}
|
||||
onValueChange={(value) =>
|
||||
setLocalConfig({ ...localConfig, sortBy: value as DriverManagementConfig["sortBy"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
<SelectItem value="name">기사명</SelectItem>
|
||||
<SelectItem value="vehicleNumber">차량번호</SelectItem>
|
||||
<SelectItem value="status">운행상태</SelectItem>
|
||||
<SelectItem value="departureTime">출발시간</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={localConfig.sortOrder}
|
||||
onValueChange={(value) =>
|
||||
setLocalConfig({ ...localConfig, sortOrder: value as DriverManagementConfig["sortOrder"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
<SelectItem value="asc">오름차순</SelectItem>
|
||||
<SelectItem value="desc">내림차순</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 표시 컬럼 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-semibold">표시 컬럼</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setLocalConfig({ ...localConfig, visibleColumns: DEFAULT_VISIBLE_COLUMNS })}
|
||||
>
|
||||
기본값으로
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(COLUMN_LABELS).map(([key, label]) => (
|
||||
<Card
|
||||
key={key}
|
||||
className={`cursor-pointer border p-3 transition-colors ${
|
||||
localConfig.visibleColumns.includes(key) ? "border-primary bg-primary/5" : "hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => toggleColumn(key)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="cursor-pointer text-sm font-medium">{label}</Label>
|
||||
<Switch
|
||||
checked={localConfig.visibleColumns.includes(key)}
|
||||
onCheckedChange={() => toggleColumn(key)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 - 고정 */}
|
||||
<div className="flex flex-shrink-0 justify-end gap-3 border-t border-gray-200 bg-gray-50 p-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>저장</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement, DriverManagementConfig, DriverInfo } from "../types";
|
||||
import { DriverListView } from "./DriverListView";
|
||||
import { DriverManagementSettings } from "./DriverManagementSettings";
|
||||
import { MOCK_DRIVERS } from "./driverMockData";
|
||||
import { filterDrivers, sortDrivers, DEFAULT_VISIBLE_COLUMNS } from "./driverUtils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Settings, Search, RefreshCw } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface DriverManagementWidgetProps {
|
||||
element: DashboardElement;
|
||||
onConfigUpdate?: (config: DriverManagementConfig) => void;
|
||||
}
|
||||
|
||||
export function DriverManagementWidget({ element, onConfigUpdate }: DriverManagementWidgetProps) {
|
||||
const [drivers, setDrivers] = useState<DriverInfo[]>(MOCK_DRIVERS);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [lastRefresh, setLastRefresh] = useState(new Date());
|
||||
|
||||
// 기본 설정
|
||||
const config = element.driverManagementConfig || {
|
||||
viewType: "list",
|
||||
autoRefreshInterval: 30,
|
||||
visibleColumns: DEFAULT_VISIBLE_COLUMNS,
|
||||
theme: "light",
|
||||
statusFilter: "all",
|
||||
sortBy: "name",
|
||||
sortOrder: "asc",
|
||||
};
|
||||
|
||||
// 자동 새로고침
|
||||
useEffect(() => {
|
||||
if (config.autoRefreshInterval <= 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
// 실제 환경에서는 API 호출
|
||||
setDrivers(MOCK_DRIVERS);
|
||||
setLastRefresh(new Date());
|
||||
}, config.autoRefreshInterval * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [config.autoRefreshInterval]);
|
||||
|
||||
// 수동 새로고침
|
||||
const handleRefresh = () => {
|
||||
setDrivers(MOCK_DRIVERS);
|
||||
setLastRefresh(new Date());
|
||||
};
|
||||
|
||||
// 설정 저장
|
||||
const handleSaveSettings = (newConfig: DriverManagementConfig) => {
|
||||
onConfigUpdate?.(newConfig);
|
||||
setSettingsOpen(false);
|
||||
};
|
||||
|
||||
// 필터링 및 정렬
|
||||
const filteredDrivers = sortDrivers(
|
||||
filterDrivers(drivers, config.statusFilter, searchTerm),
|
||||
config.sortBy,
|
||||
config.sortOrder,
|
||||
);
|
||||
|
||||
// 컴팩트 모드 판단 (위젯 크기가 작을 때)
|
||||
const isCompact = element.size.width < 400 || element.size.height < 300;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col bg-white">
|
||||
{/* 헤더 - 컴팩트 모드가 아닐 때만 표시 */}
|
||||
{!isCompact && (
|
||||
<div className="flex-shrink-0 border-b border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* 검색 */}
|
||||
<div className="relative max-w-xs flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="기사명, 차량번호 검색"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select
|
||||
value={config.statusFilter}
|
||||
onValueChange={(value) => {
|
||||
onConfigUpdate?.({
|
||||
...config,
|
||||
statusFilter: value as DriverManagementConfig["statusFilter"],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="driving">운행중</SelectItem>
|
||||
<SelectItem value="standby">대기중</SelectItem>
|
||||
<SelectItem value="resting">휴식중</SelectItem>
|
||||
<SelectItem value="maintenance">점검중</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 새로고침 버튼 */}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 설정 버튼 */}
|
||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[500px] p-0" align="end">
|
||||
<DriverManagementSettings
|
||||
config={config}
|
||||
onSave={handleSaveSettings}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-gray-600">
|
||||
<span>
|
||||
전체 <span className="font-semibold text-gray-900">{filteredDrivers.length}</span>명
|
||||
</span>
|
||||
<span className="text-gray-400">|</span>
|
||||
<span>
|
||||
운행중{" "}
|
||||
<span className="font-semibold text-green-600">
|
||||
{filteredDrivers.filter((d) => d.status === "driving").length}
|
||||
</span>
|
||||
명
|
||||
</span>
|
||||
<span className="text-gray-400">|</span>
|
||||
<span className="text-xs text-gray-500">최근 업데이트: {lastRefresh.toLocaleTimeString("ko-KR")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 리스트 뷰 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<DriverListView drivers={filteredDrivers} config={config} isCompact={isCompact} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
# 리스트 위젯 개발 계획서
|
||||
|
||||
## 📋 개요
|
||||
|
||||
차트와 동일한 방식으로 데이터를 가져오는 리스트(테이블) 위젯 개발
|
||||
|
||||
---
|
||||
|
||||
## 🎯 주요 기능
|
||||
|
||||
### 1. 데이터 소스 (차트와 동일)
|
||||
|
||||
- **내부 DB**: 현재 데이터베이스 쿼리
|
||||
- **외부 DB**: 외부 커넥션 관리에서 설정된 DB 쿼리
|
||||
- **REST API**: 외부 API 호출 (GET 방식)
|
||||
|
||||
### 2. 컬럼 설정
|
||||
|
||||
사용자가 두 가지 방식으로 컬럼을 설정할 수 있음:
|
||||
|
||||
#### 방식 1: 데이터 기반 자동 생성
|
||||
|
||||
1. 쿼리/API 실행 → 데이터 가져옴
|
||||
2. 사용자가 표시할 컬럼 선택
|
||||
3. 컬럼명을 원하는대로 변경 가능
|
||||
4. 컬럼 순서 조정 가능
|
||||
|
||||
```
|
||||
예시:
|
||||
데이터: { userId: 1, userName: "홍길동", deptCode: "DPT001" }
|
||||
↓
|
||||
사용자 설정:
|
||||
- userId → "사용자 ID"
|
||||
- userName → "이름"
|
||||
- deptCode → "부서 코드"
|
||||
```
|
||||
|
||||
#### 방식 2: 수동 컬럼 정의
|
||||
|
||||
1. 사용자가 직접 컬럼 추가
|
||||
2. 각 컬럼의 이름 지정
|
||||
3. 각 컬럼에 들어갈 데이터 필드 매핑
|
||||
|
||||
```
|
||||
예시:
|
||||
컬럼 1: "직원 정보" → userName 필드
|
||||
컬럼 2: "소속" → deptCode 필드
|
||||
컬럼 3: "등록일" → regDate 필드
|
||||
```
|
||||
|
||||
### 3. 테이블 기능
|
||||
|
||||
- **페이지네이션**: 한 페이지당 표시 개수 설정 (10, 20, 50, 100)
|
||||
- **정렬**: 컬럼 클릭 시 오름차순/내림차순 정렬
|
||||
- **검색**: 전체 컬럼에서 키워드 검색
|
||||
- **자동 새로고침**: 설정된 시간마다 자동으로 데이터 갱신
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 구조 설계
|
||||
|
||||
### 파일 구조
|
||||
|
||||
```
|
||||
frontend/components/admin/dashboard/widgets/
|
||||
├── ListWidget.tsx # 메인 위젯 컴포넌트
|
||||
├── ListWidgetConfigModal.tsx # 설정 모달
|
||||
└── list-widget/
|
||||
├── ColumnSelector.tsx # 컬럼 선택 UI
|
||||
├── ManualColumnEditor.tsx # 수동 컬럼 편집 UI
|
||||
└── ListTable.tsx # 실제 테이블 렌더링
|
||||
```
|
||||
|
||||
### 데이터 타입 (`types.ts`에 추가)
|
||||
|
||||
```typescript
|
||||
// 리스트 위젯 설정
|
||||
export interface ListWidgetConfig {
|
||||
// 컬럼 설정 방식
|
||||
columnMode: "auto" | "manual"; // 자동 or 수동
|
||||
|
||||
// 컬럼 정의
|
||||
columns: ListColumn[];
|
||||
|
||||
// 테이블 옵션
|
||||
pageSize: number; // 페이지당 행 수 (기본: 10)
|
||||
enableSearch: boolean; // 검색 활성화 (기본: true)
|
||||
enableSort: boolean; // 정렬 활성화 (기본: true)
|
||||
enablePagination: boolean; // 페이지네이션 활성화 (기본: true)
|
||||
|
||||
// 스타일
|
||||
showHeader: boolean; // 헤더 표시 (기본: true)
|
||||
stripedRows: boolean; // 줄무늬 행 (기본: true)
|
||||
compactMode: boolean; // 압축 모드 (기본: false)
|
||||
}
|
||||
|
||||
// 리스트 컬럼
|
||||
export interface ListColumn {
|
||||
id: string; // 고유 ID
|
||||
label: string; // 표시될 컬럼명
|
||||
field: string; // 데이터 필드명
|
||||
width?: number; // 너비 (px)
|
||||
align?: "left" | "center" | "right"; // 정렬
|
||||
sortable?: boolean; // 정렬 가능 여부
|
||||
visible?: boolean; // 표시 여부
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 개발 단계
|
||||
|
||||
### Phase 1: 기본 구조 ✅ (예정)
|
||||
|
||||
- [ ] `ListWidget.tsx` 기본 컴포넌트 생성
|
||||
- [ ] `types.ts`에 타입 정의 추가
|
||||
- [ ] `DashboardSidebar.tsx`에 리스트 위젯 추가
|
||||
|
||||
### Phase 2: 데이터 소스 연동 ✅ (예정)
|
||||
|
||||
- [ ] 차트의 데이터 소스 로직 재사용
|
||||
- [ ] `ListWidgetConfigModal.tsx` 생성
|
||||
- Step 1: 데이터 소스 선택 (DB/API)
|
||||
- Step 2: 쿼리/API 설정 및 실행
|
||||
- Step 3: 컬럼 설정
|
||||
|
||||
### Phase 3: 컬럼 설정 UI ✅ (예정)
|
||||
|
||||
- [ ] `ColumnSelector.tsx`: 데이터 기반 자동 생성
|
||||
- 컬럼 선택 (체크박스)
|
||||
- 컬럼명 변경 (인라인 편집)
|
||||
- 순서 조정 (드래그 앤 드롭)
|
||||
- [ ] `ManualColumnEditor.tsx`: 수동 컬럼 정의
|
||||
- 컬럼 추가/삭제
|
||||
- 컬럼명 입력
|
||||
- 데이터 필드 매핑
|
||||
|
||||
### Phase 4: 테이블 렌더링 ✅ (예정)
|
||||
|
||||
- [ ] `ListTable.tsx` 구현
|
||||
- 기본 테이블 렌더링
|
||||
- 페이지네이션
|
||||
- 정렬 기능
|
||||
- 검색 기능
|
||||
- 반응형 디자인
|
||||
|
||||
### Phase 5: 자동 새로고침 ✅ (예정)
|
||||
|
||||
- [ ] 자동 새로고침 로직 구현 (차트와 동일)
|
||||
- [ ] 수동 새로고침 버튼 추가
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI/UX 설계
|
||||
|
||||
### 위젯 크기별 표시
|
||||
|
||||
- **작은 크기 (2x2)**: 컬럼 3개까지만 표시, 페이지네이션 간략화
|
||||
- **중간 크기 (3x3)**: 전체 기능 표시
|
||||
- **큰 크기 (4x4+)**: 더 많은 행 표시
|
||||
|
||||
### 설정 모달 플로우
|
||||
|
||||
```
|
||||
Step 1: 데이터 소스 선택
|
||||
├─ 데이터베이스
|
||||
│ ├─ 현재 DB
|
||||
│ └─ 외부 DB (커넥션 선택)
|
||||
└─ REST API
|
||||
|
||||
↓
|
||||
|
||||
Step 2: 데이터 가져오기
|
||||
├─ SQL 쿼리 작성 (DB인 경우)
|
||||
├─ API URL 설정 (API인 경우)
|
||||
└─ [실행] 버튼 클릭 → 데이터 미리보기
|
||||
|
||||
↓
|
||||
|
||||
Step 3: 컬럼 설정
|
||||
├─ 방식 선택: [자동 생성] / [수동 편집]
|
||||
├─ 컬럼 선택 및 설정
|
||||
├─ 테이블 옵션 설정
|
||||
└─ [저장] 버튼
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 참고 사항
|
||||
|
||||
### 차트와의 차이점
|
||||
|
||||
- **차트**: X축/Y축 매핑, 집계 함수, 그룹핑
|
||||
- **리스트**: 컬럼 선택, 정렬, 검색, 페이지네이션
|
||||
|
||||
### 재사용 가능한 컴포넌트
|
||||
|
||||
- `DataSourceSelector` (차트에서 사용 중)
|
||||
- `DatabaseConfig` (차트에서 사용 중)
|
||||
- `ApiConfig` (차트에서 사용 중)
|
||||
- `QueryEditor` (차트에서 사용 중)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 시작하기
|
||||
|
||||
1. `types.ts`에 타입 추가
|
||||
2. `ListWidget.tsx` 기본 구조 생성
|
||||
3. 사이드바에 위젯 추가
|
||||
4. 설정 모달 구현
|
||||
5. 테이블 렌더링 구현
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface ListWidgetProps {
|
||||
element: DashboardElement;
|
||||
onConfigUpdate?: (config: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 위젯 컴포넌트
|
||||
* - DB 쿼리 또는 REST API로 데이터 가져오기
|
||||
* - 테이블 형태로 데이터 표시
|
||||
* - 페이지네이션, 정렬, 검색 기능
|
||||
*/
|
||||
export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||
const [data, setData] = useState<QueryResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const config = element.listConfig || {
|
||||
columnMode: "auto",
|
||||
viewMode: "table",
|
||||
columns: [],
|
||||
pageSize: 10,
|
||||
enablePagination: true,
|
||||
showHeader: true,
|
||||
stripedRows: true,
|
||||
compactMode: false,
|
||||
cardColumns: 3,
|
||||
};
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!element.dataSource || (!element.dataSource.query && !element.dataSource.endpoint)) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let queryResult: QueryResult;
|
||||
|
||||
// REST API vs Database 분기
|
||||
if (element.dataSource.type === "api" && element.dataSource.endpoint) {
|
||||
// REST API - 백엔드 프록시를 통한 호출
|
||||
const params = new URLSearchParams();
|
||||
if (element.dataSource.queryParams) {
|
||||
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: element.dataSource.endpoint,
|
||||
method: "GET",
|
||||
headers: element.dataSource.headers || {},
|
||||
queryParams: Object.fromEntries(params),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 API 호출 실패");
|
||||
}
|
||||
|
||||
const apiData = result.data;
|
||||
|
||||
// JSON Path 처리
|
||||
let processedData = apiData;
|
||||
if (element.dataSource.jsonPath) {
|
||||
const paths = element.dataSource.jsonPath.split(".");
|
||||
for (const path of paths) {
|
||||
if (processedData && typeof processedData === "object" && path in processedData) {
|
||||
processedData = processedData[path];
|
||||
} else {
|
||||
throw new Error(`JSON Path "${element.dataSource.jsonPath}"에서 데이터를 찾을 수 없습니다`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Array.isArray(processedData) ? processedData : [processedData];
|
||||
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||
|
||||
queryResult = {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else if (element.dataSource.query) {
|
||||
// Database (현재 DB 또는 외부 DB)
|
||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||
// 외부 DB
|
||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(element.dataSource.externalConnectionId),
|
||||
element.dataSource.query,
|
||||
);
|
||||
if (!externalResult.success) {
|
||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
queryResult = {
|
||||
columns: externalResult.data.columns,
|
||||
rows: externalResult.data.rows,
|
||||
totalRows: externalResult.data.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else {
|
||||
// 현재 DB
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
queryResult = {
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
totalRows: result.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw new Error("데이터 소스가 올바르게 설정되지 않았습니다");
|
||||
}
|
||||
|
||||
setData(queryResult);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
|
||||
// 자동 새로고침 설정
|
||||
const refreshInterval = element.dataSource?.refreshInterval;
|
||||
if (refreshInterval && refreshInterval > 0) {
|
||||
const interval = setInterval(loadData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [
|
||||
element.dataSource?.query,
|
||||
element.dataSource?.connectionType,
|
||||
element.dataSource?.externalConnectionId,
|
||||
element.dataSource?.endpoint,
|
||||
element.dataSource?.refreshInterval,
|
||||
]);
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
<div className="text-sm text-gray-600">데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-2xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-red-600">오류 발생</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 또는 설정 없음
|
||||
if (!data || config.columns.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📋</div>
|
||||
<div className="text-sm font-medium text-gray-700">리스트를 설정하세요</div>
|
||||
<div className="mt-1 text-xs text-gray-500">⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(data.rows.length / config.pageSize);
|
||||
const startIdx = (currentPage - 1) * config.pageSize;
|
||||
const endIdx = startIdx + config.pageSize;
|
||||
const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">{element.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* 테이블 뷰 */}
|
||||
{config.viewMode === "table" && (
|
||||
<div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}>
|
||||
<Table>
|
||||
{config.showHeader && (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{config.columns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<TableHead
|
||||
key={col.id}
|
||||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||
style={{ width: col.width ? `${col.width}px` : undefined }}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
)}
|
||||
<TableBody>
|
||||
{paginatedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={config.columns.filter((col) => col.visible).length}
|
||||
className="text-center text-gray-500"
|
||||
>
|
||||
데이터가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedRows.map((row, idx) => (
|
||||
<TableRow key={idx} className={config.stripedRows ? undefined : ""}>
|
||||
{config.columns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||
>
|
||||
{String(row[col.field] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카드 뷰 */}
|
||||
{config.viewMode === "card" && (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{paginatedRows.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-500">데이터가 없습니다</div>
|
||||
) : (
|
||||
<div
|
||||
className={`grid gap-4 ${config.compactMode ? "text-xs" : "text-sm"}`}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${config.cardColumns || 3}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{paginatedRows.map((row, idx) => (
|
||||
<Card key={idx} className="p-4 transition-shadow hover:shadow-md">
|
||||
<div className="space-y-2">
|
||||
{config.columns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<div key={col.id}>
|
||||
<div className="text-xs font-medium text-gray-500">{col.label}</div>
|
||||
<div
|
||||
className={`font-medium text-gray-900 ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
||||
>
|
||||
{String(row[col.field] ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{config.enablePagination && totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-600">
|
||||
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<span className="text-gray-700">{currentPage}</span>
|
||||
<span className="text-gray-400">/</span>
|
||||
<span className="text-gray-500">{totalPages}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig, ListColumn } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ChevronLeft, ChevronRight, Save, X } from "lucide-react";
|
||||
import { DataSourceSelector } from "../data-sources/DataSourceSelector";
|
||||
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "../data-sources/ApiConfig";
|
||||
import { QueryEditor } from "../QueryEditor";
|
||||
import { ColumnSelector } from "./list-widget/ColumnSelector";
|
||||
import { ManualColumnEditor } from "./list-widget/ManualColumnEditor";
|
||||
import { ListTableOptions } from "./list-widget/ListTableOptions";
|
||||
|
||||
interface ListWidgetConfigModalProps {
|
||||
isOpen: boolean;
|
||||
element: DashboardElement;
|
||||
onClose: () => void;
|
||||
onSave: (updates: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 위젯 설정 모달
|
||||
* - 3단계 설정: 데이터 소스 → 데이터 가져오기 → 컬럼 설정
|
||||
*/
|
||||
export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: ListWidgetConfigModalProps) {
|
||||
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
|
||||
const [title, setTitle] = useState(element.title || "📋 리스트");
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [listConfig, setListConfig] = useState<ListWidgetConfig>(
|
||||
element.listConfig || {
|
||||
columnMode: "auto",
|
||||
viewMode: "table",
|
||||
columns: [],
|
||||
pageSize: 10,
|
||||
enablePagination: true,
|
||||
showHeader: true,
|
||||
stripedRows: true,
|
||||
compactMode: false,
|
||||
cardColumns: 3,
|
||||
},
|
||||
);
|
||||
|
||||
// 모달 열릴 때 element에서 설정 로드 (한 번만)
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// element가 변경되었을 때만 설정을 다시 로드
|
||||
setTitle(element.title || "📋 리스트");
|
||||
|
||||
// 기존 dataSource가 있으면 그대로 사용, 없으면 기본값
|
||||
if (element.dataSource) {
|
||||
setDataSource(element.dataSource);
|
||||
}
|
||||
|
||||
// 기존 listConfig가 있으면 그대로 사용, 없으면 기본값
|
||||
if (element.listConfig) {
|
||||
setListConfig(element.listConfig);
|
||||
}
|
||||
|
||||
// 현재 스텝은 1로 초기화
|
||||
setCurrentStep(1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, element.id]); // element.id가 변경될 때만 재실행
|
||||
|
||||
// 데이터 소스 타입 변경
|
||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||
if (type === "database") {
|
||||
setDataSource((prev) => ({
|
||||
...prev,
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
}));
|
||||
} else {
|
||||
setDataSource((prev) => ({
|
||||
...prev,
|
||||
type: "api",
|
||||
method: "GET",
|
||||
}));
|
||||
}
|
||||
|
||||
// 데이터 소스 타입 변경 시에는 쿼리 결과만 초기화 (컬럼 설정은 유지)
|
||||
setQueryResult(null);
|
||||
}, []);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 쿼리 실행 결과 처리
|
||||
const handleQueryTest = useCallback(
|
||||
(result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
|
||||
// 자동 모드이고 기존 컬럼이 없을 때만 자동 생성
|
||||
if (listConfig.columnMode === "auto" && result.columns.length > 0 && listConfig.columns.length === 0) {
|
||||
const autoColumns: ListColumn[] = result.columns.map((col, idx) => ({
|
||||
id: `col_${idx}`,
|
||||
label: col,
|
||||
field: col,
|
||||
align: "left",
|
||||
visible: true,
|
||||
}));
|
||||
setListConfig((prev) => ({ ...prev, columns: autoColumns }));
|
||||
}
|
||||
},
|
||||
[listConfig.columnMode, listConfig.columns.length],
|
||||
);
|
||||
|
||||
// 다음 단계
|
||||
const handleNext = () => {
|
||||
if (currentStep < 3) {
|
||||
setCurrentStep((prev) => (prev + 1) as 1 | 2 | 3);
|
||||
}
|
||||
};
|
||||
|
||||
// 이전 단계
|
||||
const handlePrev = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((prev) => (prev - 1) as 1 | 2 | 3);
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
title,
|
||||
dataSource,
|
||||
listConfig,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 저장 가능 여부
|
||||
const canSave = queryResult && queryResult.rows.length > 0 && listConfig.columns.length > 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-[90vw] max-w-6xl flex-col rounded-xl border bg-white shadow-2xl">
|
||||
{/* 헤더 */}
|
||||
<div className="space-y-4 border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">📋 리스트 위젯 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">데이터 소스와 컬럼을 설정하세요</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-gray-100">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* 제목 입력 */}
|
||||
<div>
|
||||
<Label htmlFor="list-title" className="text-sm font-medium">
|
||||
리스트 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="list-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 사용자 목록"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행 상태 표시 */}
|
||||
<div className="border-b bg-gray-50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 1 ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 1 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<span className="text-sm font-medium">데이터 소스</span>
|
||||
</div>
|
||||
<div className="h-0.5 w-12 bg-gray-300" />
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 2 ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 2 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<span className="text-sm font-medium">데이터 가져오기</span>
|
||||
</div>
|
||||
<div className="h-0.5 w-12 bg-gray-300" />
|
||||
<div className={`flex items-center gap-2 ${currentStep >= 3 ? "text-blue-600" : "text-gray-400"}`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 3 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<span className="text-sm font-medium">컬럼 설정</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{currentStep === 1 && (
|
||||
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 왼쪽: 데이터 소스 설정 */}
|
||||
<div>
|
||||
{dataSource.type === "database" ? (
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
) : (
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
)}
|
||||
|
||||
{dataSource.type === "database" && (
|
||||
<div className="mt-4">
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 데이터 미리보기 */}
|
||||
<div>
|
||||
{queryResult && queryResult.rows.length > 0 ? (
|
||||
<div className="rounded-lg border bg-gray-50 p-4">
|
||||
<h3 className="mb-3 font-semibold text-gray-800">📋 데이터 미리보기</h3>
|
||||
<div className="overflow-x-auto rounded bg-white p-3">
|
||||
<Badge variant="secondary" className="mb-2">
|
||||
{queryResult.totalRows}개 데이터
|
||||
</Badge>
|
||||
<pre className="text-xs text-gray-700">
|
||||
{JSON.stringify(queryResult.rows.slice(0, 3), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 미리보기가 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && queryResult && (
|
||||
<div className="space-y-6">
|
||||
{listConfig.columnMode === "auto" ? (
|
||||
<ColumnSelector
|
||||
availableColumns={queryResult.columns}
|
||||
selectedColumns={listConfig.columns}
|
||||
sampleData={queryResult.rows[0]}
|
||||
onChange={(columns) => setListConfig((prev) => ({ ...prev, columns }))}
|
||||
/>
|
||||
) : (
|
||||
<ManualColumnEditor
|
||||
availableFields={queryResult.columns}
|
||||
columns={listConfig.columns}
|
||||
onChange={(columns) => setListConfig((prev) => ({ ...prev, columns }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ListTableOptions
|
||||
config={listConfig}
|
||||
onChange={(updates) => setListConfig((prev) => ({ ...prev, ...updates }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
|
||||
<div>
|
||||
{queryResult && (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
📊 {queryResult.rows.length}개 데이터 로드됨
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
{currentStep < 3 ? (
|
||||
<Button onClick={handleNext} disabled={currentStep === 2 && !queryResult}>
|
||||
다음
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import { CalendarConfig } from "../types";
|
||||
import { CalendarDay, getWeekDayNames } from "./calendarUtils";
|
||||
|
||||
interface MonthViewProps {
|
||||
days: CalendarDay[];
|
||||
config: CalendarConfig;
|
||||
isCompact?: boolean; // 작은 크기 (2x2, 3x3)
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 달력 뷰 컴포넌트
|
||||
*/
|
||||
export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
|
||||
const weekDayNames = getWeekDayNames(config.startWeekOn);
|
||||
|
||||
// 테마별 스타일
|
||||
const getThemeStyles = () => {
|
||||
if (config.theme === "custom" && config.customColor) {
|
||||
return {
|
||||
todayBg: config.customColor,
|
||||
holidayText: config.customColor,
|
||||
weekendText: "#dc2626",
|
||||
};
|
||||
}
|
||||
|
||||
if (config.theme === "dark") {
|
||||
return {
|
||||
todayBg: "#3b82f6",
|
||||
holidayText: "#f87171",
|
||||
weekendText: "#f87171",
|
||||
};
|
||||
}
|
||||
|
||||
// light 테마
|
||||
return {
|
||||
todayBg: "#3b82f6",
|
||||
holidayText: "#dc2626",
|
||||
weekendText: "#dc2626",
|
||||
};
|
||||
};
|
||||
|
||||
const themeStyles = getThemeStyles();
|
||||
|
||||
// 날짜 셀 스타일 클래스
|
||||
const getDayCellClass = (day: CalendarDay) => {
|
||||
const baseClass = "flex aspect-square items-center justify-center rounded-lg transition-colors";
|
||||
const sizeClass = isCompact ? "text-xs" : "text-sm";
|
||||
|
||||
let colorClass = "text-gray-700";
|
||||
|
||||
// 현재 월이 아닌 날짜
|
||||
if (!day.isCurrentMonth) {
|
||||
colorClass = "text-gray-300";
|
||||
}
|
||||
// 오늘
|
||||
else if (config.highlightToday && day.isToday) {
|
||||
colorClass = "text-white font-bold";
|
||||
}
|
||||
// 공휴일
|
||||
else if (config.showHolidays && day.isHoliday) {
|
||||
colorClass = "font-semibold";
|
||||
}
|
||||
// 주말
|
||||
else if (config.highlightWeekends && day.isWeekend) {
|
||||
colorClass = "text-red-600";
|
||||
}
|
||||
|
||||
const bgClass = config.highlightToday && day.isToday ? "" : "hover:bg-gray-100";
|
||||
|
||||
return `${baseClass} ${sizeClass} ${colorClass} ${bgClass}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-2">
|
||||
{/* 요일 헤더 */}
|
||||
{!isCompact && (
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{weekDayNames.map((name, index) => {
|
||||
const isWeekend = config.startWeekOn === "sunday" ? index === 0 || index === 6 : index === 5 || index === 6;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={`text-center text-xs font-semibold ${isWeekend && config.highlightWeekends ? "text-red-600" : "text-gray-600"}`}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 날짜 그리드 */}
|
||||
<div className="grid flex-1 grid-cols-7 gap-1">
|
||||
{days.map((day, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={getDayCellClass(day)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
config.highlightToday && day.isToday ? themeStyles.todayBg : undefined,
|
||||
color:
|
||||
config.showHolidays && day.isHoliday && day.isCurrentMonth
|
||||
? themeStyles.holidayText
|
||||
: undefined,
|
||||
}}
|
||||
title={day.isHoliday ? day.holidayName : undefined}
|
||||
>
|
||||
{day.day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* 달력 유틸리티 함수
|
||||
*/
|
||||
|
||||
// 한국 공휴일 데이터 (2025년 기준)
|
||||
export interface Holiday {
|
||||
date: string; // 'MM-DD' 형식
|
||||
name: string;
|
||||
isRecurring: boolean;
|
||||
}
|
||||
|
||||
export const KOREAN_HOLIDAYS: Holiday[] = [
|
||||
{ date: "01-01", name: "신정", isRecurring: true },
|
||||
{ date: "01-28", name: "설날 연휴", isRecurring: false },
|
||||
{ date: "01-29", name: "설날", isRecurring: false },
|
||||
{ date: "01-30", name: "설날 연휴", isRecurring: false },
|
||||
{ date: "03-01", name: "삼일절", isRecurring: true },
|
||||
{ date: "05-05", name: "어린이날", isRecurring: true },
|
||||
{ date: "06-06", name: "현충일", isRecurring: true },
|
||||
{ date: "08-15", name: "광복절", isRecurring: true },
|
||||
{ date: "10-03", name: "개천절", isRecurring: true },
|
||||
{ date: "10-09", name: "한글날", isRecurring: true },
|
||||
{ date: "12-25", name: "크리스마스", isRecurring: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* 특정 월의 첫 날 Date 객체 반환
|
||||
*/
|
||||
export function getFirstDayOfMonth(year: number, month: number): Date {
|
||||
return new Date(year, month, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 월의 마지막 날짜 반환
|
||||
*/
|
||||
export function getLastDateOfMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 월의 첫 날의 요일 반환 (0=일요일, 1=월요일, ...)
|
||||
*/
|
||||
export function getFirstDayOfWeek(year: number, month: number): number {
|
||||
return new Date(year, month, 1).getDay();
|
||||
}
|
||||
|
||||
/**
|
||||
* 달력 그리드에 표시할 날짜 배열 생성
|
||||
* @param year 년도
|
||||
* @param month 월 (0-11)
|
||||
* @param startWeekOn 주 시작 요일 ('monday' | 'sunday')
|
||||
* @returns 6주 * 7일 = 42개의 날짜 정보 배열
|
||||
*/
|
||||
export interface CalendarDay {
|
||||
date: Date;
|
||||
day: number;
|
||||
isCurrentMonth: boolean;
|
||||
isToday: boolean;
|
||||
isWeekend: boolean;
|
||||
isHoliday: boolean;
|
||||
holidayName?: string;
|
||||
}
|
||||
|
||||
export function generateCalendarDays(
|
||||
year: number,
|
||||
month: number,
|
||||
startWeekOn: "monday" | "sunday" = "sunday",
|
||||
): CalendarDay[] {
|
||||
const days: CalendarDay[] = [];
|
||||
const firstDay = getFirstDayOfWeek(year, month);
|
||||
const lastDate = getLastDateOfMonth(year, month);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// 시작 오프셋 계산
|
||||
let startOffset = firstDay;
|
||||
if (startWeekOn === "monday") {
|
||||
startOffset = firstDay === 0 ? 6 : firstDay - 1;
|
||||
}
|
||||
|
||||
// 이전 달 날짜들
|
||||
const prevMonthLastDate = getLastDateOfMonth(year, month - 1);
|
||||
for (let i = startOffset - 1; i >= 0; i--) {
|
||||
const date = new Date(year, month - 1, prevMonthLastDate - i);
|
||||
days.push(createCalendarDay(date, false, today));
|
||||
}
|
||||
|
||||
// 현재 달 날짜들
|
||||
for (let day = 1; day <= lastDate; day++) {
|
||||
const date = new Date(year, month, day);
|
||||
days.push(createCalendarDay(date, true, today));
|
||||
}
|
||||
|
||||
// 다음 달 날짜들 (42개 채우기)
|
||||
const remainingDays = 42 - days.length;
|
||||
for (let day = 1; day <= remainingDays; day++) {
|
||||
const date = new Date(year, month + 1, day);
|
||||
days.push(createCalendarDay(date, false, today));
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* CalendarDay 객체 생성
|
||||
*/
|
||||
function createCalendarDay(date: Date, isCurrentMonth: boolean, today: Date): CalendarDay {
|
||||
const dayOfWeek = date.getDay();
|
||||
const isToday = date.getTime() === today.getTime();
|
||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||
|
||||
// 공휴일 체크
|
||||
const monthStr = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const dayStr = String(date.getDate()).padStart(2, "0");
|
||||
const dateKey = `${monthStr}-${dayStr}`;
|
||||
const holiday = KOREAN_HOLIDAYS.find((h) => h.date === dateKey);
|
||||
|
||||
return {
|
||||
date,
|
||||
day: date.getDate(),
|
||||
isCurrentMonth,
|
||||
isToday,
|
||||
isWeekend,
|
||||
isHoliday: !!holiday,
|
||||
holidayName: holiday?.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 요일 이름 배열 반환
|
||||
*/
|
||||
export function getWeekDayNames(startWeekOn: "monday" | "sunday" = "sunday"): string[] {
|
||||
const sundayFirst = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
const mondayFirst = ["월", "화", "수", "목", "금", "토", "일"];
|
||||
return startWeekOn === "monday" ? mondayFirst : sundayFirst;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월 이름 반환
|
||||
*/
|
||||
export function getMonthName(month: number): string {
|
||||
const months = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"];
|
||||
return months[month];
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전/다음 월로 이동
|
||||
*/
|
||||
export function navigateMonth(year: number, month: number, direction: "prev" | "next"): { year: number; month: number } {
|
||||
if (direction === "prev") {
|
||||
if (month === 0) {
|
||||
return { year: year - 1, month: 11 };
|
||||
}
|
||||
return { year, month: month - 1 };
|
||||
} else {
|
||||
if (month === 11) {
|
||||
return { year: year + 1, month: 0 };
|
||||
}
|
||||
return { year, month: month + 1 };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import { DriverInfo } from "../types";
|
||||
|
||||
/**
|
||||
* 기사 관리 목업 데이터
|
||||
* 실제 환경에서는 REST API로 대체됨
|
||||
*/
|
||||
export const MOCK_DRIVERS: DriverInfo[] = [
|
||||
{
|
||||
id: "DRV001",
|
||||
name: "홍길동",
|
||||
vehicleNumber: "12가 3456",
|
||||
vehicleType: "1톤 트럭",
|
||||
phone: "010-1234-5678",
|
||||
status: "driving",
|
||||
departure: "서울시 강남구",
|
||||
destination: "경기도 성남시",
|
||||
departureTime: "2025-10-14T09:00:00",
|
||||
estimatedArrival: "2025-10-14T11:30:00",
|
||||
progress: 65,
|
||||
},
|
||||
{
|
||||
id: "DRV002",
|
||||
name: "김철수",
|
||||
vehicleNumber: "34나 7890",
|
||||
vehicleType: "2.5톤 트럭",
|
||||
phone: "010-2345-6789",
|
||||
status: "standby",
|
||||
},
|
||||
{
|
||||
id: "DRV003",
|
||||
name: "이영희",
|
||||
vehicleNumber: "56다 1234",
|
||||
vehicleType: "5톤 트럭",
|
||||
phone: "010-3456-7890",
|
||||
status: "driving",
|
||||
departure: "인천광역시",
|
||||
destination: "충청남도 천안시",
|
||||
departureTime: "2025-10-14T08:30:00",
|
||||
estimatedArrival: "2025-10-14T10:00:00",
|
||||
progress: 85,
|
||||
},
|
||||
{
|
||||
id: "DRV004",
|
||||
name: "박민수",
|
||||
vehicleNumber: "78라 5678",
|
||||
vehicleType: "카고",
|
||||
phone: "010-4567-8901",
|
||||
status: "resting",
|
||||
},
|
||||
{
|
||||
id: "DRV005",
|
||||
name: "정수진",
|
||||
vehicleNumber: "90마 9012",
|
||||
vehicleType: "냉동차",
|
||||
phone: "010-5678-9012",
|
||||
status: "maintenance",
|
||||
},
|
||||
{
|
||||
id: "DRV006",
|
||||
name: "최동욱",
|
||||
vehicleNumber: "11아 3344",
|
||||
vehicleType: "1톤 트럭",
|
||||
phone: "010-6789-0123",
|
||||
status: "driving",
|
||||
departure: "부산광역시",
|
||||
destination: "울산광역시",
|
||||
departureTime: "2025-10-14T07:45:00",
|
||||
estimatedArrival: "2025-10-14T09:15:00",
|
||||
progress: 92,
|
||||
},
|
||||
{
|
||||
id: "DRV007",
|
||||
name: "강미선",
|
||||
vehicleNumber: "22자 5566",
|
||||
vehicleType: "탑차",
|
||||
phone: "010-7890-1234",
|
||||
status: "standby",
|
||||
},
|
||||
{
|
||||
id: "DRV008",
|
||||
name: "윤성호",
|
||||
vehicleNumber: "33차 7788",
|
||||
vehicleType: "2.5톤 트럭",
|
||||
phone: "010-8901-2345",
|
||||
status: "driving",
|
||||
departure: "대전광역시",
|
||||
destination: "세종특별자치시",
|
||||
departureTime: "2025-10-14T10:20:00",
|
||||
estimatedArrival: "2025-10-14T11:00:00",
|
||||
progress: 45,
|
||||
},
|
||||
{
|
||||
id: "DRV009",
|
||||
name: "장혜진",
|
||||
vehicleNumber: "44카 9900",
|
||||
vehicleType: "냉동차",
|
||||
phone: "010-9012-3456",
|
||||
status: "resting",
|
||||
},
|
||||
{
|
||||
id: "DRV010",
|
||||
name: "임태양",
|
||||
vehicleNumber: "55타 1122",
|
||||
vehicleType: "5톤 트럭",
|
||||
phone: "010-0123-4567",
|
||||
status: "driving",
|
||||
departure: "광주광역시",
|
||||
destination: "전라남도 목포시",
|
||||
departureTime: "2025-10-14T06:30:00",
|
||||
estimatedArrival: "2025-10-14T08:45:00",
|
||||
progress: 78,
|
||||
},
|
||||
{
|
||||
id: "DRV011",
|
||||
name: "오준석",
|
||||
vehicleNumber: "66파 3344",
|
||||
vehicleType: "카고",
|
||||
phone: "010-1111-2222",
|
||||
status: "standby",
|
||||
},
|
||||
{
|
||||
id: "DRV012",
|
||||
name: "한소희",
|
||||
vehicleNumber: "77하 5566",
|
||||
vehicleType: "1톤 트럭",
|
||||
phone: "010-2222-3333",
|
||||
status: "maintenance",
|
||||
},
|
||||
{
|
||||
id: "DRV013",
|
||||
name: "송민재",
|
||||
vehicleNumber: "88거 7788",
|
||||
vehicleType: "탑차",
|
||||
phone: "010-3333-4444",
|
||||
status: "driving",
|
||||
departure: "경기도 수원시",
|
||||
destination: "경기도 평택시",
|
||||
departureTime: "2025-10-14T09:50:00",
|
||||
estimatedArrival: "2025-10-14T11:20:00",
|
||||
progress: 38,
|
||||
},
|
||||
{
|
||||
id: "DRV014",
|
||||
name: "배수지",
|
||||
vehicleNumber: "99너 9900",
|
||||
vehicleType: "2.5톤 트럭",
|
||||
phone: "010-4444-5555",
|
||||
status: "driving",
|
||||
departure: "강원도 춘천시",
|
||||
destination: "강원도 원주시",
|
||||
departureTime: "2025-10-14T08:00:00",
|
||||
estimatedArrival: "2025-10-14T09:30:00",
|
||||
progress: 72,
|
||||
},
|
||||
{
|
||||
id: "DRV015",
|
||||
name: "신동엽",
|
||||
vehicleNumber: "00더 1122",
|
||||
vehicleType: "5톤 트럭",
|
||||
phone: "010-5555-6666",
|
||||
status: "standby",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 차량 유형 목록
|
||||
*/
|
||||
export const VEHICLE_TYPES = ["1톤 트럭", "2.5톤 트럭", "5톤 트럭", "카고", "탑차", "냉동차"];
|
||||
|
||||
/**
|
||||
* 운행 상태별 통계 계산
|
||||
*/
|
||||
export function getDriverStatistics(drivers: DriverInfo[]) {
|
||||
return {
|
||||
total: drivers.length,
|
||||
driving: drivers.filter((d) => d.status === "driving").length,
|
||||
standby: drivers.filter((d) => d.status === "standby").length,
|
||||
resting: drivers.filter((d) => d.status === "resting").length,
|
||||
maintenance: drivers.filter((d) => d.status === "maintenance").length,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
import { DriverInfo, DriverManagementConfig } from "../types";
|
||||
|
||||
/**
|
||||
* 운행 상태별 색상 반환
|
||||
*/
|
||||
export function getStatusColor(status: DriverInfo["status"]) {
|
||||
switch (status) {
|
||||
case "driving":
|
||||
return {
|
||||
bg: "bg-green-100",
|
||||
text: "text-green-800",
|
||||
border: "border-green-300",
|
||||
badge: "bg-green-500",
|
||||
};
|
||||
case "standby":
|
||||
return {
|
||||
bg: "bg-gray-100",
|
||||
text: "text-gray-800",
|
||||
border: "border-gray-300",
|
||||
badge: "bg-gray-500",
|
||||
};
|
||||
case "resting":
|
||||
return {
|
||||
bg: "bg-orange-100",
|
||||
text: "text-orange-800",
|
||||
border: "border-orange-300",
|
||||
badge: "bg-orange-500",
|
||||
};
|
||||
case "maintenance":
|
||||
return {
|
||||
bg: "bg-red-100",
|
||||
text: "text-red-800",
|
||||
border: "border-red-300",
|
||||
badge: "bg-red-500",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: "bg-gray-100",
|
||||
text: "text-gray-800",
|
||||
border: "border-gray-300",
|
||||
badge: "bg-gray-500",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 상태 한글 변환
|
||||
*/
|
||||
export function getStatusLabel(status: DriverInfo["status"]) {
|
||||
switch (status) {
|
||||
case "driving":
|
||||
return "운행중";
|
||||
case "standby":
|
||||
return "대기중";
|
||||
case "resting":
|
||||
return "휴식중";
|
||||
case "maintenance":
|
||||
return "점검중";
|
||||
default:
|
||||
return "알 수 없음";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간 포맷팅 (HH:MM)
|
||||
*/
|
||||
export function formatTime(dateString?: string): string {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString("ko-KR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 시간 포맷팅 (MM/DD HH:MM)
|
||||
*/
|
||||
export function formatDateTime(dateString?: string): string {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 운행 진행률 계산 (실제로는 GPS 데이터 기반)
|
||||
*/
|
||||
export function calculateProgress(driver: DriverInfo): number {
|
||||
if (!driver.departureTime || !driver.estimatedArrival) return 0;
|
||||
|
||||
const now = new Date();
|
||||
const departure = new Date(driver.departureTime);
|
||||
const arrival = new Date(driver.estimatedArrival);
|
||||
|
||||
const totalTime = arrival.getTime() - departure.getTime();
|
||||
const elapsedTime = now.getTime() - departure.getTime();
|
||||
|
||||
const progress = Math.min(100, Math.max(0, (elapsedTime / totalTime) * 100));
|
||||
return Math.round(progress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기사 필터링
|
||||
*/
|
||||
export function filterDrivers(
|
||||
drivers: DriverInfo[],
|
||||
statusFilter: DriverManagementConfig["statusFilter"],
|
||||
searchTerm: string,
|
||||
): DriverInfo[] {
|
||||
let filtered = drivers;
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== "all") {
|
||||
filtered = filtered.filter((driver) => driver.status === statusFilter);
|
||||
}
|
||||
|
||||
// 검색어 필터
|
||||
if (searchTerm.trim()) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(driver) =>
|
||||
driver.name.toLowerCase().includes(term) ||
|
||||
driver.vehicleNumber.toLowerCase().includes(term) ||
|
||||
driver.phone.includes(term),
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기사 정렬
|
||||
*/
|
||||
export function sortDrivers(
|
||||
drivers: DriverInfo[],
|
||||
sortBy: DriverManagementConfig["sortBy"],
|
||||
sortOrder: DriverManagementConfig["sortOrder"],
|
||||
): DriverInfo[] {
|
||||
const sorted = [...drivers];
|
||||
|
||||
sorted.sort((a, b) => {
|
||||
let compareResult = 0;
|
||||
|
||||
switch (sortBy) {
|
||||
case "name":
|
||||
compareResult = a.name.localeCompare(b.name, "ko-KR");
|
||||
break;
|
||||
case "vehicleNumber":
|
||||
compareResult = a.vehicleNumber.localeCompare(b.vehicleNumber);
|
||||
break;
|
||||
case "status":
|
||||
const statusOrder = { driving: 0, resting: 1, standby: 2, maintenance: 3 };
|
||||
compareResult = statusOrder[a.status] - statusOrder[b.status];
|
||||
break;
|
||||
case "departureTime":
|
||||
const timeA = a.departureTime ? new Date(a.departureTime).getTime() : 0;
|
||||
const timeB = b.departureTime ? new Date(b.departureTime).getTime() : 0;
|
||||
compareResult = timeA - timeB;
|
||||
break;
|
||||
}
|
||||
|
||||
return sortOrder === "asc" ? compareResult : -compareResult;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테마별 색상 반환
|
||||
*/
|
||||
export function getThemeColors(theme: string, customColor?: string) {
|
||||
if (theme === "custom" && customColor) {
|
||||
const lighterColor = adjustColor(customColor, 40);
|
||||
const darkerColor = adjustColor(customColor, -40);
|
||||
|
||||
return {
|
||||
background: lighterColor,
|
||||
text: darkerColor,
|
||||
border: customColor,
|
||||
hover: customColor,
|
||||
};
|
||||
}
|
||||
|
||||
if (theme === "dark") {
|
||||
return {
|
||||
background: "#1f2937",
|
||||
text: "#f3f4f6",
|
||||
border: "#374151",
|
||||
hover: "#374151",
|
||||
};
|
||||
}
|
||||
|
||||
// light theme (default)
|
||||
return {
|
||||
background: "#ffffff",
|
||||
text: "#1f2937",
|
||||
border: "#e5e7eb",
|
||||
hover: "#f3f4f6",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 색상 밝기 조정
|
||||
*/
|
||||
function adjustColor(color: string, amount: number): string {
|
||||
const clamp = (num: number) => Math.min(255, Math.max(0, num));
|
||||
|
||||
const hex = color.replace("#", "");
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
const newR = clamp(r + amount);
|
||||
const newG = clamp(g + amount);
|
||||
const newB = clamp(b + amount);
|
||||
|
||||
return `#${newR.toString(16).padStart(2, "0")}${newG.toString(16).padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 표시 컬럼 목록
|
||||
*/
|
||||
export const DEFAULT_VISIBLE_COLUMNS = [
|
||||
"status",
|
||||
"name",
|
||||
"vehicleNumber",
|
||||
"vehicleType",
|
||||
"departure",
|
||||
"destination",
|
||||
"departureTime",
|
||||
"estimatedArrival",
|
||||
"phone",
|
||||
];
|
||||
|
||||
/**
|
||||
* 컬럼 라벨 매핑
|
||||
*/
|
||||
export const COLUMN_LABELS: Record<string, string> = {
|
||||
status: "상태",
|
||||
name: "기사명",
|
||||
vehicleNumber: "차량번호",
|
||||
vehicleType: "차량유형",
|
||||
departure: "출발지",
|
||||
destination: "목적지",
|
||||
departureTime: "출발시간",
|
||||
estimatedArrival: "도착예정",
|
||||
phone: "연락처",
|
||||
progress: "진행률",
|
||||
};
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ListColumn } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
||||
interface ColumnSelectorProps {
|
||||
availableColumns: string[];
|
||||
selectedColumns: ListColumn[];
|
||||
sampleData: Record<string, any>;
|
||||
onChange: (columns: ListColumn[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 선택 컴포넌트 (자동 모드)
|
||||
* - 쿼리 결과에서 컬럼 선택
|
||||
* - 컬럼명 변경
|
||||
* - 정렬, 너비, 정렬 방향 설정
|
||||
*/
|
||||
export function ColumnSelector({ availableColumns, selectedColumns, sampleData, onChange }: ColumnSelectorProps) {
|
||||
// 컬럼 선택/해제
|
||||
const handleToggle = (field: string) => {
|
||||
const exists = selectedColumns.find((col) => col.field === field);
|
||||
if (exists) {
|
||||
onChange(selectedColumns.filter((col) => col.field !== field));
|
||||
} else {
|
||||
const newCol: ListColumn = {
|
||||
id: `col_${selectedColumns.length}`,
|
||||
label: field,
|
||||
field,
|
||||
align: "left",
|
||||
visible: true,
|
||||
};
|
||||
onChange([...selectedColumns, newCol]);
|
||||
}
|
||||
};
|
||||
|
||||
// 컬럼 라벨 변경
|
||||
const handleLabelChange = (field: string, label: string) => {
|
||||
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, label } : col)));
|
||||
};
|
||||
|
||||
// 정렬 방향 변경
|
||||
const handleAlignChange = (field: string, align: "left" | "center" | "right") => {
|
||||
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">컬럼 선택 및 설정</h3>
|
||||
<p className="text-sm text-gray-600">표시할 컬럼을 선택하고 이름을 변경하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{availableColumns.map((field) => {
|
||||
const selectedCol = selectedColumns.find((col) => col.field === field);
|
||||
const isSelected = !!selectedCol;
|
||||
const preview = sampleData[field];
|
||||
const previewText =
|
||||
preview !== undefined && preview !== null
|
||||
? typeof preview === "object"
|
||||
? JSON.stringify(preview).substring(0, 30)
|
||||
: String(preview).substring(0, 30)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field}
|
||||
className={`rounded-lg border p-4 transition-colors ${
|
||||
isSelected ? "border-blue-300 bg-blue-50" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-start gap-3">
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => handleToggle(field)} className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">{field}</span>
|
||||
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelected && selectedCol && (
|
||||
<div className="ml-7 grid grid-cols-2 gap-3">
|
||||
{/* 컬럼명 */}
|
||||
<div>
|
||||
<Label className="text-xs">표시 이름</Label>
|
||||
<Input
|
||||
value={selectedCol.label}
|
||||
onChange={(e) => handleLabelChange(field, e.target.value)}
|
||||
placeholder="컬럼명"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div>
|
||||
<Label className="text-xs">정렬</Label>
|
||||
<Select
|
||||
value={selectedCol.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleAlignChange(field, value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedColumns.length === 0 && (
|
||||
<div className="mt-4 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-center text-sm text-yellow-700">
|
||||
⚠️ 최소 1개 이상의 컬럼을 선택해주세요
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ListWidgetConfig } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ListTableOptionsProps {
|
||||
config: ListWidgetConfig;
|
||||
onChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 테이블 옵션 설정 컴포넌트
|
||||
* - 페이지 크기, 검색, 정렬 등 설정
|
||||
*/
|
||||
export function ListTableOptions({ config, onChange }: ListTableOptionsProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">테이블 옵션</h3>
|
||||
<p className="text-sm text-gray-600">테이블 동작과 스타일을 설정하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 뷰 모드 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">뷰 모드</Label>
|
||||
<RadioGroup
|
||||
value={config.viewMode}
|
||||
onValueChange={(value: "table" | "card") => onChange({ viewMode: value })}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="table" id="table" />
|
||||
<Label htmlFor="table" className="cursor-pointer font-normal">
|
||||
📊 테이블 (기본)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="card" id="card" />
|
||||
<Label htmlFor="card" className="cursor-pointer font-normal">
|
||||
🗂️ 카드
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 모드 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">컬럼 설정 방식</Label>
|
||||
<RadioGroup
|
||||
value={config.columnMode}
|
||||
onValueChange={(value: "auto" | "manual") => onChange({ columnMode: value })}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto" id="auto" />
|
||||
<Label htmlFor="auto" className="cursor-pointer font-normal">
|
||||
자동 (쿼리 결과에서 선택)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="manual" id="manual" />
|
||||
<Label htmlFor="manual" className="cursor-pointer font-normal">
|
||||
수동 (직접 추가 및 매핑)
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 카드 뷰 컬럼 수 */}
|
||||
{config.viewMode === "card" && (
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">카드 컬럼 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="6"
|
||||
value={config.cardColumns || 3}
|
||||
onChange={(e) => onChange({ cardColumns: parseInt(e.target.value) || 3 })}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">한 줄에 표시할 카드 개수 (1-6)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지 크기 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">페이지당 행 수</Label>
|
||||
<Select value={String(config.pageSize)} onValueChange={(value) => onChange({ pageSize: parseInt(value) })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5개</SelectItem>
|
||||
<SelectItem value="10">10개</SelectItem>
|
||||
<SelectItem value="20">20개</SelectItem>
|
||||
<SelectItem value="50">50개</SelectItem>
|
||||
<SelectItem value="100">100개</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 기능 활성화 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">기능</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enablePagination"
|
||||
checked={config.enablePagination}
|
||||
onCheckedChange={(checked) => onChange({ enablePagination: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="enablePagination" className="cursor-pointer font-normal">
|
||||
페이지네이션
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 스타일 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">스타일</Label>
|
||||
<div className="space-y-2">
|
||||
{config.viewMode === "table" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showHeader"
|
||||
checked={config.showHeader}
|
||||
onCheckedChange={(checked) => onChange({ showHeader: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="showHeader" className="cursor-pointer font-normal">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="stripedRows"
|
||||
checked={config.stripedRows}
|
||||
onCheckedChange={(checked) => onChange({ stripedRows: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="stripedRows" className="cursor-pointer font-normal">
|
||||
줄무늬 행
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="compactMode"
|
||||
checked={config.compactMode}
|
||||
onCheckedChange={(checked) => onChange({ compactMode: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="compactMode" className="cursor-pointer font-normal">
|
||||
압축 모드 (작은 크기)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ListColumn } 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 { Plus, Trash2, GripVertical } from "lucide-react";
|
||||
|
||||
interface ManualColumnEditorProps {
|
||||
availableFields: string[];
|
||||
columns: ListColumn[];
|
||||
onChange: (columns: ListColumn[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 컬럼 편집 컴포넌트
|
||||
* - 사용자가 직접 컬럼 추가/삭제
|
||||
* - 컬럼명과 데이터 필드 직접 매핑
|
||||
*/
|
||||
export function ManualColumnEditor({ availableFields, columns, onChange }: ManualColumnEditorProps) {
|
||||
// 새 컬럼 추가
|
||||
const handleAddColumn = () => {
|
||||
const newCol: ListColumn = {
|
||||
id: `col_${Date.now()}`,
|
||||
label: `컬럼 ${columns.length + 1}`,
|
||||
field: availableFields[0] || "",
|
||||
align: "left",
|
||||
visible: true,
|
||||
};
|
||||
onChange([...columns, newCol]);
|
||||
};
|
||||
|
||||
// 컬럼 삭제
|
||||
const handleRemove = (id: string) => {
|
||||
onChange(columns.filter((col) => col.id !== id));
|
||||
};
|
||||
|
||||
// 컬럼 속성 업데이트
|
||||
const handleUpdate = (id: string, updates: Partial<ListColumn>) => {
|
||||
onChange(columns.map((col) => (col.id === id ? { ...col, ...updates } : col)));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">수동 컬럼 편집</h3>
|
||||
<p className="text-sm text-gray-600">직접 컬럼을 추가하고 데이터 필드를 매핑하세요</p>
|
||||
</div>
|
||||
<Button onClick={handleAddColumn} size="sm" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{columns.map((col, index) => (
|
||||
<div key={col.id} className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">컬럼 {index + 1}</span>
|
||||
<Button
|
||||
onClick={() => handleRemove(col.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* 컬럼명 */}
|
||||
<div>
|
||||
<Label className="text-xs">표시 이름 *</Label>
|
||||
<Input
|
||||
value={col.label}
|
||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
||||
placeholder="예: 사용자 이름"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 데이터 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs">데이터 필드 *</Label>
|
||||
<Select value={col.field} onValueChange={(value) => handleUpdate(col.id, { field: value })}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div>
|
||||
<Label className="text-xs">정렬</Label>
|
||||
<Select
|
||||
value={col.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 너비 */}
|
||||
<div>
|
||||
<Label className="text-xs">너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={col.width || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdate(col.id, { width: e.target.value ? parseInt(e.target.value) : undefined })
|
||||
}
|
||||
placeholder="자동"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{columns.length === 0 && (
|
||||
<div className="mt-4 rounded-lg border border-gray-300 bg-gray-100 p-8 text-center">
|
||||
<div className="text-sm text-gray-600">컬럼을 추가하여 시작하세요</div>
|
||||
<Button onClick={handleAddColumn} size="sm" className="mt-3 gap-2">
|
||||
<Plus className="h-4 w-4" />첫 번째 컬럼 추가
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { DashboardElement, QueryResult } from '@/components/admin/dashboard/types';
|
||||
import { ChartRenderer } from '@/components/admin/dashboard/charts/ChartRenderer';
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { DashboardElement, QueryResult } from "@/components/admin/dashboard/types";
|
||||
import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer";
|
||||
|
||||
interface DashboardViewerProps {
|
||||
elements: DashboardElement[];
|
||||
|
|
@ -23,36 +23,60 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash
|
|||
|
||||
// 개별 요소 데이터 로딩
|
||||
const loadElementData = useCallback(async (element: DashboardElement) => {
|
||||
if (!element.dataSource?.query || element.type !== 'chart') {
|
||||
if (!element.dataSource?.query || element.type !== "chart") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingElements(prev => new Set([...prev, element.id]));
|
||||
setLoadingElements((prev) => new Set([...prev, element.id]));
|
||||
|
||||
try {
|
||||
// console.log(`🔄 요소 ${element.id} 데이터 로딩 시작:`, element.dataSource.query);
|
||||
|
||||
// 실제 API 호출
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
|
||||
// console.log(`✅ 요소 ${element.id} 데이터 로딩 완료:`, result);
|
||||
|
||||
const data: QueryResult = {
|
||||
columns: result.columns || [],
|
||||
rows: result.rows || [],
|
||||
totalRows: result.rowCount || 0,
|
||||
executionTime: 0
|
||||
};
|
||||
|
||||
setElementData(prev => ({
|
||||
...prev,
|
||||
[element.id]: data
|
||||
}));
|
||||
let 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),
|
||||
element.dataSource.query,
|
||||
);
|
||||
|
||||
if (!externalResult.success) {
|
||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
|
||||
const data: QueryResult = {
|
||||
columns: externalResult.data?.[0] ? Object.keys(externalResult.data[0]) : [],
|
||||
rows: externalResult.data || [],
|
||||
totalRows: externalResult.data?.length || 0,
|
||||
executionTime: 0,
|
||||
};
|
||||
|
||||
setElementData((prev) => ({
|
||||
...prev,
|
||||
[element.id]: data,
|
||||
}));
|
||||
} else {
|
||||
// 현재 DB
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
|
||||
const data: QueryResult = {
|
||||
columns: result.columns || [],
|
||||
rows: result.rows || [],
|
||||
totalRows: result.rowCount || 0,
|
||||
executionTime: 0,
|
||||
};
|
||||
|
||||
setElementData((prev) => ({
|
||||
...prev,
|
||||
[element.id]: data,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error(`❌ Element ${element.id} data loading error:`, error);
|
||||
// 에러 발생 시 무시 (차트는 빈 상태로 표시됨)
|
||||
} finally {
|
||||
setLoadingElements(prev => {
|
||||
setLoadingElements((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(element.id);
|
||||
return newSet;
|
||||
|
|
@ -63,11 +87,11 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash
|
|||
// 모든 요소 데이터 로딩
|
||||
const loadAllData = useCallback(async () => {
|
||||
setLastRefresh(new Date());
|
||||
|
||||
const chartElements = elements.filter(el => el.type === 'chart' && el.dataSource?.query);
|
||||
|
||||
|
||||
const chartElements = elements.filter((el) => el.type === "chart" && el.dataSource?.query);
|
||||
|
||||
// 병렬로 모든 차트 데이터 로딩
|
||||
await Promise.all(chartElements.map(element => loadElementData(element)));
|
||||
await Promise.all(chartElements.map((element) => loadElementData(element)));
|
||||
}, [elements, loadElementData]);
|
||||
|
||||
// 초기 데이터 로딩
|
||||
|
|
@ -88,34 +112,28 @@ export function DashboardViewer({ elements, dashboardId, refreshInterval }: Dash
|
|||
// 요소가 없는 경우
|
||||
if (elements.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-gray-50">
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">📊</div>
|
||||
<div className="text-xl font-medium text-gray-700 mb-2">
|
||||
표시할 요소가 없습니다
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
대시보드 편집기에서 차트나 위젯을 추가해보세요
|
||||
</div>
|
||||
<div className="mb-4 text-6xl">📊</div>
|
||||
<div className="mb-2 text-xl font-medium text-gray-700">표시할 요소가 없습니다</div>
|
||||
<div className="text-sm text-gray-500">대시보드 편집기에서 차트나 위젯을 추가해보세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-gray-100 overflow-auto">
|
||||
<div className="relative h-full w-full overflow-auto bg-gray-100">
|
||||
{/* 새로고침 상태 표시 */}
|
||||
<div className="absolute top-4 right-4 z-10 bg-white rounded-lg shadow-sm px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground absolute top-4 right-4 z-10 rounded-lg bg-white px-3 py-2 text-xs shadow-sm">
|
||||
마지막 업데이트: {lastRefresh.toLocaleTimeString()}
|
||||
{Array.from(loadingElements).length > 0 && (
|
||||
<span className="ml-2 text-primary">
|
||||
({Array.from(loadingElements).length}개 로딩 중...)
|
||||
</span>
|
||||
<span className="text-primary ml-2">({Array.from(loadingElements).length}개 로딩 중...)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 대시보드 요소들 */}
|
||||
<div className="relative" style={{ minHeight: '100%' }}>
|
||||
<div className="relative" style={{ minHeight: "100%" }}>
|
||||
{elements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
|
|
@ -145,32 +163,32 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
|
||||
return (
|
||||
<div
|
||||
className="absolute bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
|
||||
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||
style={{
|
||||
left: element.position.x,
|
||||
top: element.position.y,
|
||||
width: element.size.width,
|
||||
height: element.size.height
|
||||
height: element.size.height,
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex justify-between items-center">
|
||||
<h3 className="font-semibold text-gray-800 text-sm">{element.title}</h3>
|
||||
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800">{element.title}</h3>
|
||||
|
||||
{/* 새로고침 버튼 (호버 시에만 표시) */}
|
||||
{isHovered && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="text-gray-400 hover:text-muted-foreground disabled:opacity-50"
|
||||
className="hover:text-muted-foreground text-gray-400 disabled:opacity-50"
|
||||
title="새로고침"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="w-4 h-4 border border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
|
||||
) : (
|
||||
'🔄'
|
||||
"🔄"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -178,20 +196,15 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
|
||||
{/* 내용 */}
|
||||
<div className="h-[calc(100%-57px)]">
|
||||
{element.type === 'chart' ? (
|
||||
<ChartRenderer
|
||||
element={element}
|
||||
data={data}
|
||||
width={element.size.width}
|
||||
height={element.size.height - 57}
|
||||
/>
|
||||
{element.type === "chart" ? (
|
||||
<ChartRenderer element={element} data={data} width={element.size.width} height={element.size.height - 57} />
|
||||
) : (
|
||||
// 위젯 렌더링
|
||||
<div className="w-full h-full p-4 flex items-center justify-center bg-gradient-to-br from-blue-400 to-purple-600 text-white">
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-blue-400 to-purple-600 p-4 text-white">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">
|
||||
{element.subtype === 'exchange' && '💱'}
|
||||
{element.subtype === 'weather' && '☁️'}
|
||||
<div className="mb-2 text-3xl">
|
||||
{element.subtype === "exchange" && "💱"}
|
||||
{element.subtype === "weather" && "☁️"}
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-line">{element.content}</div>
|
||||
</div>
|
||||
|
|
@ -201,10 +214,10 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
|
||||
{/* 로딩 오버레이 */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center">
|
||||
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-white">
|
||||
<div className="text-center">
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<div className="text-sm text-muted-foreground">업데이트 중...</div>
|
||||
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<div className="text-muted-foreground text-sm">업데이트 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -218,53 +231,73 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
|
|||
function generateSampleQueryResult(query: string, chartType: string): QueryResult {
|
||||
// 시간에 따라 약간씩 다른 데이터 생성 (실시간 업데이트 시뮬레이션)
|
||||
const timeVariation = Math.sin(Date.now() / 10000) * 0.1 + 1;
|
||||
|
||||
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('상품');
|
||||
const isWeekly = query.toLowerCase().includes('week');
|
||||
|
||||
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("상품");
|
||||
const isWeekly = query.toLowerCase().includes("week");
|
||||
|
||||
let columns: string[];
|
||||
let rows: Record<string, any>[];
|
||||
|
||||
if (isMonthly && isSales) {
|
||||
columns = ['month', 'sales', 'order_count'];
|
||||
columns = ["month", "sales", "order_count"];
|
||||
rows = [
|
||||
{ month: '2024-01', sales: Math.round(1200000 * timeVariation), order_count: Math.round(45 * timeVariation) },
|
||||
{ month: '2024-02', sales: Math.round(1350000 * timeVariation), order_count: Math.round(52 * timeVariation) },
|
||||
{ month: '2024-03', sales: Math.round(1180000 * timeVariation), order_count: Math.round(41 * timeVariation) },
|
||||
{ month: '2024-04', sales: Math.round(1420000 * timeVariation), order_count: Math.round(58 * timeVariation) },
|
||||
{ month: '2024-05', sales: Math.round(1680000 * timeVariation), order_count: Math.round(67 * timeVariation) },
|
||||
{ month: '2024-06', sales: Math.round(1540000 * timeVariation), order_count: Math.round(61 * timeVariation) },
|
||||
{ month: "2024-01", sales: Math.round(1200000 * timeVariation), order_count: Math.round(45 * timeVariation) },
|
||||
{ month: "2024-02", sales: Math.round(1350000 * timeVariation), order_count: Math.round(52 * timeVariation) },
|
||||
{ month: "2024-03", sales: Math.round(1180000 * timeVariation), order_count: Math.round(41 * timeVariation) },
|
||||
{ month: "2024-04", sales: Math.round(1420000 * timeVariation), order_count: Math.round(58 * timeVariation) },
|
||||
{ month: "2024-05", sales: Math.round(1680000 * timeVariation), order_count: Math.round(67 * timeVariation) },
|
||||
{ month: "2024-06", sales: Math.round(1540000 * timeVariation), order_count: Math.round(61 * timeVariation) },
|
||||
];
|
||||
} else if (isWeekly && isUsers) {
|
||||
columns = ['week', 'new_users'];
|
||||
columns = ["week", "new_users"];
|
||||
rows = [
|
||||
{ week: '2024-W10', new_users: Math.round(23 * timeVariation) },
|
||||
{ week: '2024-W11', new_users: Math.round(31 * timeVariation) },
|
||||
{ week: '2024-W12', new_users: Math.round(28 * timeVariation) },
|
||||
{ week: '2024-W13', new_users: Math.round(35 * timeVariation) },
|
||||
{ week: '2024-W14', new_users: Math.round(42 * timeVariation) },
|
||||
{ week: '2024-W15', new_users: Math.round(38 * timeVariation) },
|
||||
{ week: "2024-W10", new_users: Math.round(23 * timeVariation) },
|
||||
{ week: "2024-W11", new_users: Math.round(31 * timeVariation) },
|
||||
{ week: "2024-W12", new_users: Math.round(28 * timeVariation) },
|
||||
{ week: "2024-W13", new_users: Math.round(35 * timeVariation) },
|
||||
{ week: "2024-W14", new_users: Math.round(42 * timeVariation) },
|
||||
{ week: "2024-W15", new_users: Math.round(38 * timeVariation) },
|
||||
];
|
||||
} else if (isProducts) {
|
||||
columns = ['product_name', 'total_sold', 'revenue'];
|
||||
columns = ["product_name", "total_sold", "revenue"];
|
||||
rows = [
|
||||
{ product_name: '스마트폰', total_sold: Math.round(156 * timeVariation), revenue: Math.round(234000000 * timeVariation) },
|
||||
{ product_name: '노트북', total_sold: Math.round(89 * timeVariation), revenue: Math.round(178000000 * timeVariation) },
|
||||
{ product_name: '태블릿', total_sold: Math.round(134 * timeVariation), revenue: Math.round(67000000 * timeVariation) },
|
||||
{ product_name: '이어폰', total_sold: Math.round(267 * timeVariation), revenue: Math.round(26700000 * timeVariation) },
|
||||
{ product_name: '스마트워치', total_sold: Math.round(98 * timeVariation), revenue: Math.round(49000000 * timeVariation) },
|
||||
{
|
||||
product_name: "스마트폰",
|
||||
total_sold: Math.round(156 * timeVariation),
|
||||
revenue: Math.round(234000000 * timeVariation),
|
||||
},
|
||||
{
|
||||
product_name: "노트북",
|
||||
total_sold: Math.round(89 * timeVariation),
|
||||
revenue: Math.round(178000000 * timeVariation),
|
||||
},
|
||||
{
|
||||
product_name: "태블릿",
|
||||
total_sold: Math.round(134 * timeVariation),
|
||||
revenue: Math.round(67000000 * timeVariation),
|
||||
},
|
||||
{
|
||||
product_name: "이어폰",
|
||||
total_sold: Math.round(267 * timeVariation),
|
||||
revenue: Math.round(26700000 * timeVariation),
|
||||
},
|
||||
{
|
||||
product_name: "스마트워치",
|
||||
total_sold: Math.round(98 * timeVariation),
|
||||
revenue: Math.round(49000000 * timeVariation),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
columns = ['category', 'value', 'count'];
|
||||
columns = ["category", "value", "count"];
|
||||
rows = [
|
||||
{ category: 'A', value: Math.round(100 * timeVariation), count: Math.round(10 * timeVariation) },
|
||||
{ category: 'B', value: Math.round(150 * timeVariation), count: Math.round(15 * timeVariation) },
|
||||
{ category: 'C', value: Math.round(120 * timeVariation), count: Math.round(12 * timeVariation) },
|
||||
{ category: 'D', value: Math.round(180 * timeVariation), count: Math.round(18 * timeVariation) },
|
||||
{ category: 'E', value: Math.round(90 * timeVariation), count: Math.round(9 * timeVariation) },
|
||||
{ category: "A", value: Math.round(100 * timeVariation), count: Math.round(10 * timeVariation) },
|
||||
{ category: "B", value: Math.round(150 * timeVariation), count: Math.round(15 * timeVariation) },
|
||||
{ category: "C", value: Math.round(120 * timeVariation), count: Math.round(12 * timeVariation) },
|
||||
{ category: "D", value: Math.round(180 * timeVariation), count: Math.round(18 * timeVariation) },
|
||||
{ category: "E", value: Math.round(90 * timeVariation), count: Math.round(9 * timeVariation) },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,308 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Check, X, Phone, MapPin, Package, Clock, AlertCircle } from "lucide-react";
|
||||
|
||||
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;
|
||||
estimatedCost?: number;
|
||||
}
|
||||
|
||||
export default function BookingAlertWidget() {
|
||||
const [bookings, setBookings] = useState<BookingRequest[]>([]);
|
||||
const [newCount, setNewCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<"all" | "pending" | "accepted">("pending");
|
||||
const [showNotification, setShowNotification] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookings();
|
||||
const interval = setInterval(fetchBookings, 10000); // 10초마다 갱신
|
||||
return () => clearInterval(interval);
|
||||
}, [filter]);
|
||||
|
||||
const fetchBookings = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem("authToken");
|
||||
const filterParam = filter !== "all" ? `?status=${filter}` : "";
|
||||
const response = await fetch(`http://localhost:9771/api/bookings${filterParam}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const newBookings = result.data || [];
|
||||
|
||||
// 신규 예약이 있으면 알림 표시
|
||||
if (result.newCount > 0 && newBookings.length > bookings.length) {
|
||||
setShowNotification(true);
|
||||
setTimeout(() => setShowNotification(false), 5000);
|
||||
}
|
||||
|
||||
setBookings(newBookings);
|
||||
setNewCount(result.newCount);
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("예약 로딩 오류:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = async (id: string) => {
|
||||
if (!confirm("이 예약을 수락하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(`http://localhost:9771/api/bookings/${id}/accept`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchBookings();
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("예약 수락 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (id: string) => {
|
||||
const reason = prompt("거절 사유를 입력하세요:");
|
||||
if (!reason) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(`http://localhost:9771/api/bookings/${id}/reject`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchBookings();
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("예약 거절 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getVehicleIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "truck":
|
||||
return "🚚";
|
||||
case "van":
|
||||
return "🚐";
|
||||
case "car":
|
||||
return "🚗";
|
||||
default:
|
||||
return "🚗";
|
||||
}
|
||||
};
|
||||
|
||||
const getTimeStatus = (scheduledTime: string) => {
|
||||
const now = new Date();
|
||||
const scheduled = new Date(scheduledTime);
|
||||
const diff = scheduled.getTime() - now.getTime();
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
|
||||
if (hours < 0) return { text: "⏰ 시간 초과", color: "text-red-600" };
|
||||
if (hours < 2) return { text: `⏱️ ${hours}시간 후`, color: "text-red-600" };
|
||||
if (hours < 4) return { text: `⏱️ ${hours}시간 후`, color: "text-orange-600" };
|
||||
return { text: `📅 ${hours}시간 후`, color: "text-gray-600" };
|
||||
};
|
||||
|
||||
const isNew = (createdAt: string) => {
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||
return new Date(createdAt) > fiveMinutesAgo;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-gray-500">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-rose-50">
|
||||
{/* 신규 알림 배너 */}
|
||||
{showNotification && newCount > 0 && (
|
||||
<div className="animate-pulse border-b border-rose-300 bg-rose-100 px-4 py-2 text-center">
|
||||
<span className="font-bold text-rose-700">🔔 새로운 예약 {newCount}건이 도착했습니다!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-bold text-gray-800">🔔 예약 요청 알림</h3>
|
||||
{newCount > 0 && (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white">
|
||||
{newCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchBookings}
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
|
||||
>
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex gap-2">
|
||||
{(["pending", "accepted", "all"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||
filter === f ? "bg-primary text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{f === "pending" ? "대기중" : f === "accepted" ? "수락됨" : "전체"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 예약 리스트 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{bookings.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📭</div>
|
||||
<div>예약 요청이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{bookings.map((booking) => (
|
||||
<div
|
||||
key={booking.id}
|
||||
className={`group relative rounded-lg border-2 bg-white p-4 shadow-sm transition-all hover:shadow-md ${
|
||||
booking.priority === "urgent" ? "border-red-400" : "border-gray-200"
|
||||
} ${booking.status !== "pending" ? "opacity-60" : ""}`}
|
||||
>
|
||||
{/* NEW 뱃지 */}
|
||||
{isNew(booking.createdAt) && booking.status === "pending" && (
|
||||
<div className="absolute -right-2 -top-2 animate-bounce">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white shadow-lg">
|
||||
🆕
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우선순위 표시 */}
|
||||
{booking.priority === "urgent" && (
|
||||
<div className="mb-2 flex items-center gap-1 text-sm font-bold text-red-600">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
긴급 예약
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 고객 정보 */}
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-2xl">{getVehicleIcon(booking.vehicleType)}</span>
|
||||
<div>
|
||||
<div className="font-bold text-gray-800">{booking.customerName}</div>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-600">
|
||||
<Phone className="h-3 w-3" />
|
||||
{booking.customerPhone}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{booking.status === "pending" && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleAccept(booking.id)}
|
||||
className="flex items-center gap-1 rounded bg-green-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-600"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
수락
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReject(booking.id)}
|
||||
className="flex items-center gap-1 rounded bg-red-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
거절
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{booking.status === "accepted" && (
|
||||
<span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-700">
|
||||
✓ 수락됨
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 경로 정보 */}
|
||||
<div className="mb-3 space-y-2 border-t border-gray-100 pt-3">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-700">출발지</div>
|
||||
<div className="text-gray-600">{booking.pickupLocation}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-rose-600" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-700">도착지</div>
|
||||
<div className="text-gray-600">{booking.dropoffLocation}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Package className="h-3 w-3 text-gray-500" />
|
||||
<span className="text-gray-600">
|
||||
{booking.cargoType} ({booking.weight}kg)
|
||||
</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${getTimeStatus(booking.scheduledTime).color}`}>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span className="font-medium">{getTimeStatus(booking.scheduledTime).text}</span>
|
||||
</div>
|
||||
{booking.estimatedCost && (
|
||||
<div className="col-span-2 font-bold text-primary">
|
||||
예상 비용: {booking.estimatedCost.toLocaleString()}원
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* 계산기 위젯 컴포넌트
|
||||
* - 기본 사칙연산 지원
|
||||
* - 실시간 계산
|
||||
* - 대시보드 위젯으로 사용 가능
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface CalculatorWidgetProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CalculatorWidget({ className = '' }: CalculatorWidgetProps) {
|
||||
const [display, setDisplay] = useState<string>('0');
|
||||
const [previousValue, setPreviousValue] = useState<number | null>(null);
|
||||
const [operation, setOperation] = useState<string | null>(null);
|
||||
const [waitingForOperand, setWaitingForOperand] = useState<boolean>(false);
|
||||
|
||||
// 숫자 입력 처리
|
||||
const handleNumber = (num: string) => {
|
||||
if (waitingForOperand) {
|
||||
setDisplay(num);
|
||||
setWaitingForOperand(false);
|
||||
} else {
|
||||
setDisplay(display === '0' ? num : display + num);
|
||||
}
|
||||
};
|
||||
|
||||
// 소수점 입력
|
||||
const handleDecimal = () => {
|
||||
if (waitingForOperand) {
|
||||
setDisplay('0.');
|
||||
setWaitingForOperand(false);
|
||||
} else if (display.indexOf('.') === -1) {
|
||||
setDisplay(display + '.');
|
||||
}
|
||||
};
|
||||
|
||||
// 연산자 입력
|
||||
const handleOperation = (nextOperation: string) => {
|
||||
const inputValue = parseFloat(display);
|
||||
|
||||
if (previousValue === null) {
|
||||
setPreviousValue(inputValue);
|
||||
} else if (operation) {
|
||||
const currentValue = previousValue || 0;
|
||||
const newValue = calculate(currentValue, inputValue, operation);
|
||||
|
||||
setDisplay(String(newValue));
|
||||
setPreviousValue(newValue);
|
||||
}
|
||||
|
||||
setWaitingForOperand(true);
|
||||
setOperation(nextOperation);
|
||||
};
|
||||
|
||||
// 계산 수행
|
||||
const calculate = (firstValue: number, secondValue: number, operation: string): number => {
|
||||
switch (operation) {
|
||||
case '+':
|
||||
return firstValue + secondValue;
|
||||
case '-':
|
||||
return firstValue - secondValue;
|
||||
case '×':
|
||||
return firstValue * secondValue;
|
||||
case '÷':
|
||||
return secondValue !== 0 ? firstValue / secondValue : 0;
|
||||
default:
|
||||
return secondValue;
|
||||
}
|
||||
};
|
||||
|
||||
// 등호 처리
|
||||
const handleEquals = () => {
|
||||
const inputValue = parseFloat(display);
|
||||
|
||||
if (previousValue !== null && operation) {
|
||||
const newValue = calculate(previousValue, inputValue, operation);
|
||||
setDisplay(String(newValue));
|
||||
setPreviousValue(null);
|
||||
setOperation(null);
|
||||
setWaitingForOperand(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleClear = () => {
|
||||
setDisplay('0');
|
||||
setPreviousValue(null);
|
||||
setOperation(null);
|
||||
setWaitingForOperand(false);
|
||||
};
|
||||
|
||||
// 백스페이스
|
||||
const handleBackspace = () => {
|
||||
if (!waitingForOperand) {
|
||||
const newDisplay = display.slice(0, -1);
|
||||
setDisplay(newDisplay || '0');
|
||||
}
|
||||
};
|
||||
|
||||
// 부호 변경
|
||||
const handleSign = () => {
|
||||
const value = parseFloat(display);
|
||||
setDisplay(String(value * -1));
|
||||
};
|
||||
|
||||
// 퍼센트
|
||||
const handlePercent = () => {
|
||||
const value = parseFloat(display);
|
||||
setDisplay(String(value / 100));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`h-full w-full p-3 bg-gradient-to-br from-slate-50 to-gray-100 ${className}`}>
|
||||
<div className="h-full flex flex-col justify-center gap-2">
|
||||
{/* 디스플레이 */}
|
||||
<div className="bg-white border-2 border-gray-200 rounded-lg p-4 shadow-inner min-h-[80px]">
|
||||
<div className="text-right h-full flex flex-col justify-center">
|
||||
<div className="h-4 mb-1">
|
||||
{operation && previousValue !== null && (
|
||||
<div className="text-xs text-gray-400">
|
||||
{previousValue} {operation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 truncate">
|
||||
{display}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 그리드 */}
|
||||
<div className="flex-1 grid grid-cols-4 gap-2">
|
||||
{/* 첫 번째 줄 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClear}
|
||||
className="h-full text-red-600 hover:bg-red-50 hover:text-red-700 font-semibold select-none"
|
||||
>
|
||||
AC
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSign}
|
||||
className="h-full text-gray-600 hover:bg-gray-100 font-semibold select-none"
|
||||
>
|
||||
+/-
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePercent}
|
||||
className="h-full text-gray-600 hover:bg-gray-100 font-semibold select-none"
|
||||
>
|
||||
%
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleOperation('÷')}
|
||||
className="h-full bg-blue-500 hover:bg-blue-600 text-white font-semibold select-none"
|
||||
>
|
||||
÷
|
||||
</Button>
|
||||
|
||||
{/* 두 번째 줄 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('7')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
>
|
||||
7
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('8')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
>
|
||||
8
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('9')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
>
|
||||
9
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleOperation('×')}
|
||||
className="h-full bg-blue-500 hover:bg-blue-600 text-white font-semibold select-none"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
|
||||
{/* 세 번째 줄 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('4')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
>
|
||||
4
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('5')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
>
|
||||
5
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('6')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
>
|
||||
6
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleOperation('-')}
|
||||
className="h-full bg-blue-500 hover:bg-blue-600 text-white font-semibold select-none"
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
|
||||
{/* 네 번째 줄 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('1')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
>
|
||||
1
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('2')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
>
|
||||
2
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('3')}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
>
|
||||
3
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleOperation('+')}
|
||||
className="h-full bg-blue-500 hover:bg-blue-600 text-white font-semibold select-none"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
|
||||
{/* 다섯 번째 줄 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleNumber('0')}
|
||||
className="h-full col-span-2 hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
>
|
||||
0
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDecimal}
|
||||
className="h-full hover:bg-gray-100 font-semibold text-lg select-none"
|
||||
>
|
||||
.
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleEquals}
|
||||
className="h-full bg-green-500 hover:bg-green-600 text-white font-semibold select-none"
|
||||
>
|
||||
=
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,495 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw, Package, TruckIcon, AlertTriangle, CheckCircle, Clock, XCircle } from "lucide-react";
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
interface CustomerIssue {
|
||||
id: string;
|
||||
customer: string;
|
||||
trackingNumber: string;
|
||||
issueType: "damage" | "delay" | "missing" | "other";
|
||||
description: string;
|
||||
status: "open" | "in_progress" | "resolved";
|
||||
reportedAt: string;
|
||||
}
|
||||
|
||||
interface DeliveryStatusWidgetProps {
|
||||
element?: any; // 대시보드 요소 (dataSource 포함)
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export default function DeliveryStatusWidget({ element, refreshInterval = 60000 }: DeliveryStatusWidgetProps) {
|
||||
const [deliveries, setDeliveries] = useState<DeliveryItem[]>([]);
|
||||
const [issues, setIssues] = useState<CustomerIssue[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>("all"); // 필터 상태 추가
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// 설정된 쿼리가 없으면 로딩 중단 (더미 데이터 사용 안 함)
|
||||
if (!element?.dataSource?.query) {
|
||||
setIsLoading(false);
|
||||
setDeliveries([]);
|
||||
setIssues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dashboards/execute-query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||
},
|
||||
body: JSON.stringify({ query: element.dataSource.query }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data.rows.length > 0) {
|
||||
// TODO: DB 데이터를 DeliveryItem 형식으로 변환
|
||||
setDeliveries(result.data.rows);
|
||||
setLastUpdate(new Date());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배송 데이터 로드 실패:", error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 데이터 로드 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [element?.dataSource?.query, refreshInterval]);
|
||||
|
||||
// 더미 데이터 완전히 제거 (아래 코드 삭제)
|
||||
/*
|
||||
// 가상 배송 데이터 (개발용 - 실제 DB 연동 시 삭제)
|
||||
const dummyDeliveries: DeliveryItem[] = [
|
||||
{
|
||||
id: "D001",
|
||||
trackingNumber: "TRK-2025-001",
|
||||
customer: "삼성전자",
|
||||
origin: "서울 물류센터",
|
||||
destination: "부산 공장",
|
||||
status: "in_transit",
|
||||
estimatedDelivery: "2025-10-15 14:00",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: "D002",
|
||||
trackingNumber: "TRK-2025-002",
|
||||
customer: "LG화학",
|
||||
origin: "인천항",
|
||||
destination: "광주 공장",
|
||||
status: "delivered",
|
||||
estimatedDelivery: "2025-10-14 16:30",
|
||||
priority: "normal",
|
||||
},
|
||||
{
|
||||
id: "D003",
|
||||
trackingNumber: "TRK-2025-003",
|
||||
customer: "현대자동차",
|
||||
origin: "평택 물류센터",
|
||||
destination: "울산 공장",
|
||||
status: "delayed",
|
||||
estimatedDelivery: "2025-10-14 18:00",
|
||||
delayReason: "교통 체증",
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
id: "D004",
|
||||
trackingNumber: "TRK-2025-004",
|
||||
customer: "SK하이닉스",
|
||||
origin: "이천 물류센터",
|
||||
destination: "청주 공장",
|
||||
status: "pickup_waiting",
|
||||
estimatedDelivery: "2025-10-15 10:00",
|
||||
priority: "normal",
|
||||
},
|
||||
{
|
||||
id: "D005",
|
||||
trackingNumber: "TRK-2025-005",
|
||||
customer: "포스코",
|
||||
origin: "포항 물류센터",
|
||||
destination: "광양 제철소",
|
||||
status: "delayed",
|
||||
estimatedDelivery: "2025-10-14 20:00",
|
||||
delayReason: "기상 악화",
|
||||
priority: "high",
|
||||
},
|
||||
];
|
||||
|
||||
// 가상 고객 이슈 데이터
|
||||
const dummyIssues: CustomerIssue[] = [
|
||||
{
|
||||
id: "I001",
|
||||
customer: "삼성전자",
|
||||
trackingNumber: "TRK-2025-001",
|
||||
issueType: "delay",
|
||||
description: "배송 지연으로 인한 생산 일정 차질",
|
||||
status: "in_progress",
|
||||
reportedAt: "2025-10-14 15:30",
|
||||
},
|
||||
{
|
||||
id: "I002",
|
||||
customer: "LG디스플레이",
|
||||
trackingNumber: "TRK-2024-998",
|
||||
issueType: "damage",
|
||||
description: "화물 일부 파손",
|
||||
status: "open",
|
||||
reportedAt: "2025-10-14 14:20",
|
||||
},
|
||||
{
|
||||
id: "I003",
|
||||
customer: "SK이노베이션",
|
||||
trackingNumber: "TRK-2024-995",
|
||||
issueType: "missing",
|
||||
description: "화물 일부 누락",
|
||||
status: "resolved",
|
||||
reportedAt: "2025-10-13 16:45",
|
||||
},
|
||||
];
|
||||
|
||||
*/
|
||||
|
||||
const getStatusColor = (status: DeliveryItem["status"]) => {
|
||||
switch (status) {
|
||||
case "in_transit":
|
||||
return "bg-blue-100 text-blue-700 border-blue-300";
|
||||
case "delivered":
|
||||
return "bg-green-100 text-green-700 border-green-300";
|
||||
case "delayed":
|
||||
return "bg-red-100 text-red-700 border-red-300";
|
||||
case "pickup_waiting":
|
||||
return "bg-yellow-100 text-yellow-700 border-yellow-300";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 border-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: DeliveryItem["status"]) => {
|
||||
switch (status) {
|
||||
case "in_transit":
|
||||
return "배송중";
|
||||
case "delivered":
|
||||
return "완료";
|
||||
case "delayed":
|
||||
return "지연";
|
||||
case "pickup_waiting":
|
||||
return "픽업 대기";
|
||||
default:
|
||||
return "알 수 없음";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: DeliveryItem["status"]) => {
|
||||
switch (status) {
|
||||
case "in_transit":
|
||||
return <TruckIcon className="h-4 w-4" />;
|
||||
case "delivered":
|
||||
return <CheckCircle className="h-4 w-4" />;
|
||||
case "delayed":
|
||||
return <AlertTriangle className="h-4 w-4" />;
|
||||
case "pickup_waiting":
|
||||
return <Clock className="h-4 w-4" />;
|
||||
default:
|
||||
return <Package className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getIssueTypeText = (type: CustomerIssue["issueType"]) => {
|
||||
switch (type) {
|
||||
case "damage":
|
||||
return "파손";
|
||||
case "delay":
|
||||
return "지연";
|
||||
case "missing":
|
||||
return "누락";
|
||||
case "other":
|
||||
return "기타";
|
||||
default:
|
||||
return "알 수 없음";
|
||||
}
|
||||
};
|
||||
|
||||
const getIssueStatusColor = (status: CustomerIssue["status"]) => {
|
||||
switch (status) {
|
||||
case "open":
|
||||
return "bg-red-100 text-red-700 border-red-300";
|
||||
case "in_progress":
|
||||
return "bg-yellow-100 text-yellow-700 border-yellow-300";
|
||||
case "resolved":
|
||||
return "bg-green-100 text-green-700 border-green-300";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 border-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
const getIssueStatusText = (status: CustomerIssue["status"]) => {
|
||||
switch (status) {
|
||||
case "open":
|
||||
return "접수";
|
||||
case "in_progress":
|
||||
return "처리중";
|
||||
case "resolved":
|
||||
return "해결";
|
||||
default:
|
||||
return "알 수 없음";
|
||||
}
|
||||
};
|
||||
|
||||
const statusStats = {
|
||||
in_transit: deliveries.filter((d) => d.status === "in_transit").length,
|
||||
delivered: deliveries.filter((d) => d.status === "delivered").length,
|
||||
delayed: deliveries.filter((d) => d.status === "delayed").length,
|
||||
pickup_waiting: deliveries.filter((d) => d.status === "pickup_waiting").length,
|
||||
};
|
||||
|
||||
// 필터링된 배송 목록
|
||||
const filteredDeliveries = selectedStatus === "all"
|
||||
? deliveries
|
||||
: deliveries.filter((d) => d.status === selectedStatus);
|
||||
|
||||
// 오늘 통계 계산
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayStats = {
|
||||
// 오늘 발송 건수 (created_at이 오늘인 것)
|
||||
shipped: deliveries.filter((d: any) => {
|
||||
if (!d.created_at) return false;
|
||||
const createdDate = new Date(d.created_at);
|
||||
createdDate.setHours(0, 0, 0, 0);
|
||||
return createdDate.getTime() === today.getTime();
|
||||
}).length,
|
||||
// 오늘 도착 건수 (status가 delivered이고 estimated_delivery가 오늘인 것)
|
||||
delivered: deliveries.filter((d: any) => {
|
||||
if (d.status !== "delivered" && d.status !== "delivered") return false;
|
||||
if (!d.estimated_delivery && !d.estimatedDelivery) return false;
|
||||
const deliveredDate = new Date(d.estimated_delivery || d.estimatedDelivery);
|
||||
deliveredDate.setHours(0, 0, 0, 0);
|
||||
return deliveredDate.getTime() === today.getTime();
|
||||
}).length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gradient-to-br from-slate-50 to-blue-50 p-4 overflow-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">📦 배송 / 화물 처리 현황</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
disabled={isLoading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 배송 상태 요약 */}
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">배송 상태 요약 (클릭하여 필터링)</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedStatus("all")}
|
||||
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
||||
selectedStatus === "all"
|
||||
? "border-gray-900 bg-gray-100 ring-2 ring-gray-900"
|
||||
: "border-gray-500 bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-600 mb-0.5">전체</div>
|
||||
<div className="text-lg font-bold text-gray-900">{deliveries.length}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("in_transit")}
|
||||
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
||||
selectedStatus === "in_transit"
|
||||
? "border-blue-900 bg-blue-100 ring-2 ring-blue-900"
|
||||
: "border-blue-500 bg-white hover:bg-blue-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-600 mb-0.5">배송중</div>
|
||||
<div className="text-lg font-bold text-blue-600">{statusStats.in_transit}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("delivered")}
|
||||
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
||||
selectedStatus === "delivered"
|
||||
? "border-green-900 bg-green-100 ring-2 ring-green-900"
|
||||
: "border-green-500 bg-white hover:bg-green-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-600 mb-0.5">완료</div>
|
||||
<div className="text-lg font-bold text-green-600">{statusStats.delivered}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("delayed")}
|
||||
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
||||
selectedStatus === "delayed"
|
||||
? "border-red-900 bg-red-100 ring-2 ring-red-900"
|
||||
: "border-red-500 bg-white hover:bg-red-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-600 mb-0.5">지연</div>
|
||||
<div className="text-lg font-bold text-red-600">{statusStats.delayed}</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("pickup_waiting")}
|
||||
className={`rounded-lg p-1.5 shadow-sm border-l-4 transition-all ${
|
||||
selectedStatus === "pickup_waiting"
|
||||
? "border-yellow-900 bg-yellow-100 ring-2 ring-yellow-900"
|
||||
: "border-yellow-500 bg-white hover:bg-yellow-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-600 mb-0.5">픽업 대기</div>
|
||||
<div className="text-lg font-bold text-yellow-600">{statusStats.pickup_waiting}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오늘 발송/도착 건수 */}
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700">오늘 처리 현황</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-gray-500">
|
||||
<div className="text-xs text-gray-600 mb-0.5">발송 건수</div>
|
||||
<div className="text-lg font-bold text-gray-900">{todayStats.shipped}</div>
|
||||
<div className="text-xs text-gray-500">건</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-1.5 shadow-sm border-l-4 border-gray-500">
|
||||
<div className="text-xs text-gray-600 mb-0.5">도착 건수</div>
|
||||
<div className="text-lg font-bold text-gray-900">{todayStats.delivered}</div>
|
||||
<div className="text-xs text-gray-500">건</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터링된 화물 리스트 */}
|
||||
<div className="mb-3">
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-gray-600" />
|
||||
{selectedStatus === "all" && `전체 화물 (${filteredDeliveries.length})`}
|
||||
{selectedStatus === "in_transit" && `배송 중인 화물 (${filteredDeliveries.length})`}
|
||||
{selectedStatus === "delivered" && `배송 완료 (${filteredDeliveries.length})`}
|
||||
{selectedStatus === "delayed" && `지연 중인 화물 (${filteredDeliveries.length})`}
|
||||
{selectedStatus === "pickup_waiting" && `픽업 대기 (${filteredDeliveries.length})`}
|
||||
</h4>
|
||||
<div className="rounded-lg bg-white shadow-sm border border-gray-200 overflow-hidden">
|
||||
{filteredDeliveries.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-gray-500">
|
||||
{selectedStatus === "all" ? "화물이 없습니다" : "해당 상태의 화물이 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[200px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
|
||||
{filteredDeliveries.map((delivery) => (
|
||||
<div
|
||||
key={delivery.id}
|
||||
className="p-3 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-gray-900">{delivery.customer}</div>
|
||||
<div className="text-xs text-gray-600">{delivery.trackingNumber}</div>
|
||||
</div>
|
||||
<span className={`rounded-md px-2 py-1 text-xs font-semibold border ${getStatusColor(delivery.status)}`}>
|
||||
{getStatusText(delivery.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">경로:</span>
|
||||
<span>{delivery.origin} → {delivery.destination}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">예정:</span>
|
||||
<span>{delivery.estimatedDelivery}</span>
|
||||
</div>
|
||||
{delivery.delayReason && (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span className="font-medium">사유:</span>
|
||||
<span>{delivery.delayReason}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 고객 클레임/이슈 리포트 */}
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-orange-600" />
|
||||
고객 클레임/이슈 ({issues.filter((i) => i.status !== "resolved").length})
|
||||
</h4>
|
||||
<div className="rounded-lg bg-white shadow-sm border border-gray-200 overflow-hidden">
|
||||
{issues.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-gray-500">
|
||||
이슈가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[200px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100">
|
||||
{issues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="p-3 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div className="font-semibold text-sm text-gray-900">{issue.customer}</div>
|
||||
<div className="text-xs text-gray-600">{issue.trackingNumber}</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span className="rounded-md px-2 py-1 text-xs font-semibold bg-gray-100 text-gray-700 border border-gray-300">
|
||||
{getIssueTypeText(issue.issueType)}
|
||||
</span>
|
||||
<span className={`rounded-md px-2 py-1 text-xs font-semibold border ${getIssueStatusColor(issue.status)}`}>
|
||||
{getIssueStatusText(issue.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<div>{issue.description}</div>
|
||||
<div className="text-gray-500">접수: {issue.reportedAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { FileText, Download, Calendar, Folder, Search } from "lucide-react";
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
name: string;
|
||||
category: "계약서" | "보험" | "세금계산서" | "기타";
|
||||
size: string;
|
||||
uploadDate: string;
|
||||
url: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 목 데이터
|
||||
const mockDocuments: Document[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "2025년 1월 세금계산서.pdf",
|
||||
category: "세금계산서",
|
||||
size: "1.2 MB",
|
||||
uploadDate: "2025-01-05",
|
||||
url: "/documents/tax-invoice-202501.pdf",
|
||||
description: "1월 매출 세금계산서",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "차량보험증권_서울12가3456.pdf",
|
||||
category: "보험",
|
||||
size: "856 KB",
|
||||
uploadDate: "2024-12-20",
|
||||
url: "/documents/insurance-vehicle-1.pdf",
|
||||
description: "1톤 트럭 종합보험",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "운송계약서_ABC물류.pdf",
|
||||
category: "계약서",
|
||||
size: "2.4 MB",
|
||||
uploadDate: "2024-12-15",
|
||||
url: "/documents/contract-abc-logistics.pdf",
|
||||
description: "ABC물류 연간 운송 계약",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "2024년 12월 세금계산서.pdf",
|
||||
category: "세금계산서",
|
||||
size: "1.1 MB",
|
||||
uploadDate: "2024-12-05",
|
||||
url: "/documents/tax-invoice-202412.pdf",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "화물배상책임보험증권.pdf",
|
||||
category: "보험",
|
||||
size: "720 KB",
|
||||
uploadDate: "2024-11-30",
|
||||
url: "/documents/cargo-insurance.pdf",
|
||||
description: "화물 배상책임보험",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
name: "차고지 임대계약서.pdf",
|
||||
category: "계약서",
|
||||
size: "1.8 MB",
|
||||
uploadDate: "2024-11-15",
|
||||
url: "/documents/garage-lease-contract.pdf",
|
||||
},
|
||||
];
|
||||
|
||||
export default function DocumentWidget() {
|
||||
const [documents] = useState<Document[]>(mockDocuments);
|
||||
const [filter, setFilter] = useState<"all" | Document["category"]>("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const filteredDocuments = documents.filter((doc) => {
|
||||
const matchesFilter = filter === "all" || doc.category === filter;
|
||||
const matchesSearch =
|
||||
searchTerm === "" ||
|
||||
doc.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
doc.description?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return matchesFilter && matchesSearch;
|
||||
});
|
||||
|
||||
const getCategoryIcon = (category: Document["category"]) => {
|
||||
switch (category) {
|
||||
case "계약서":
|
||||
return "📄";
|
||||
case "보험":
|
||||
return "🛡️";
|
||||
case "세금계산서":
|
||||
return "💰";
|
||||
case "기타":
|
||||
return "📁";
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: Document["category"]) => {
|
||||
switch (category) {
|
||||
case "계약서":
|
||||
return "bg-blue-100 text-blue-700";
|
||||
case "보험":
|
||||
return "bg-green-100 text-green-700";
|
||||
case "세금계산서":
|
||||
return "bg-amber-100 text-amber-700";
|
||||
case "기타":
|
||||
return "bg-gray-100 text-gray-700";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (doc: Document) => {
|
||||
// 실제로는 백엔드 API 호출
|
||||
alert(`다운로드: ${doc.name}\n(실제 구현 시 파일 다운로드 처리)`);
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: documents.length,
|
||||
contract: documents.filter((d) => d.category === "계약서").length,
|
||||
insurance: documents.filter((d) => d.category === "보험").length,
|
||||
tax: documents.filter((d) => d.category === "세금계산서").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-gray-800">📂 문서 관리</h3>
|
||||
<button className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90">
|
||||
+ 업로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 */}
|
||||
<div className="mb-3 grid grid-cols-4 gap-2 text-xs">
|
||||
<div className="rounded bg-gray-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-gray-700">{stats.total}</div>
|
||||
<div className="text-gray-600">전체</div>
|
||||
</div>
|
||||
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-blue-700">{stats.contract}</div>
|
||||
<div className="text-blue-600">계약서</div>
|
||||
</div>
|
||||
<div className="rounded bg-green-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-green-700">{stats.insurance}</div>
|
||||
<div className="text-green-600">보험</div>
|
||||
</div>
|
||||
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-amber-700">{stats.tax}</div>
|
||||
<div className="text-amber-600">계산서</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="mb-3 relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="문서명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full rounded border border-gray-300 py-2 pl-10 pr-3 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex gap-2">
|
||||
{(["all", "계약서", "보험", "세금계산서", "기타"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||
filter === f ? "bg-primary text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "전체" : f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 리스트 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{filteredDocuments.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📭</div>
|
||||
<div>문서가 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredDocuments.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="group flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-primary hover:shadow-md"
|
||||
>
|
||||
{/* 아이콘 */}
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-gray-50 text-2xl">
|
||||
{getCategoryIcon(doc.category)}
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate font-medium text-gray-800">{doc.name}</div>
|
||||
{doc.description && (
|
||||
<div className="mt-0.5 truncate text-xs text-gray-600">{doc.description}</div>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-gray-500">
|
||||
<span className={`rounded px-2 py-0.5 ${getCategoryColor(doc.category)}`}>
|
||||
{doc.category}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(doc.uploadDate).toLocaleDateString()}
|
||||
</span>
|
||||
<span>{doc.size}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다운로드 버튼 */}
|
||||
<button
|
||||
onClick={() => handleDownload(doc)}
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary text-white transition-colors hover:bg-primary/90"
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ import { getExchangeRate, ExchangeRateData } from '@/lib/api/openApi';
|
|||
import { TrendingUp, TrendingDown, RefreshCw, ArrowRightLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface ExchangeWidgetProps {
|
||||
baseCurrency?: string;
|
||||
|
|
@ -29,6 +30,8 @@ export default function ExchangeWidget({
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [calculatorAmount, setCalculatorAmount] = useState<string>('');
|
||||
const [displayAmount, setDisplayAmount] = useState<string>('');
|
||||
|
||||
// 지원 통화 목록
|
||||
const currencies = [
|
||||
|
|
@ -86,6 +89,33 @@ export default function ExchangeWidget({
|
|||
return currencies.find((c) => c.value === currency)?.symbol || currency;
|
||||
};
|
||||
|
||||
// 계산기 금액 입력 처리
|
||||
const handleCalculatorInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
|
||||
// 쉼표 제거 후 숫자만 추출
|
||||
const cleanValue = value.replace(/,/g, '').replace(/[^\d]/g, '');
|
||||
|
||||
// 계산용 원본 값 저장
|
||||
setCalculatorAmount(cleanValue);
|
||||
|
||||
// 표시용 포맷팅된 값 저장
|
||||
if (cleanValue === '') {
|
||||
setDisplayAmount('');
|
||||
} else {
|
||||
const num = parseInt(cleanValue);
|
||||
setDisplayAmount(num.toLocaleString('ko-KR'));
|
||||
}
|
||||
};
|
||||
|
||||
// 계산 결과
|
||||
const calculateResult = () => {
|
||||
const amount = parseFloat(calculatorAmount || '0');
|
||||
if (!exchangeRate || isNaN(amount)) return 0;
|
||||
|
||||
return amount * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate);
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (loading && !exchangeRate) {
|
||||
return (
|
||||
|
|
@ -98,31 +128,15 @@ export default function ExchangeWidget({
|
|||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error || !exchangeRate) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border p-6">
|
||||
<TrendingDown className="h-12 w-12 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600 text-center mb-3">{error || '환율 정보를 불러올 수 없습니다.'}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchExchangeRate}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 에러 상태 - 하지만 계산기는 표시
|
||||
const hasError = error || !exchangeRate;
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-6">
|
||||
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">💱 환율</h3>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-1">💱 환율</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{lastUpdated
|
||||
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
|
||||
|
|
@ -144,9 +158,9 @@ export default function ExchangeWidget({
|
|||
</div>
|
||||
|
||||
{/* 통화 선택 */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Select value={base} onValueChange={setBase}>
|
||||
<SelectTrigger className="flex-1 bg-white">
|
||||
<SelectTrigger className="flex-1 bg-white h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -162,13 +176,13 @@ export default function ExchangeWidget({
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSwap}
|
||||
className="h-10 w-10 p-0 rounded-full hover:bg-white"
|
||||
className="h-8 w-8 p-0 rounded-full hover:bg-white"
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
<ArrowRightLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger className="flex-1 bg-white">
|
||||
<SelectTrigger className="flex-1 bg-white h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -181,54 +195,78 @@ export default function ExchangeWidget({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{hasError && (
|
||||
<div className="mb-3 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-xs text-red-600 text-center">{error || '환율 정보를 불러올 수 없습니다.'}</p>
|
||||
<button
|
||||
onClick={fetchExchangeRate}
|
||||
className="mt-2 w-full text-xs text-red-600 hover:text-red-700 underline"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 환율 표시 */}
|
||||
<div className="bg-white rounded-lg border p-4 mb-4">
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
{exchangeRate.base === 'KRW' ? '1,000' : '1'} {getCurrencySymbol(exchangeRate.base)} =
|
||||
{!hasError && (
|
||||
<div className="mb-2 bg-white rounded-lg border p-2">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-400 mb-0.5">
|
||||
{exchangeRate.base === 'KRW' ? '1,000' : '1'} {getCurrencySymbol(exchangeRate.base)} =
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{exchangeRate.base === 'KRW'
|
||||
? (exchangeRate.rate * 1000).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
: exchangeRate.rate.toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">{getCurrencySymbol(exchangeRate.target)}</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-1">
|
||||
{exchangeRate.base === 'KRW'
|
||||
? (exchangeRate.rate * 1000).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 2,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 계산기 입력 */}
|
||||
<div className="bg-white rounded-lg border p-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={displayAmount || ""}
|
||||
onChange={handleCalculatorInput}
|
||||
placeholder="금액 직접 입력"
|
||||
autoComplete="off"
|
||||
className="flex-1 text-center text-sm font-semibold"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 w-12">{base}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 to-transparent" />
|
||||
<span className="text-xs text-gray-400">▼</span>
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 text-center text-lg font-bold text-green-600 bg-green-50 border border-green-200 rounded px-2 py-1.5">
|
||||
{calculateResult().toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
: exchangeRate.rate.toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{getCurrencySymbol(exchangeRate.target)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 계산 예시 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">10,000 {base}</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{(10000 * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate)).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
{target}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 w-12">{target}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">100,000 {base}</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{(100000 * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate)).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
{target}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 출처 */}
|
||||
<div className="mt-4 pt-3 border-t text-center">
|
||||
<div className="mt-3 pt-2 border-t text-center">
|
||||
<p className="text-xs text-gray-400">출처: {exchangeRate.source}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,244 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Calendar, Wrench, Truck, Check, Clock, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface MaintenanceSchedule {
|
||||
id: string;
|
||||
vehicleNumber: string;
|
||||
vehicleType: string;
|
||||
maintenanceType: "정기점검" | "수리" | "타이어교체" | "오일교환" | "기타";
|
||||
scheduledDate: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "overdue";
|
||||
notes?: string;
|
||||
estimatedCost?: number;
|
||||
}
|
||||
|
||||
// 목 데이터
|
||||
const mockSchedules: MaintenanceSchedule[] = [
|
||||
{
|
||||
id: "1",
|
||||
vehicleNumber: "서울12가3456",
|
||||
vehicleType: "1톤 트럭",
|
||||
maintenanceType: "정기점검",
|
||||
scheduledDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "scheduled",
|
||||
notes: "6개월 정기점검",
|
||||
estimatedCost: 300000,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
vehicleNumber: "경기34나5678",
|
||||
vehicleType: "2.5톤 트럭",
|
||||
maintenanceType: "오일교환",
|
||||
scheduledDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "scheduled",
|
||||
estimatedCost: 150000,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
vehicleNumber: "인천56다7890",
|
||||
vehicleType: "라보",
|
||||
maintenanceType: "타이어교체",
|
||||
scheduledDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: "overdue",
|
||||
notes: "긴급",
|
||||
estimatedCost: 400000,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
vehicleNumber: "부산78라1234",
|
||||
vehicleType: "1톤 트럭",
|
||||
maintenanceType: "수리",
|
||||
scheduledDate: new Date().toISOString(),
|
||||
status: "in_progress",
|
||||
notes: "엔진 점검 중",
|
||||
estimatedCost: 800000,
|
||||
},
|
||||
];
|
||||
|
||||
export default function MaintenanceWidget() {
|
||||
const [schedules] = useState<MaintenanceSchedule[]>(mockSchedules);
|
||||
const [filter, setFilter] = useState<"all" | MaintenanceSchedule["status"]>("all");
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||
|
||||
const filteredSchedules = schedules.filter(
|
||||
(s) => filter === "all" || s.status === filter
|
||||
);
|
||||
|
||||
const getStatusBadge = (status: MaintenanceSchedule["status"]) => {
|
||||
switch (status) {
|
||||
case "scheduled":
|
||||
return <span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">예정</span>;
|
||||
case "in_progress":
|
||||
return <span className="rounded bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">진행중</span>;
|
||||
case "completed":
|
||||
return <span className="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-700">완료</span>;
|
||||
case "overdue":
|
||||
return <span className="rounded bg-red-100 px-2 py-1 text-xs font-medium text-red-700">지연</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const getMaintenanceIcon = (type: MaintenanceSchedule["maintenanceType"]) => {
|
||||
switch (type) {
|
||||
case "정기점검":
|
||||
return "🔍";
|
||||
case "수리":
|
||||
return "🔧";
|
||||
case "타이어교체":
|
||||
return "⚙️";
|
||||
case "오일교환":
|
||||
return "🛢️";
|
||||
default:
|
||||
return "🔧";
|
||||
}
|
||||
};
|
||||
|
||||
const getDaysUntil = (date: string) => {
|
||||
const now = new Date();
|
||||
const scheduled = new Date(date);
|
||||
const diff = scheduled.getTime() - now.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 0) return `${Math.abs(days)}일 지연`;
|
||||
if (days === 0) return "오늘";
|
||||
if (days === 1) return "내일";
|
||||
return `${days}일 후`;
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: schedules.length,
|
||||
scheduled: schedules.filter((s) => s.status === "scheduled").length,
|
||||
inProgress: schedules.filter((s) => s.status === "in_progress").length,
|
||||
overdue: schedules.filter((s) => s.status === "overdue").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-teal-50">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-gray-800">🔧 정비 일정 관리</h3>
|
||||
<button className="rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90">
|
||||
+ 일정 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 */}
|
||||
<div className="mb-3 grid grid-cols-4 gap-2 text-xs">
|
||||
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-blue-700">{stats.scheduled}</div>
|
||||
<div className="text-blue-600">예정</div>
|
||||
</div>
|
||||
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-amber-700">{stats.inProgress}</div>
|
||||
<div className="text-amber-600">진행중</div>
|
||||
</div>
|
||||
<div className="rounded bg-red-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-red-700">{stats.overdue}</div>
|
||||
<div className="text-red-600">지연</div>
|
||||
</div>
|
||||
<div className="rounded bg-gray-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-gray-700">{stats.total}</div>
|
||||
<div className="text-gray-600">전체</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex gap-2">
|
||||
{(["all", "scheduled", "in_progress", "overdue"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||
filter === f ? "bg-primary text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "전체" : f === "scheduled" ? "예정" : f === "in_progress" ? "진행중" : "지연"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 일정 리스트 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{filteredSchedules.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📅</div>
|
||||
<div>정비 일정이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredSchedules.map((schedule) => (
|
||||
<div
|
||||
key={schedule.id}
|
||||
className={`group rounded-lg border-2 bg-white p-4 shadow-sm transition-all hover:shadow-md ${
|
||||
schedule.status === "overdue" ? "border-red-300" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{getMaintenanceIcon(schedule.maintenanceType)}</span>
|
||||
<div>
|
||||
<div className="font-bold text-gray-800">{schedule.vehicleNumber}</div>
|
||||
<div className="text-xs text-gray-600">{schedule.vehicleType}</div>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(schedule.status)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 rounded bg-gray-50 p-2">
|
||||
<div className="text-sm font-medium text-gray-700">{schedule.maintenanceType}</div>
|
||||
{schedule.notes && <div className="mt-1 text-xs text-gray-600">{schedule.notes}</div>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center gap-1 text-gray-600">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(schedule.scheduledDate).toLocaleDateString()}
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-1 font-medium ${
|
||||
schedule.status === "overdue" ? "text-red-600" : "text-blue-600"
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
{getDaysUntil(schedule.scheduledDate)}
|
||||
</div>
|
||||
{schedule.estimatedCost && (
|
||||
<div className="col-span-2 font-bold text-primary">
|
||||
예상 비용: {schedule.estimatedCost.toLocaleString()}원
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{schedule.status === "scheduled" && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="flex-1 rounded bg-blue-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-600">
|
||||
시작
|
||||
</button>
|
||||
<button className="flex-1 rounded bg-gray-200 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-300">
|
||||
일정 변경
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{schedule.status === "in_progress" && (
|
||||
<div className="mt-3">
|
||||
<button className="w-full rounded bg-green-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-600">
|
||||
<Check className="mr-1 inline h-3 w-3" />
|
||||
완료 처리
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { RefreshCw, AlertTriangle, Cloud, Construction } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
// 알림 타입
|
||||
type AlertType = "accident" | "weather" | "construction";
|
||||
|
||||
// 알림 인터페이스
|
||||
interface Alert {
|
||||
id: string;
|
||||
type: AlertType;
|
||||
severity: "high" | "medium" | "low";
|
||||
title: string;
|
||||
location: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export default function RiskAlertWidget() {
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [filter, setFilter] = useState<AlertType | "all">("all");
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [newAlertIds, setNewAlertIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 데이터 로드 (백엔드 통합 호출)
|
||||
const loadData = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// 백엔드 API 호출 (교통사고, 기상특보, 도로공사 통합)
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: Alert[];
|
||||
count: number;
|
||||
lastUpdated?: string;
|
||||
cached?: boolean;
|
||||
}>("/risk-alerts");
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const newData = response.data.data;
|
||||
|
||||
// 새로운 알림 감지
|
||||
const oldIds = new Set(alerts.map(a => a.id));
|
||||
const newIds = new Set<string>();
|
||||
newData.forEach(alert => {
|
||||
if (!oldIds.has(alert.id)) {
|
||||
newIds.add(alert.id);
|
||||
}
|
||||
});
|
||||
|
||||
setAlerts(newData);
|
||||
setNewAlertIds(newIds);
|
||||
setLastUpdated(new Date());
|
||||
|
||||
// 3초 후 새 알림 애니메이션 제거
|
||||
if (newIds.size > 0) {
|
||||
setTimeout(() => setNewAlertIds(new Set()), 3000);
|
||||
}
|
||||
} else {
|
||||
console.error("❌ 리스크 알림 데이터 로드 실패");
|
||||
setAlerts([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("❌ 리스크 알림 API 오류:", error.message);
|
||||
// API 오류 시 빈 배열 유지
|
||||
setAlerts([]);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// 1분마다 자동 새로고침 (60000ms)
|
||||
const interval = setInterval(loadData, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 필터링된 알림
|
||||
const filteredAlerts = filter === "all" ? alerts : alerts.filter((alert) => alert.type === filter);
|
||||
|
||||
// 심각도별 색상
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case "high":
|
||||
return "border-red-500";
|
||||
case "medium":
|
||||
return "border-yellow-500";
|
||||
case "low":
|
||||
return "border-blue-500";
|
||||
default:
|
||||
return "border-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
// 심각도별 배지 색상
|
||||
const getSeverityBadge = (severity: string) => {
|
||||
switch (severity) {
|
||||
case "high":
|
||||
return "bg-red-100 text-red-700";
|
||||
case "medium":
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
case "low":
|
||||
return "bg-blue-100 text-blue-700";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700";
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 타입별 아이콘
|
||||
const getAlertIcon = (type: AlertType) => {
|
||||
switch (type) {
|
||||
case "accident":
|
||||
return <AlertTriangle className="h-5 w-5 text-red-600" />;
|
||||
case "weather":
|
||||
return <Cloud className="h-5 w-5 text-blue-600" />;
|
||||
case "construction":
|
||||
return <Construction className="h-5 w-5 text-yellow-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 알림 타입별 한글명
|
||||
const getAlertTypeName = (type: AlertType) => {
|
||||
switch (type) {
|
||||
case "accident":
|
||||
return "교통사고";
|
||||
case "weather":
|
||||
return "날씨특보";
|
||||
case "construction":
|
||||
return "도로공사";
|
||||
}
|
||||
};
|
||||
|
||||
// 시간 포맷
|
||||
const formatTime = (isoString: string) => {
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diffMinutes = Math.floor((now.getTime() - date.getTime()) / 60000);
|
||||
|
||||
if (diffMinutes < 1) return "방금 전";
|
||||
if (diffMinutes < 60) return `${diffMinutes}분 전`;
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours}시간 전`;
|
||||
return `${Math.floor(diffHours / 24)}일 전`;
|
||||
};
|
||||
|
||||
// 통계 계산
|
||||
const stats = {
|
||||
accident: alerts.filter((a) => a.type === "accident").length,
|
||||
weather: alerts.filter((a) => a.type === "weather").length,
|
||||
construction: alerts.filter((a) => a.type === "construction").length,
|
||||
high: alerts.filter((a) => a.severity === "high").length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-3 overflow-hidden bg-slate-50 p-3">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||
<h3 className="text-base font-semibold text-gray-900">리스크 / 알림</h3>
|
||||
{stats.high > 0 && (
|
||||
<Badge className="bg-red-100 text-red-700 hover:bg-red-100">긴급 {stats.high}건</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{lastUpdated && newAlertIds.size > 0 && (
|
||||
<Badge className="bg-blue-100 text-blue-700 text-xs animate-pulse">
|
||||
새 알림 {newAlertIds.size}건
|
||||
</Badge>
|
||||
)}
|
||||
{lastUpdated && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{lastUpdated.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={loadData} disabled={isRefreshing} className="h-8 px-2">
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Card
|
||||
className={`cursor-pointer border-l-4 border-red-500 p-2 transition-colors hover:bg-gray-50 ${filter === "accident" ? "bg-gray-100" : ""}`}
|
||||
onClick={() => setFilter(filter === "accident" ? "all" : "accident")}
|
||||
>
|
||||
<div className="text-xs text-gray-600">교통사고</div>
|
||||
<div className="text-lg font-bold text-gray-900">{stats.accident}건</div>
|
||||
</Card>
|
||||
<Card
|
||||
className={`cursor-pointer border-l-4 border-blue-500 p-2 transition-colors hover:bg-gray-50 ${filter === "weather" ? "bg-gray-100" : ""}`}
|
||||
onClick={() => setFilter(filter === "weather" ? "all" : "weather")}
|
||||
>
|
||||
<div className="text-xs text-gray-600">날씨특보</div>
|
||||
<div className="text-lg font-bold text-gray-900">{stats.weather}건</div>
|
||||
</Card>
|
||||
<Card
|
||||
className={`cursor-pointer border-l-4 border-yellow-500 p-2 transition-colors hover:bg-gray-50 ${filter === "construction" ? "bg-gray-100" : ""}`}
|
||||
onClick={() => setFilter(filter === "construction" ? "all" : "construction")}
|
||||
>
|
||||
<div className="text-xs text-gray-600">도로공사</div>
|
||||
<div className="text-lg font-bold text-gray-900">{stats.construction}건</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 필터 상태 표시 */}
|
||||
{filter !== "all" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getAlertTypeName(filter)} 필터 적용 중
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFilter("all")}
|
||||
className="h-6 px-2 text-xs text-gray-600"
|
||||
>
|
||||
전체 보기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 알림 목록 */}
|
||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
{filteredAlerts.length === 0 ? (
|
||||
<Card className="p-4 text-center">
|
||||
<div className="text-sm text-gray-500">알림이 없습니다</div>
|
||||
</Card>
|
||||
) : (
|
||||
filteredAlerts.map((alert) => (
|
||||
<Card
|
||||
key={alert.id}
|
||||
className={`border-l-4 p-3 transition-all duration-300 ${getSeverityColor(alert.severity)} ${
|
||||
newAlertIds.has(alert.id) ? 'bg-blue-50/30 ring-1 ring-blue-200' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
{getAlertIcon(alert.type)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900">{alert.title}</h4>
|
||||
{newAlertIds.has(alert.id) && (
|
||||
<Badge className="bg-blue-100 text-blue-700 text-xs">
|
||||
NEW
|
||||
</Badge>
|
||||
)}
|
||||
<Badge className={`text-xs ${getSeverityBadge(alert.severity)}`}>
|
||||
{alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs font-medium text-gray-700">{alert.location}</p>
|
||||
<p className="mt-1 text-xs text-gray-600">{alert.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-right text-xs text-gray-500">{formatTime(alert.timestamp)}</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 메시지 */}
|
||||
<div className="border-t border-gray-200 pt-2 text-center text-xs text-gray-500">
|
||||
💡 1분마다 자동으로 업데이트됩니다
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, Check, X, Clock, AlertCircle, GripVertical, ChevronDown } from "lucide-react";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface TodoStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
urgent: number;
|
||||
overdue: number;
|
||||
}
|
||||
|
||||
export default function TodoWidget() {
|
||||
const [todos, setTodos] = useState<TodoItem[]>([]);
|
||||
const [stats, setStats] = useState<TodoStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<"all" | "pending" | "in_progress" | "completed">("all");
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newTodo, setNewTodo] = useState({
|
||||
title: "",
|
||||
description: "",
|
||||
priority: "normal" as TodoItem["priority"],
|
||||
isUrgent: false,
|
||||
dueDate: "",
|
||||
assignedTo: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchTodos();
|
||||
const interval = setInterval(fetchTodos, 30000); // 30초마다 갱신
|
||||
return () => clearInterval(interval);
|
||||
}, [filter]);
|
||||
|
||||
const fetchTodos = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem("authToken");
|
||||
const filterParam = filter !== "all" ? `?status=${filter}` : "";
|
||||
const response = await fetch(`http://localhost:9771/api/todos${filterParam}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setTodos(result.data || []);
|
||||
setStats(result.stats);
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("To-Do 로딩 오류:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTodo = async () => {
|
||||
if (!newTodo.title.trim()) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch("http://localhost:9771/api/todos", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(newTodo),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNewTodo({
|
||||
title: "",
|
||||
description: "",
|
||||
priority: "normal",
|
||||
isUrgent: false,
|
||||
dueDate: "",
|
||||
assignedTo: "",
|
||||
});
|
||||
setShowAddForm(false);
|
||||
fetchTodos();
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("To-Do 추가 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (id: string, status: TodoItem["status"]) => {
|
||||
try {
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(`http://localhost:9771/api/todos/${id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchTodos();
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("상태 업데이트 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("이 To-Do를 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await fetch(`http://localhost:9771/api/todos/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchTodos();
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("To-Do 삭제 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: TodoItem["priority"]) => {
|
||||
switch (priority) {
|
||||
case "urgent":
|
||||
return "bg-red-100 text-red-700 border-red-300";
|
||||
case "high":
|
||||
return "bg-orange-100 text-orange-700 border-orange-300";
|
||||
case "normal":
|
||||
return "bg-blue-100 text-blue-700 border-blue-300";
|
||||
case "low":
|
||||
return "bg-gray-100 text-gray-700 border-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityIcon = (priority: TodoItem["priority"]) => {
|
||||
switch (priority) {
|
||||
case "urgent":
|
||||
return "🔴";
|
||||
case "high":
|
||||
return "🟠";
|
||||
case "normal":
|
||||
return "🟡";
|
||||
case "low":
|
||||
return "🟢";
|
||||
}
|
||||
};
|
||||
|
||||
const getTimeRemaining = (dueDate: string) => {
|
||||
const now = new Date();
|
||||
const due = new Date(dueDate);
|
||||
const diff = due.getTime() - now.getTime();
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (diff < 0) return "⏰ 기한 초과";
|
||||
if (days > 0) return `📅 ${days}일 남음`;
|
||||
if (hours > 0) return `⏱️ ${hours}시간 남음`;
|
||||
return "⚠️ 오늘 마감";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-gray-500">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gradient-to-br from-slate-50 to-blue-50">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b border-gray-200 bg-white px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold text-gray-800">✅ To-Do / 긴급 지시</h3>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-white transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||
<div className="rounded bg-blue-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-blue-700">{stats.pending}</div>
|
||||
<div className="text-blue-600">대기</div>
|
||||
</div>
|
||||
<div className="rounded bg-amber-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-amber-700">{stats.inProgress}</div>
|
||||
<div className="text-amber-600">진행중</div>
|
||||
</div>
|
||||
<div className="rounded bg-red-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-red-700">{stats.urgent}</div>
|
||||
<div className="text-red-600">긴급</div>
|
||||
</div>
|
||||
<div className="rounded bg-rose-50 px-2 py-1.5 text-center">
|
||||
<div className="font-bold text-rose-700">{stats.overdue}</div>
|
||||
<div className="text-rose-600">지연</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="mt-3 flex gap-2">
|
||||
{(["all", "pending", "in_progress", "completed"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
||||
filter === f
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{f === "all" ? "전체" : f === "pending" ? "대기" : f === "in_progress" ? "진행중" : "완료"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 추가 폼 */}
|
||||
{showAddForm && (
|
||||
<div className="border-b border-gray-200 bg-white p-4">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="할 일 제목*"
|
||||
value={newTodo.title}
|
||||
onChange={(e) => setNewTodo({ ...newTodo, title: e.target.value })}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="상세 설명 (선택)"
|
||||
value={newTodo.description}
|
||||
onChange={(e) => setNewTodo({ ...newTodo, description: e.target.value })}
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select
|
||||
value={newTodo.priority}
|
||||
onChange={(e) => setNewTodo({ ...newTodo, priority: e.target.value as TodoItem["priority"] })}
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
>
|
||||
<option value="low">🟢 낮음</option>
|
||||
<option value="normal">🟡 보통</option>
|
||||
<option value="high">🟠 높음</option>
|
||||
<option value="urgent">🔴 긴급</option>
|
||||
</select>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={newTodo.dueDate}
|
||||
onChange={(e) => setNewTodo({ ...newTodo, dueDate: e.target.value })}
|
||||
className="rounded border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newTodo.isUrgent}
|
||||
onChange={(e) => setNewTodo({ ...newTodo, isUrgent: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-red-600 font-medium">긴급 지시</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAddTodo}
|
||||
className="flex-1 rounded bg-primary px-4 py-2 text-sm text-white hover:bg-primary/90"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-300"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* To-Do 리스트 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{todos.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📝</div>
|
||||
<div>할 일이 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{todos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={`group relative rounded-lg border-2 bg-white p-3 shadow-sm transition-all hover:shadow-md ${
|
||||
todo.isUrgent ? "border-red-400" : "border-gray-200"
|
||||
} ${todo.status === "completed" ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* 우선순위 아이콘 */}
|
||||
<div className="mt-1 text-lg">{getPriorityIcon(todo.priority)}</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className={`font-medium ${todo.status === "completed" ? "line-through" : ""}`}>
|
||||
{todo.isUrgent && <span className="mr-1 text-red-600">⚡</span>}
|
||||
{todo.title}
|
||||
</div>
|
||||
{todo.description && (
|
||||
<div className="mt-1 text-xs text-gray-600">{todo.description}</div>
|
||||
)}
|
||||
{todo.dueDate && (
|
||||
<div className="mt-1 text-xs text-gray-500">{getTimeRemaining(todo.dueDate)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex gap-1">
|
||||
{todo.status !== "completed" && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(todo.id, "completed")}
|
||||
className="rounded p-1 text-green-600 hover:bg-green-50"
|
||||
title="완료"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(todo.id)}
|
||||
className="rounded p-1 text-red-600 hover:bg-red-50"
|
||||
title="삭제"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 변경 */}
|
||||
{todo.status !== "completed" && (
|
||||
<div className="mt-2 flex gap-1">
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(todo.id, "pending")}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
todo.status === "pending"
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
대기
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(todo.id, "in_progress")}
|
||||
className={`rounded px-2 py-1 text-xs ${
|
||||
todo.status === "in_progress"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
진행중
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { RefreshCw, Truck, Navigation, Gauge } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Vehicle {
|
||||
id: string;
|
||||
vehicle_number: string;
|
||||
vehicle_name: string;
|
||||
driver_name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
status: string;
|
||||
speed: number;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
interface VehicleListWidgetProps {
|
||||
element?: any; // 대시보드 요소 (dataSource 포함)
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export default function VehicleListWidget({ element, refreshInterval = 30000 }: VehicleListWidgetProps) {
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>("all");
|
||||
|
||||
const loadVehicles = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// 설정된 쿼리가 없으면 로딩 중단 (기본 쿼리 사용 안 함)
|
||||
if (!element?.dataSource?.query) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = element.dataSource.query;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dashboards/execute-query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data.rows.length > 0) {
|
||||
setVehicles(result.data.rows);
|
||||
setLastUpdate(new Date());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("차량 목록 로드 실패:", error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 데이터 로드 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
loadVehicles();
|
||||
const interval = setInterval(loadVehicles, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [element?.dataSource?.query, refreshInterval]);
|
||||
|
||||
// 설정되지 않았을 때도 빈 상태로 표시 (안내 메시지 제거)
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const s = status?.toLowerCase() || "";
|
||||
if (s === "active" || s === "running") return "bg-green-500";
|
||||
if (s === "inactive" || s === "idle") return "bg-yellow-500";
|
||||
if (s === "maintenance") return "bg-orange-500";
|
||||
if (s === "warning" || s === "breakdown") return "bg-red-500";
|
||||
return "bg-gray-500";
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const s = status?.toLowerCase() || "";
|
||||
if (s === "active" || s === "running") return "운행 중";
|
||||
if (s === "inactive" || s === "idle") return "대기";
|
||||
if (s === "maintenance") return "정비";
|
||||
if (s === "warning" || s === "breakdown") return "고장";
|
||||
return "알 수 없음";
|
||||
};
|
||||
|
||||
const filteredVehicles =
|
||||
selectedStatus === "all" ? vehicles : vehicles.filter((v) => v.status?.toLowerCase() === selectedStatus);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-gradient-to-br from-slate-50 to-blue-50 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">📋 차량 목록</h3>
|
||||
<p className="text-xs text-gray-500">마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadVehicles} disabled={isLoading} className="h-8 w-8 p-0">
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필터 버튼 */}
|
||||
<div className="mb-3 flex gap-2 overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setSelectedStatus("all")}
|
||||
className={`whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selectedStatus === "all" ? "bg-gray-900 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
전체 ({vehicles.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("active")}
|
||||
className={`whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selectedStatus === "active" ? "bg-green-500 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
운행 중 ({vehicles.filter((v) => v.status?.toLowerCase() === "active").length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("inactive")}
|
||||
className={`whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selectedStatus === "inactive" ? "bg-yellow-500 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
대기 ({vehicles.filter((v) => v.status?.toLowerCase() === "inactive").length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("maintenance")}
|
||||
className={`whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selectedStatus === "maintenance" ? "bg-orange-500 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
정비 ({vehicles.filter((v) => v.status?.toLowerCase() === "maintenance").length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedStatus("warning")}
|
||||
className={`whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||
selectedStatus === "warning" ? "bg-red-500 text-white" : "bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
고장 ({vehicles.filter((v) => v.status?.toLowerCase() === "warning").length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 차량 목록 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredVehicles.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white">
|
||||
<div className="text-center">
|
||||
<Truck className="mx-auto h-12 w-12 text-gray-300" />
|
||||
<p className="mt-2 text-sm text-gray-500">차량이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredVehicles.map((vehicle) => (
|
||||
<div
|
||||
key={vehicle.id}
|
||||
className="rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Truck className="h-4 w-4 text-gray-600" />
|
||||
<span className="font-semibold text-gray-900">{vehicle.vehicle_name}</span>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold text-white ${getStatusColor(vehicle.status)}`}>
|
||||
{getStatusText(vehicle.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">차량번호</span>
|
||||
<span className="font-mono font-medium">{vehicle.vehicle_number}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">기사</span>
|
||||
<span className="font-medium">{vehicle.driver_name || "미배정"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Navigation className="h-3 w-3 text-gray-400" />
|
||||
<span className="flex-1 truncate text-gray-700">{vehicle.destination || "대기 중"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Gauge className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-gray-700">{vehicle.speed || 0} km/h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
if (typeof window !== "undefined") {
|
||||
const L = require("leaflet");
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
});
|
||||
}
|
||||
|
||||
// Leaflet 동적 import (SSR 방지)
|
||||
const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false });
|
||||
const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false });
|
||||
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
|
||||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||||
const Circle = dynamic(() => import("react-leaflet").then((mod) => mod.Circle), { ssr: false });
|
||||
|
||||
// 브이월드 API 키
|
||||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||||
|
||||
interface Vehicle {
|
||||
id: string;
|
||||
name: string;
|
||||
driver: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
status: "active" | "inactive" | "maintenance" | "warning";
|
||||
speed: number;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
interface VehicleMapOnlyWidgetProps {
|
||||
element?: any; // 대시보드 요소 (dataSource, chartConfig 포함)
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000 }: VehicleMapOnlyWidgetProps) {
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
|
||||
const loadVehicles = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// 설정된 쿼리가 없으면 로딩 중단
|
||||
if (!element?.dataSource?.query) {
|
||||
setIsLoading(false);
|
||||
setVehicles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 설정된 컬럼 매핑 확인
|
||||
if (!element?.chartConfig?.latitudeColumn || !element?.chartConfig?.longitudeColumn) {
|
||||
setIsLoading(false);
|
||||
setVehicles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dashboards/execute-query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: element.dataSource.query,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data.rows.length > 0) {
|
||||
// 설정된 컬럼 매핑 가져오기
|
||||
const latCol = element.chartConfig.latitudeColumn;
|
||||
const lngCol = element.chartConfig.longitudeColumn;
|
||||
const labelCol = element.chartConfig.labelColumn || "name";
|
||||
const statusCol = element.chartConfig.statusColumn || "status";
|
||||
|
||||
// DB 데이터를 Vehicle 형식으로 변환
|
||||
const vehiclesFromDB: Vehicle[] = result.data.rows.map((row: any, index: number) => ({
|
||||
id: row.id || row.vehicle_number || `V${index + 1}`,
|
||||
name: row[labelCol] || `차량 ${index + 1}`,
|
||||
driver: row.driver_name || row.driver || "미배정",
|
||||
lat: parseFloat(row[latCol]),
|
||||
lng: parseFloat(row[lngCol]),
|
||||
status:
|
||||
row[statusCol] === "warning"
|
||||
? "warning"
|
||||
: row[statusCol] === "active"
|
||||
? "active"
|
||||
: row[statusCol] === "maintenance"
|
||||
? "maintenance"
|
||||
: "inactive",
|
||||
speed: parseFloat(row.speed) || 0,
|
||||
destination: row.destination || "대기 중",
|
||||
}));
|
||||
|
||||
setVehicles(vehiclesFromDB);
|
||||
setLastUpdate(new Date());
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("차량 데이터 로드 실패:", error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// useEffect는 항상 같은 순서로 호출되어야 함 (early return 전에 배치)
|
||||
useEffect(() => {
|
||||
loadVehicles();
|
||||
const interval = setInterval(loadVehicles, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [element?.dataSource?.query, element?.chartConfig?.latitudeColumn, element?.chartConfig?.longitudeColumn, refreshInterval]);
|
||||
|
||||
// 쿼리 없으면 빈 지도만 표시 (안내 메시지 제거)
|
||||
|
||||
const getStatusColor = (status: Vehicle["status"]) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "#22c55e"; // 운행 중 - 초록
|
||||
case "inactive":
|
||||
return "#eab308"; // 대기 - 노랑
|
||||
case "maintenance":
|
||||
return "#f97316"; // 정비 - 주황
|
||||
case "warning":
|
||||
return "#ef4444"; // 고장 - 빨강
|
||||
default:
|
||||
return "#6b7280"; // 기타 - 회색
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: Vehicle["status"]) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return "운행 중";
|
||||
case "inactive":
|
||||
return "대기";
|
||||
case "maintenance":
|
||||
return "정비";
|
||||
case "warning":
|
||||
return "고장";
|
||||
default:
|
||||
return "알 수 없음";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gradient-to-br from-slate-50 to-blue-50 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">🗺️ 차량 위치 지도</h3>
|
||||
<p className="text-xs text-gray-500">마지막 업데이트: {lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadVehicles} disabled={isLoading} className="h-8 w-8 p-0">
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 지도 영역 - 브이월드 타일맵 */}
|
||||
<div className="h-[calc(100%-60px)]">
|
||||
<div className="relative h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white">
|
||||
<MapContainer
|
||||
center={[36.5, 127.5]}
|
||||
zoom={7}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
zoomControl={true}
|
||||
preferCanvas={true}
|
||||
>
|
||||
{/* 브이월드 타일맵 (HTTPS, 캐싱 적용) */}
|
||||
<TileLayer
|
||||
url={`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`}
|
||||
attribution='© <a href="https://www.vworld.kr">VWorld (국토교통부)</a>'
|
||||
maxZoom={19}
|
||||
minZoom={7}
|
||||
updateWhenIdle={true}
|
||||
updateWhenZooming={false}
|
||||
keepBuffer={2}
|
||||
/>
|
||||
|
||||
{/* 차량 마커 */}
|
||||
{vehicles.map((vehicle) => (
|
||||
<React.Fragment key={vehicle.id}>
|
||||
<Circle
|
||||
center={[vehicle.lat, vehicle.lng]}
|
||||
radius={150}
|
||||
pathOptions={{
|
||||
color: getStatusColor(vehicle.status),
|
||||
fillColor: getStatusColor(vehicle.status),
|
||||
fillOpacity: 0.3,
|
||||
}}
|
||||
/>
|
||||
<Marker position={[vehicle.lat, vehicle.lng]}>
|
||||
<Popup>
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 text-sm font-bold">{vehicle.name}</div>
|
||||
<div>
|
||||
<strong>기사:</strong> {vehicle.driver}
|
||||
</div>
|
||||
<div>
|
||||
<strong>상태:</strong> {getStatusText(vehicle.status)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>속도:</strong> {vehicle.speed} km/h
|
||||
</div>
|
||||
<div>
|
||||
<strong>목적지:</strong> {vehicle.destination}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</MapContainer>
|
||||
|
||||
{/* 지도 정보 */}
|
||||
<div className="absolute right-2 top-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
|
||||
<div className="text-xs text-gray-600">
|
||||
<div className="mb-1 font-semibold">🗺️ 브이월드 (VWorld)</div>
|
||||
<div className="text-xs">국토교통부 공식 지도</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 차량 수 표시 또는 설정 안내 */}
|
||||
<div className="absolute bottom-2 left-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
|
||||
{vehicles.length > 0 ? (
|
||||
<div className="text-xs font-semibold text-gray-900">총 {vehicles.length}대 모니터링 중</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-600">
|
||||
⚙️ 톱니바퀴 클릭하여 데이터 연결
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { RefreshCw, TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface VehicleStatusWidgetProps {
|
||||
element?: any; // 대시보드 요소 (dataSource 포함)
|
||||
refreshInterval?: number;
|
||||
}
|
||||
|
||||
interface StatusData {
|
||||
active: number; // 운행 중
|
||||
inactive: number; // 대기
|
||||
maintenance: number; // 정비
|
||||
warning: number; // 고장
|
||||
total: number;
|
||||
}
|
||||
|
||||
export default function VehicleStatusWidget({ element, refreshInterval = 30000 }: VehicleStatusWidgetProps) {
|
||||
const [statusData, setStatusData] = useState<StatusData>({
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
maintenance: 0,
|
||||
warning: 0,
|
||||
total: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
|
||||
|
||||
const loadStatusData = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
// 설정된 쿼리가 없으면 로딩 중단 (기본 쿼리 사용 안 함)
|
||||
if (!element?.dataSource?.query) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = element.dataSource.query;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dashboards/execute-query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success && result.data.rows.length > 0) {
|
||||
const newStatus: StatusData = {
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
maintenance: 0,
|
||||
warning: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
// 쿼리 결과가 GROUP BY 형식인지 확인
|
||||
const isGroupedData = result.data.rows[0].count !== undefined;
|
||||
|
||||
if (isGroupedData) {
|
||||
// GROUP BY 형식: SELECT status, COUNT(*) as count
|
||||
result.data.rows.forEach((row: any) => {
|
||||
const count = parseInt(row.count) || 0;
|
||||
const status = row.status?.toLowerCase() || "";
|
||||
|
||||
if (status === "active" || status === "running") {
|
||||
newStatus.active = count;
|
||||
} else if (status === "inactive" || status === "idle") {
|
||||
newStatus.inactive = count;
|
||||
} else if (status === "maintenance") {
|
||||
newStatus.maintenance = count;
|
||||
} else if (status === "warning" || status === "breakdown") {
|
||||
newStatus.warning = count;
|
||||
}
|
||||
|
||||
newStatus.total += count;
|
||||
});
|
||||
} else {
|
||||
// SELECT * 형식: 전체 데이터를 가져와서 카운트
|
||||
result.data.rows.forEach((row: any) => {
|
||||
const status = row.status?.toLowerCase() || "";
|
||||
|
||||
if (status === "active" || status === "running") {
|
||||
newStatus.active++;
|
||||
} else if (status === "inactive" || status === "idle") {
|
||||
newStatus.inactive++;
|
||||
} else if (status === "maintenance") {
|
||||
newStatus.maintenance++;
|
||||
} else if (status === "warning" || status === "breakdown") {
|
||||
newStatus.warning++;
|
||||
}
|
||||
|
||||
newStatus.total++;
|
||||
});
|
||||
}
|
||||
|
||||
setStatusData(newStatus);
|
||||
setLastUpdate(new Date());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("차량 상태 데이터 로드 실패:", error);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 데이터 로드 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
loadStatusData();
|
||||
const interval = setInterval(loadStatusData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [element?.dataSource?.query, refreshInterval]);
|
||||
|
||||
// 설정되지 않았을 때도 빈 상태로 표시 (안내 메시지 제거)
|
||||
|
||||
const activeRate = statusData.total > 0 ? ((statusData.active / statusData.total) * 100).toFixed(1) : "0";
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-green-50 p-2">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">📊 차량 상태 현황</h3>
|
||||
{statusData.total > 0 ? (
|
||||
<p className="text-xs text-gray-500">{lastUpdate.toLocaleTimeString("ko-KR")}</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">⚙️ 데이터 연결 필요</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={loadStatusData} disabled={isLoading} className="h-7 w-7 p-0">
|
||||
<RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 스크롤 가능한 콘텐츠 영역 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 총 차량 수 */}
|
||||
<div className="mb-1 rounded border border-gray-200 bg-white p-1.5 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-gray-600">총 차량</div>
|
||||
<div className="text-base font-bold text-gray-900">{statusData.total}대</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-gray-600">가동률</div>
|
||||
<div className="flex items-center gap-0.5 text-sm font-bold text-green-600">
|
||||
{activeRate}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태별 카드 */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 운행 중 */}
|
||||
<div className="rounded border-l-2 border-green-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500"></div>
|
||||
<div className="text-xs font-medium text-gray-600">운행</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-green-600">{statusData.active}</div>
|
||||
</div>
|
||||
|
||||
{/* 대기 */}
|
||||
<div className="rounded border-l-2 border-yellow-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-yellow-500"></div>
|
||||
<div className="text-xs font-medium text-gray-600">대기</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-yellow-600">{statusData.inactive}</div>
|
||||
</div>
|
||||
|
||||
{/* 정비 */}
|
||||
<div className="rounded border-l-2 border-orange-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-orange-500"></div>
|
||||
<div className="text-xs font-medium text-gray-600">정비</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-orange-600">{statusData.maintenance}</div>
|
||||
</div>
|
||||
|
||||
{/* 고장 */}
|
||||
<div className="rounded border-l-2 border-red-500 bg-white p-1.5 shadow-sm">
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-red-500"></div>
|
||||
<div className="text-xs font-medium text-gray-600">고장</div>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-red-600">{statusData.warning}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
RefreshCw,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
|
|
@ -34,11 +35,37 @@ export default function WeatherWidget({
|
|||
refreshInterval = 600000,
|
||||
}: WeatherWidgetProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [selectedCity, setSelectedCity] = useState(city);
|
||||
const [weather, setWeather] = useState<WeatherData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
// 표시할 날씨 정보 선택
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>([
|
||||
'temperature',
|
||||
'feelsLike',
|
||||
'humidity',
|
||||
'windSpeed',
|
||||
'pressure',
|
||||
]);
|
||||
|
||||
// 날씨 항목 정의
|
||||
const weatherItems = [
|
||||
{ id: 'temperature', label: '기온', icon: Sun },
|
||||
{ id: 'feelsLike', label: '체감온도', icon: Sun },
|
||||
{ id: 'humidity', label: '습도', icon: Droplets },
|
||||
{ id: 'windSpeed', label: '풍속', icon: Wind },
|
||||
{ id: 'pressure', label: '기압', icon: Gauge },
|
||||
];
|
||||
|
||||
// 항목 토글
|
||||
const toggleItem = (itemId: string) => {
|
||||
setSelectedItems((prev) =>
|
||||
prev.includes(itemId) ? prev.filter((id) => id !== itemId) : [...prev, itemId]
|
||||
);
|
||||
};
|
||||
|
||||
// 도시 목록 (전국 시/군/구 단위)
|
||||
const cities = [
|
||||
|
|
@ -278,9 +305,9 @@ export default function WeatherWidget({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-6">
|
||||
<div className="h-full bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -334,6 +361,46 @@ export default function WeatherWidget({
|
|||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-3" align="end">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">표시 항목</h4>
|
||||
{weatherItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => toggleItem(item.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors',
|
||||
selectedItems.includes(item.id)
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'h-3.5 w-3.5',
|
||||
selectedItems.includes(item.id) ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -345,59 +412,104 @@ export default function WeatherWidget({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 날씨 아이콘 및 온도 */}
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{getWeatherIcon(weather.weatherMain)}
|
||||
<div>
|
||||
<div className="text-5xl font-bold text-gray-900">
|
||||
{weather.temperature}°C
|
||||
{/* 반응형 그리드 레이아웃 - 자동 조정 */}
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))' }}>
|
||||
{/* 날씨 아이콘 및 온도 */}
|
||||
<div className="bg-white/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-shrink-0">
|
||||
{(() => {
|
||||
const iconClass = "h-5 w-5";
|
||||
switch (weather.weatherMain.toLowerCase()) {
|
||||
case 'clear':
|
||||
return <Sun className={`${iconClass} text-yellow-500`} />;
|
||||
case 'clouds':
|
||||
return <Cloud className={`${iconClass} text-gray-400`} />;
|
||||
case 'rain':
|
||||
case 'drizzle':
|
||||
return <CloudRain className={`${iconClass} text-blue-500`} />;
|
||||
case 'snow':
|
||||
return <CloudSnow className={`${iconClass} text-blue-300`} />;
|
||||
default:
|
||||
return <Cloud className={`${iconClass} text-gray-400`} />;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-bold text-gray-900 leading-tight truncate">
|
||||
{weather.temperature}°C
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 capitalize leading-tight truncate">
|
||||
{weather.weatherDescription}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 capitalize">
|
||||
{weather.weatherDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
|
||||
<Wind className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">체감 온도</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{weather.feelsLike}°C
|
||||
</p>
|
||||
{/* 기온 - 선택 가능 */}
|
||||
{selectedItems.includes('temperature') && (
|
||||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||||
<Sun className="h-3.5 w-3.5 text-orange-500 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-gray-400 leading-tight truncate">기온</p>
|
||||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||||
{weather.temperature}°C
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
|
||||
<Droplets className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">습도</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{weather.humidity}%
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 체감 온도 */}
|
||||
{selectedItems.includes('feelsLike') && (
|
||||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||||
<Wind className="h-3.5 w-3.5 text-blue-500 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-gray-400 leading-tight truncate">체감온도</p>
|
||||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||||
{weather.feelsLike}°C
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
|
||||
<Wind className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">풍속</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{weather.windSpeed} m/s
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 습도 */}
|
||||
{selectedItems.includes('humidity') && (
|
||||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||||
<Droplets className="h-3.5 w-3.5 text-blue-500 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-gray-400 leading-tight truncate">습도</p>
|
||||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||||
{weather.humidity}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
|
||||
<Gauge className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">기압</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{weather.pressure} hPa
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 풍속 */}
|
||||
{selectedItems.includes('windSpeed') && (
|
||||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||||
<Wind className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-gray-400 leading-tight truncate">풍속</p>
|
||||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||||
{weather.windSpeed} m/s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기압 */}
|
||||
{selectedItems.includes('pressure') && (
|
||||
<div className="flex items-center gap-1.5 bg-white/50 rounded-lg p-3">
|
||||
<Gauge className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-gray-400 leading-tight truncate">기압</p>
|
||||
<p className="text-sm font-semibold text-gray-900 leading-tight truncate">
|
||||
{weather.pressure} hPa
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import "./divider-line/DividerLineRenderer";
|
|||
import "./accordion-basic/AccordionBasicRenderer";
|
||||
import "./table-list/TableListRenderer";
|
||||
import "./card-display/CardDisplayRenderer";
|
||||
import "./map/MapRenderer";
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -0,0 +1,285 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { RefreshCw, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
if (typeof window !== "undefined") {
|
||||
const L = require("leaflet");
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
});
|
||||
}
|
||||
|
||||
// Leaflet 동적 import (SSR 방지)
|
||||
const MapContainer = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.MapContainer),
|
||||
{ ssr: false }
|
||||
);
|
||||
const TileLayer = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.TileLayer),
|
||||
{ ssr: false }
|
||||
);
|
||||
const Marker = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.Marker),
|
||||
{ ssr: false }
|
||||
);
|
||||
const Popup = dynamic(
|
||||
() => import("react-leaflet").then((mod) => mod.Popup),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
interface MapMarker {
|
||||
id: string | number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label?: string;
|
||||
status?: string;
|
||||
additionalInfo?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface MapComponentProps {
|
||||
component: {
|
||||
id: string;
|
||||
config?: {
|
||||
dataSource?: {
|
||||
type?: "internal" | "external";
|
||||
connectionId?: number | null;
|
||||
tableName?: string;
|
||||
latColumn?: string;
|
||||
lngColumn?: string;
|
||||
labelColumn?: string;
|
||||
statusColumn?: string;
|
||||
additionalColumns?: string[];
|
||||
whereClause?: string;
|
||||
};
|
||||
mapConfig?: {
|
||||
center?: { lat: number; lng: number };
|
||||
zoom?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
};
|
||||
markerConfig?: {
|
||||
showLabel?: boolean;
|
||||
showPopup?: boolean;
|
||||
statusColors?: Record<string, string>;
|
||||
};
|
||||
refreshInterval?: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function MapComponent({ component }: MapComponentProps) {
|
||||
const [markers, setMarkers] = useState<MapMarker[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
|
||||
const dataSource = component.config?.dataSource;
|
||||
const mapConfig = component.config?.mapConfig;
|
||||
const markerConfig = component.config?.markerConfig;
|
||||
const refreshInterval = component.config?.refreshInterval || 0;
|
||||
|
||||
// 데이터 로드
|
||||
const loadMapData = async () => {
|
||||
if (!dataSource?.tableName || !dataSource?.latColumn || !dataSource?.lngColumn) {
|
||||
setError("테이블명, 위도 컬럼, 경도 컬럼을 설정해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// API URL 구성
|
||||
const isExternal = dataSource.type === "external" && dataSource.connectionId;
|
||||
const baseUrl = isExternal
|
||||
? `/api/map-data/external/${dataSource.connectionId}`
|
||||
: `/api/map-data/internal`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
tableName: dataSource.tableName,
|
||||
latColumn: dataSource.latColumn,
|
||||
lngColumn: dataSource.lngColumn,
|
||||
});
|
||||
|
||||
if (dataSource.labelColumn) {
|
||||
params.append("labelColumn", dataSource.labelColumn);
|
||||
}
|
||||
if (dataSource.statusColumn) {
|
||||
params.append("statusColumn", dataSource.statusColumn);
|
||||
}
|
||||
if (dataSource.additionalColumns && dataSource.additionalColumns.length > 0) {
|
||||
params.append("additionalColumns", dataSource.additionalColumns.join(","));
|
||||
}
|
||||
if (dataSource.whereClause) {
|
||||
params.append("whereClause", dataSource.whereClause);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "데이터 조회 실패");
|
||||
}
|
||||
|
||||
setMarkers(result.data.markers || []);
|
||||
setLastUpdate(new Date());
|
||||
} catch (err: any) {
|
||||
console.error("지도 데이터 로드 오류:", err);
|
||||
setError(err.message || "데이터를 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로드 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
loadMapData();
|
||||
|
||||
if (refreshInterval > 0) {
|
||||
const interval = setInterval(loadMapData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [
|
||||
dataSource?.type,
|
||||
dataSource?.connectionId,
|
||||
dataSource?.tableName,
|
||||
dataSource?.latColumn,
|
||||
dataSource?.lngColumn,
|
||||
dataSource?.whereClause,
|
||||
refreshInterval,
|
||||
]);
|
||||
|
||||
// 마커 색상 가져오기
|
||||
const getMarkerColor = (status?: string): string => {
|
||||
if (!status || !markerConfig?.statusColors) {
|
||||
return markerConfig?.statusColors?.default || "#3b82f6";
|
||||
}
|
||||
return markerConfig.statusColors[status] || markerConfig.statusColors.default || "#3b82f6";
|
||||
};
|
||||
|
||||
// 커스텀 마커 아이콘 생성
|
||||
const createMarkerIcon = (status?: string) => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
const L = require("leaflet");
|
||||
const color = getMarkerColor(status);
|
||||
|
||||
return new L.Icon({
|
||||
iconUrl: `data:image/svg+xml;base64,${btoa(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="41" viewBox="0 0 25 41">
|
||||
<path d="M12.5 0C5.6 0 0 5.6 0 12.5c0 8.4 12.5 28.5 12.5 28.5S25 20.9 25 12.5C25 5.6 19.4 0 12.5 0z" fill="${color}"/>
|
||||
<circle cx="12.5" cy="12.5" r="6" fill="white"/>
|
||||
</svg>
|
||||
`)}`,
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [0, -41],
|
||||
});
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="mx-auto h-12 w-12 text-red-500" />
|
||||
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||
<Button onClick={loadMapData} className="mt-4" size="sm">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* 지도 */}
|
||||
{typeof window !== "undefined" && (
|
||||
<MapContainer
|
||||
center={[
|
||||
mapConfig?.center?.lat || 36.5,
|
||||
mapConfig?.center?.lng || 127.5,
|
||||
]}
|
||||
zoom={mapConfig?.zoom || 7}
|
||||
minZoom={mapConfig?.minZoom || 5}
|
||||
maxZoom={mapConfig?.maxZoom || 18}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
/>
|
||||
|
||||
{/* 마커 렌더링 */}
|
||||
{markers.map((marker) => (
|
||||
<Marker
|
||||
key={marker.id}
|
||||
position={[marker.latitude, marker.longitude]}
|
||||
icon={createMarkerIcon(marker.status)}
|
||||
>
|
||||
{markerConfig?.showPopup !== false && (
|
||||
<Popup>
|
||||
<div className="text-sm">
|
||||
{marker.label && (
|
||||
<div className="mb-2 font-bold text-base">{marker.label}</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<strong>위도:</strong> {marker.latitude.toFixed(6)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>경도:</strong> {marker.longitude.toFixed(6)}
|
||||
</div>
|
||||
{marker.status && (
|
||||
<div>
|
||||
<strong>상태:</strong> {marker.status}
|
||||
</div>
|
||||
)}
|
||||
{marker.additionalInfo &&
|
||||
Object.entries(marker.additionalInfo).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
)}
|
||||
|
||||
{/* 상단 정보 바 */}
|
||||
<div className="absolute top-2 right-2 z-[1000] flex items-center gap-2 rounded-lg bg-white/90 backdrop-blur-sm px-3 py-2 shadow-lg">
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
마커: {markers.length}개
|
||||
</span>
|
||||
{lastUpdate && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{lastUpdate.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={loadMapData}
|
||||
disabled={isLoading}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue