차트 구현 및 리스트 구현 #98
|
|
@ -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 통합 ✅)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ChartConfig>(config || {});
|
||||
|
||||
// 설정 업데이트
|
||||
const updateConfig = useCallback((updates: Partial<ChartConfig>) => {
|
||||
const newConfig = { ...currentConfig, ...updates };
|
||||
setCurrentConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}, [currentConfig, onConfigChange]);
|
||||
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>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-blue-600" />
|
||||
<h4 className="text-lg font-semibold text-gray-800">차트 설정</h4>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 결과가 없을 때 */}
|
||||
{!queryResult && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-yellow-800 text-sm">
|
||||
💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 차트를 설정할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
<Alert>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<AlertDescription>먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 차트를 설정할 수 있습니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 데이터 필드 매핑 */}
|
||||
|
|
@ -47,154 +61,157 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
<>
|
||||
{/* 차트 제목 */}
|
||||
<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 || ""} onValueChange={(value) => updateConfig({ xAxis: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col} value={col}>
|
||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</Label>
|
||||
<Card className="max-h-60 overflow-y-auto p-3">
|
||||
<div className="space-y-2">
|
||||
{availableColumns.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] && <span className="ml-2 text-xs text-gray-500">(예: {sampleData[col]})</span>}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
<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 || "sum"}
|
||||
onValueChange={(value) => updateConfig({ aggregation: value as any })}
|
||||
>
|
||||
<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="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">
|
||||
집계 함수는 현재 쿼리 결과에 적용되지 않습니다. SQL 쿼리에서 직접 집계하는 것을 권장합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 그룹핑 필드 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
그룹핑 필드 (선택사항)
|
||||
</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"
|
||||
>
|
||||
<option value="">없음</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Label>그룹핑 필드 (선택사항)</Label>
|
||||
<Select value={currentConfig.groupBy || ""} onValueChange={(value) => updateConfig({ groupBy: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">없음</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 +227,63 @@ 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}개 (${currentConfig.yAxis.join(", ")})`
|
||||
: currentConfig.yAxis || "미설정"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">집계:</span>
|
||||
<span>{currentConfig.aggregation || "sum"}</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>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>X축과 Y축을 모두 설정해야 차트가 표시됩니다.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
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 { validateDataSource } from "./data-sources/dataSourceUtils";
|
||||
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,22 +21,48 @@ interface ElementConfigModalProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* 요소 설정 모달 컴포넌트
|
||||
* - 차트/위젯 데이터 소스 설정
|
||||
* - 쿼리 에디터 통합
|
||||
* - 차트 설정 패널 통합
|
||||
* 요소 설정 모달 컴포넌트 (리팩토링)
|
||||
* - 3단계 플로우: 데이터 소스 선택 → 데이터 설정 → 차트 설정
|
||||
* - 새로운 데이터 소스 컴포넌트 통합
|
||||
*/
|
||||
export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) {
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", refreshInterval: 30000 },
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 30 },
|
||||
);
|
||||
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 | 3>(1);
|
||||
|
||||
// 데이터 소스 변경 처리
|
||||
const handleDataSourceChange = useCallback((newDataSource: ChartDataSource) => {
|
||||
setDataSource(newDataSource);
|
||||
// 모달이 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 30 });
|
||||
setChartConfig(element.chartConfig || {});
|
||||
setQueryResult(null);
|
||||
setCurrentStep(1);
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
// 데이터 소스 타입 변경
|
||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||
if (type === "database") {
|
||||
setDataSource({
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
refreshInterval: 30,
|
||||
});
|
||||
} else {
|
||||
setDataSource({
|
||||
type: "api",
|
||||
method: "GET",
|
||||
refreshInterval: 30,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 차트 설정 변경 처리
|
||||
|
|
@ -39,12 +73,48 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
// 쿼리 테스트 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
// 쿼리 결과가 나오면 자동으로 차트 설정 탭으로 이동
|
||||
// 쿼리 결과가 나오면 자동으로 3단계로 이동
|
||||
if (result.rows.length > 0) {
|
||||
setActiveTab("chart");
|
||||
setCurrentStep(3);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 다음 단계로 이동 (검증 포함)
|
||||
const handleNext = useCallback(() => {
|
||||
if (currentStep === 1) {
|
||||
// 1단계: 데이터 소스 타입 선택 완료
|
||||
setCurrentStep(2);
|
||||
} else if (currentStep === 2) {
|
||||
// 2단계: 데이터 설정 완료 - 검증
|
||||
const validation = validateDataSource(
|
||||
dataSource.type,
|
||||
dataSource.connectionType,
|
||||
dataSource.externalConnectionId,
|
||||
dataSource.query,
|
||||
dataSource.endpoint,
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
alert(validation.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 쿼리 결과가 있으면 3단계로 이동
|
||||
if (queryResult && queryResult.rows.length > 0) {
|
||||
setCurrentStep(3);
|
||||
} else {
|
||||
alert("먼저 데이터를 테스트하여 결과를 확인하세요");
|
||||
}
|
||||
}
|
||||
}, [currentStep, dataSource, queryResult]);
|
||||
|
||||
// 이전 단계로 이동
|
||||
const handlePrev = useCallback(() => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep((prev) => (prev - 1) as 1 | 2 | 3);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = useCallback(() => {
|
||||
const updatedElement: DashboardElement = {
|
||||
|
|
@ -67,88 +137,99 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
return null;
|
||||
}
|
||||
|
||||
// 저장 가능 여부 확인
|
||||
const canSave =
|
||||
queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(chartConfig.yAxis || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-lg bg-white shadow-xl">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="flex h-[85vh] w-full max-w-5xl flex-col rounded-xl border bg-white shadow-2xl">
|
||||
{/* 모달 헤더 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-6">
|
||||
<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-muted-foreground mt-1 text-sm">데이터 소스와 차트 설정을 구성하세요</p>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{element.title} 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">3단계 플로우로 차트를 설정하세요</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="hover:text-muted-foreground text-2xl text-gray-400">
|
||||
×
|
||||
</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={`border-b-2 px-6 py-3 text-sm font-medium 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={`border-b-2 px-6 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === "chart"
|
||||
? "border-primary text-primary bg-accent"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700"
|
||||
} `}
|
||||
>
|
||||
📊 차트 설정
|
||||
{queryResult && (
|
||||
<span className="ml-2 rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
|
||||
{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} / 3: {currentStep === 1 && "데이터 소스 선택"}
|
||||
{currentStep === 2 && "데이터 설정"}
|
||||
{currentStep === 3 && "차트 설정"}
|
||||
</div>
|
||||
<Badge variant="secondary">{Math.round((currentStep / 3) * 100)}% 완료</Badge>
|
||||
</div>
|
||||
<Progress value={(currentStep / 3) * 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" && (
|
||||
{currentStep === 2 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<ChartConfigPanel config={chartConfig} queryResult={queryResult} onConfigChange={handleChartConfigChange} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="flex items-center justify-between border-t border-gray-200 p-6">
|
||||
<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="text-muted-foreground rounded-lg border border-gray-300 px-4 py-2 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="bg-accent0 rounded-lg px-4 py-2 text-white hover:bg-blue-600 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</Button>
|
||||
{currentStep < 3 ? (
|
||||
<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,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<QueryResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<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 className="absolute bottom-3 right-3 text-xs text-gray-400">Ctrl+Enter로 실행</div>
|
||||
</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 || 30000)}
|
||||
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') {
|
||||
if (e.ctrlKey && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
executeQuery();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [executeQuery]);
|
||||
}
|
||||
|
||||
|
|
@ -332,18 +327,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 +362,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 +401,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,318 @@
|
|||
"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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const url = `http://localhost:8080/api/dashboards/fetch-api?${params.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("token") || "test-token"}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
endpoint: dataSource.endpoint,
|
||||
headers: dataSource.headers || {},
|
||||
jsonPath: dataSource.jsonPath || "",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result: ApiResponse<QueryResult> = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "API 호출에 실패했습니다");
|
||||
}
|
||||
|
||||
setTestResult(result.data);
|
||||
onTestResult?.(result.data);
|
||||
} 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,205 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChartDataSource, ExternalConnection, ApiResponse } 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 { 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<ExternalConnection[]>([]);
|
||||
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 response = await fetch("http://localhost:8080/api/external-connections", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token") || "test-token"}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("외부 커넥션 목록을 불러오는데 실패했습니다");
|
||||
}
|
||||
|
||||
const result: ApiResponse<ExternalConnection[]> = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || "외부 커넥션 목록을 불러오는데 실패했습니다");
|
||||
}
|
||||
|
||||
setConnections(result.data || []);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 현재 선택된 커넥션 찾기
|
||||
const selectedConnection = connections.find((conn) => 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 || ""}
|
||||
onValueChange={(value) => {
|
||||
onChange({ externalConnectionId: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{conn.name}</span>
|
||||
<span className="text-xs text-gray-500">({conn.type})</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.name}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">타입:</span> {selectedConnection.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";
|
||||
}
|
||||
|
|
@ -56,22 +56,63 @@ export interface ResizeHandle {
|
|||
}
|
||||
|
||||
export interface ChartDataSource {
|
||||
type: "api" | "database" | "static";
|
||||
endpoint?: string; // API 엔드포인트
|
||||
query?: string; // SQL 쿼리
|
||||
refreshInterval?: number; // 자동 새로고침 간격 (ms)
|
||||
filters?: any[]; // 필터 조건
|
||||
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축 데이터 필드 (단일 또는 다중)
|
||||
// 축 매핑
|
||||
xAxis?: string; // X축 필드명
|
||||
yAxis?: string | string[]; // Y축 필드명 (다중 가능)
|
||||
|
||||
// 데이터 처리
|
||||
groupBy?: string; // 그룹핑 필드
|
||||
aggregation?: "sum" | "avg" | "count" | "max" | "min";
|
||||
colors?: string[]; // 차트 색상
|
||||
sortBy?: string; // 정렬 기준 필드
|
||||
sortOrder?: "asc" | "desc"; // 정렬 순서
|
||||
limit?: number; // 데이터 개수 제한
|
||||
|
||||
// 스타일
|
||||
colors?: string[]; // 차트 색상 팔레트
|
||||
title?: string; // 차트 제목
|
||||
showLegend?: boolean; // 범례 표시 여부
|
||||
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"; // 누적 모드
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
|
|
@ -131,3 +172,30 @@ export interface DriverInfo {
|
|||
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; // 색상
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue