diff --git a/frontend/components/admin/dashboard/CHART_SYSTEM_PLAN.md b/frontend/components/admin/dashboard/CHART_SYSTEM_PLAN.md index b732dad2..8483f02b 100644 --- a/frontend/components/admin/dashboard/CHART_SYSTEM_PLAN.md +++ b/frontend/components/admin/dashboard/CHART_SYSTEM_PLAN.md @@ -203,46 +203,46 @@ export interface ChartDataset { #### Step 1.1: 데이터 소스 선택기 -- [ ] `DataSourceSelector.tsx` 생성 -- [ ] DB vs API 선택 라디오 버튼 -- [ ] 선택에 따라 하위 UI 동적 렌더링 -- [ ] 상태 관리 (현재 선택된 소스 타입) +- [x] `DataSourceSelector.tsx` 생성 +- [x] DB vs API 선택 라디오 버튼 +- [x] 선택에 따라 하위 UI 동적 렌더링 +- [x] 상태 관리 (현재 선택된 소스 타입) #### Step 1.2: 데이터베이스 설정 -- [ ] `DatabaseConfig.tsx` 생성 -- [ ] 현재 DB / 외부 DB 선택 라디오 버튼 -- [ ] 외부 DB 선택 시: +- [x] `DatabaseConfig.tsx` 생성 +- [x] 현재 DB / 외부 DB 선택 라디오 버튼 +- [x] 외부 DB 선택 시: - **기존 외부 커넥션 관리에서 등록된 커넥션 목록 불러오기** - 드롭다운으로 커넥션 선택 (ID, 이름, 타입 표시) - "외부 커넥션 관리로 이동" 링크 제공 - 선택된 커넥션 정보 표시 (읽기 전용) -- [ ] SQL 에디터 통합 (기존 `QueryEditor` 재사용) -- [ ] 쿼리 테스트 버튼 (선택된 커넥션으로 실행) +- [x] SQL 에디터 통합 (기존 `QueryEditor` 재사용) +- [x] 쿼리 테스트 버튼 (선택된 커넥션으로 실행) #### Step 1.3: REST API 설정 -- [ ] `ApiConfig.tsx` 생성 -- [ ] API 엔드포인트 URL 입력 -- [ ] HTTP 메서드: GET 고정 (UI에서 표시만) -- [ ] URL 쿼리 파라미터 추가 UI (키-값 쌍) +- [x] `ApiConfig.tsx` 생성 +- [x] API 엔드포인트 URL 입력 +- [x] HTTP 메서드: GET 고정 (UI에서 표시만) +- [x] URL 쿼리 파라미터 추가 UI (키-값 쌍) - 동적 파라미터 추가/제거 버튼 - 예시: `?category=electronics&limit=10` -- [ ] 헤더 추가 UI (키-값 쌍) +- [x] 헤더 추가 UI (키-값 쌍) - Authorization 헤더 빠른 입력 - 일반적인 헤더 템플릿 제공 -- [ ] JSON Path 설정 (데이터 추출 경로) +- [x] JSON Path 설정 (데이터 추출 경로) - 예시: `data.results`, `items`, `response.data` -- [ ] 테스트 요청 버튼 -- [ ] 응답 미리보기 (JSON 구조 표시) +- [x] 테스트 요청 버튼 +- [x] 응답 미리보기 (JSON 구조 표시) #### Step 1.4: 데이터 소스 유틸리티 -- [ ] `dataSourceUtils.ts` 생성 -- [ ] DB 커넥션 검증 함수 -- [ ] API 요청 실행 함수 -- [ ] JSON Path 파싱 함수 -- [ ] 데이터 정규화 함수 (DB/API 결과를 통일된 형식으로) +- [x] `dataSourceUtils.ts` 생성 +- [x] DB 커넥션 검증 함수 +- [x] API 요청 실행 함수 +- [x] JSON Path 파싱 함수 +- [x] 데이터 정규화 함수 (DB/API 결과를 통일된 형식으로) ### Phase 2: 서버 측 API 구현 (2-3시간) @@ -649,7 +649,7 @@ LIMIT 10; **구현 시작일**: 2025-10-14 **목표 완료일**: 2025-10-20 -**현재 진행률**: 0% (계획 수립 완료) +**현재 진행률**: 22% (Phase 1 완료 + shadcn/ui 통합 ✅) --- diff --git a/frontend/components/admin/dashboard/ChartConfigPanel.tsx b/frontend/components/admin/dashboard/ChartConfigPanel.tsx index d67cfefb..67e69da8 100644 --- a/frontend/components/admin/dashboard/ChartConfigPanel.tsx +++ b/frontend/components/admin/dashboard/ChartConfigPanel.tsx @@ -1,7 +1,16 @@ -'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 { Settings, TrendingUp, AlertCircle } from "lucide-react"; interface ChartConfigPanelProps { config?: ChartConfig; @@ -19,27 +28,32 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC const [currentConfig, setCurrentConfig] = useState(config || {}); // 설정 업데이트 - const updateConfig = useCallback((updates: Partial) => { - const newConfig = { ...currentConfig, ...updates }; - setCurrentConfig(newConfig); - onConfigChange(newConfig); - }, [currentConfig, onConfigChange]); + const updateConfig = useCallback( + (updates: Partial) => { + const newConfig = { ...currentConfig, ...updates }; + setCurrentConfig(newConfig); + onConfigChange(newConfig); + }, + [currentConfig, onConfigChange], + ); // 사용 가능한 컬럼 목록 const availableColumns = queryResult?.columns || []; const sampleData = queryResult?.rows?.[0] || {}; return ( -
-

⚙️ 차트 설정

+
+
+ +

차트 설정

+
{/* 쿼리 결과가 없을 때 */} {!queryResult && ( -
-
- 💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 차트를 설정할 수 있습니다. -
-
+ + + 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 차트를 설정할 수 있습니다. + )} {/* 데이터 필드 매핑 */} @@ -47,154 +61,157 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC <> {/* 차트 제목 */}
- - 차트 제목 + updateConfig({ title: e.target.value })} placeholder="차트 제목을 입력하세요" - className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
+ + {/* X축 설정 */}
- +
{/* Y축 설정 (다중 선택 가능) */}
- + +
+ {availableColumns.map((col) => { + const isSelected = Array.isArray(currentConfig.yAxis) + ? currentConfig.yAxis.includes(col) + : currentConfig.yAxis === col; + + return ( +
+ { + const currentYAxis = Array.isArray(currentConfig.yAxis) + ? currentConfig.yAxis + : currentConfig.yAxis + ? [currentConfig.yAxis] + : []; + + let newYAxis: string | string[]; + if (checked) { + newYAxis = [...currentYAxis, col]; + } else { + newYAxis = currentYAxis.filter((c) => c !== col); + } + + // 단일 값이면 문자열로, 다중 값이면 배열로 + if (newYAxis.length === 1) { + newYAxis = newYAxis[0]; + } + + updateConfig({ yAxis: newYAxis }); + }} + /> + +
+ ); + })} +
+
+

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

+ + {/* 집계 함수 */}
-
{/* 그룹핑 필드 (선택사항) */}
- - + +
+ + {/* 차트 색상 */}
- +
{[ - ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'], // 기본 - ['#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'], // 밝은 - ['#1F2937', '#374151', '#6B7280', '#9CA3AF'], // 회색 - ['#DC2626', '#EA580C', '#CA8A04', '#65A30D'], // 따뜻한 + ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"], // 기본 + ["#8B5CF6", "#EC4899", "#06B6D4", "#84CC16"], // 밝은 + ["#1F2937", "#374151", "#6B7280", "#9CA3AF"], // 회색 + ["#DC2626", "#EA580C", "#CA8A04", "#65A30D"], // 따뜻한 ].map((colorSet, setIdx) => ( +
- {/* 탭 네비게이션 */} -
- - + {/* 진행 상황 표시 */} +
+
+
+ 단계 {currentStep} / 3: {currentStep === 1 && "데이터 소스 선택"} + {currentStep === 2 && "데이터 설정"} + {currentStep === 3 && "차트 설정"} +
+ {Math.round((currentStep / 3) * 100)}% 완료 +
+
- {/* 탭 내용 */} + {/* 단계별 내용 */}
- {activeTab === "query" && ( - + {currentStep === 1 && ( + )} - {activeTab === "chart" && ( + {currentStep === 2 && ( +
+ {dataSource.type === "database" ? ( + <> + + + + ) : ( + + )} +
+ )} + + {currentStep === 3 && ( )}
{/* 모달 푸터 */} -
-
- {dataSource.query && ( - <> - 💾 쿼리: {dataSource.query.length > 50 ? `${dataSource.query.substring(0, 50)}...` : dataSource.query} - +
+
+ {queryResult && ( + + 📊 {queryResult.rows.length}개 데이터 로드됨 + )}
- + )} + - + + {currentStep < 3 ? ( + + ) : ( + + )}
diff --git a/frontend/components/admin/dashboard/QueryEditor.tsx b/frontend/components/admin/dashboard/QueryEditor.tsx index 5aa70a80..c826961d 100644 --- a/frontend/components/admin/dashboard/QueryEditor.tsx +++ b/frontend/components/admin/dashboard/QueryEditor.tsx @@ -1,7 +1,16 @@ -'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 { 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; @@ -16,7 +25,7 @@ interface QueryEditorProps { * - 데이터 소스 설정 */ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: QueryEditorProps) { - const [query, setQuery] = useState(dataSource?.query || ''); + const [query, setQuery] = useState(dataSource?.query || ""); const [isExecuting, setIsExecuting] = useState(false); const [queryResult, setQueryResult] = useState(null); const [error, setError] = useState(null); @@ -24,7 +33,7 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que // 쿼리 실행 const executeQuery = useCallback(async () => { if (!query.trim()) { - setError('쿼리를 입력해주세요.'); + setError("쿼리를 입력해주세요."); return; } @@ -33,24 +42,24 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que try { // 실제 API 호출 - const response = await fetch('http://localhost:8080/api/dashboards/execute-query', { - method: 'POST', + 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 토큰 사용 + "Content-Type": "application/json", + Authorization: `Bearer ${localStorage.getItem("token") || "test-token"}`, // JWT 토큰 사용 }, - body: JSON.stringify({ query: query.trim() }) + body: JSON.stringify({ query: query.trim() }), }); if (!response.ok) { const errorData = await response.json(); - throw new Error(errorData.message || '쿼리 실행에 실패했습니다.'); + throw new Error(errorData.message || "쿼리 실행에 실패했습니다."); } const apiResult = await response.json(); - + if (!apiResult.success) { - throw new Error(apiResult.message || '쿼리 실행에 실패했습니다.'); + throw new Error(apiResult.message || "쿼리 실행에 실패했습니다."); } // API 결과를 QueryResult 형식으로 변환 @@ -58,22 +67,21 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que columns: apiResult.data.columns, rows: apiResult.data.rows, totalRows: apiResult.data.rowCount, - executionTime: 0 // API에서 실행 시간을 제공하지 않으므로 0으로 설정 + executionTime: 0, // API에서 실행 시간을 제공하지 않으므로 0으로 설정 }; - + setQueryResult(result); onQueryTest?.(result); // 데이터 소스 업데이트 onDataSourceChange({ - type: 'database', + type: "database", query: query.trim(), refreshInterval: dataSource?.refreshInterval || 30000, - lastExecuted: new Date().toISOString() + 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 { @@ -105,7 +113,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 +122,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,192 +145,179 @@ SELECT FROM regional_sales WHERE year = EXTRACT(YEAR FROM CURRENT_DATE) GROUP BY region -ORDER BY Q4 DESC;` +ORDER BY Q4 DESC;`, }; - setQuery(samples[sampleType as keyof typeof samples] || ''); + setQuery(samples[sampleType as keyof typeof samples] || ""); }, []); return ( -
+
{/* 쿼리 에디터 헤더 */} -
-

📝 SQL 쿼리 에디터

-
- +
+
+ +

SQL 쿼리 에디터

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