490 lines
18 KiB
TypeScript
490 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useCallback } from 'react';
|
|
import { ChartDataSource, QueryResult } from './types';
|
|
|
|
interface QueryEditorProps {
|
|
dataSource?: ChartDataSource;
|
|
onDataSourceChange: (dataSource: ChartDataSource) => void;
|
|
onQueryTest?: (result: QueryResult) => void;
|
|
}
|
|
|
|
/**
|
|
* SQL 쿼리 에디터 컴포넌트
|
|
* - SQL 쿼리 작성 및 편집
|
|
* - 쿼리 실행 및 결과 미리보기
|
|
* - 데이터 소스 설정
|
|
*/
|
|
export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: QueryEditorProps) {
|
|
const [query, setQuery] = useState(dataSource?.query || '');
|
|
const [isExecuting, setIsExecuting] = useState(false);
|
|
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// 쿼리 실행
|
|
const executeQuery = useCallback(async () => {
|
|
if (!query.trim()) {
|
|
setError('쿼리를 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
setIsExecuting(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// 실제 API 호출
|
|
const response = await fetch('http://localhost:8080/api/dashboards/execute-query', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('token') || 'test-token'}` // JWT 토큰 사용
|
|
},
|
|
body: JSON.stringify({ query: query.trim() })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.message || '쿼리 실행에 실패했습니다.');
|
|
}
|
|
|
|
const apiResult = await response.json();
|
|
|
|
if (!apiResult.success) {
|
|
throw new Error(apiResult.message || '쿼리 실행에 실패했습니다.');
|
|
}
|
|
|
|
// API 결과를 QueryResult 형식으로 변환
|
|
const result: QueryResult = {
|
|
columns: apiResult.data.columns,
|
|
rows: apiResult.data.rows,
|
|
totalRows: apiResult.data.rowCount,
|
|
executionTime: 0 // API에서 실행 시간을 제공하지 않으므로 0으로 설정
|
|
};
|
|
|
|
setQueryResult(result);
|
|
onQueryTest?.(result);
|
|
|
|
// 데이터 소스 업데이트
|
|
onDataSourceChange({
|
|
type: 'database',
|
|
query: query.trim(),
|
|
refreshInterval: dataSource?.refreshInterval || 30000,
|
|
lastExecuted: new Date().toISOString()
|
|
});
|
|
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : '쿼리 실행 중 오류가 발생했습니다.';
|
|
setError(errorMessage);
|
|
// console.error('Query execution error:', err);
|
|
} finally {
|
|
setIsExecuting(false);
|
|
}
|
|
}, [query, dataSource?.refreshInterval, onDataSourceChange, onQueryTest]);
|
|
|
|
// 샘플 쿼리 삽입
|
|
const insertSampleQuery = useCallback((sampleType: string) => {
|
|
const samples = {
|
|
comparison: `-- 제품별 월별 매출 비교 (다중 시리즈)
|
|
-- 갤럭시(Galaxy) vs 아이폰(iPhone) 매출 비교
|
|
SELECT
|
|
DATE_TRUNC('month', order_date) as month,
|
|
SUM(CASE WHEN product_category = '갤럭시' THEN amount ELSE 0 END) as galaxy_sales,
|
|
SUM(CASE WHEN product_category = '아이폰' THEN amount ELSE 0 END) as iphone_sales,
|
|
SUM(CASE WHEN product_category = '기타' THEN amount ELSE 0 END) as other_sales
|
|
FROM orders
|
|
WHERE order_date >= CURRENT_DATE - INTERVAL '12 months'
|
|
GROUP BY DATE_TRUNC('month', order_date)
|
|
ORDER BY month;`,
|
|
|
|
sales: `-- 월별 매출 데이터
|
|
SELECT
|
|
DATE_TRUNC('month', order_date) as month,
|
|
SUM(total_amount) as sales,
|
|
COUNT(*) as order_count
|
|
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,
|
|
COUNT(*) as new_users
|
|
FROM users
|
|
WHERE created_at >= CURRENT_DATE - INTERVAL '3 months'
|
|
GROUP BY DATE_TRUNC('week', created_at)
|
|
ORDER BY week;`,
|
|
|
|
products: `-- 상품별 판매량
|
|
SELECT
|
|
product_name,
|
|
SUM(quantity) as total_sold,
|
|
SUM(quantity * price) as revenue
|
|
FROM order_items oi
|
|
JOIN products p ON oi.product_id = p.id
|
|
WHERE oi.created_at >= CURRENT_DATE - INTERVAL '1 month'
|
|
GROUP BY product_name
|
|
ORDER BY total_sold DESC
|
|
LIMIT 10;`,
|
|
|
|
regional: `-- 지역별 매출 비교
|
|
SELECT
|
|
region as 지역,
|
|
SUM(CASE WHEN quarter = 'Q1' THEN sales ELSE 0 END) as Q1,
|
|
SUM(CASE WHEN quarter = 'Q2' THEN sales ELSE 0 END) as Q2,
|
|
SUM(CASE WHEN quarter = 'Q3' THEN sales ELSE 0 END) as Q3,
|
|
SUM(CASE WHEN quarter = 'Q4' THEN sales ELSE 0 END) as Q4
|
|
FROM regional_sales
|
|
WHERE year = EXTRACT(YEAR FROM CURRENT_DATE)
|
|
GROUP BY region
|
|
ORDER BY Q4 DESC;`
|
|
};
|
|
|
|
setQuery(samples[sampleType as keyof typeof samples] || '');
|
|
}, []);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 쿼리 에디터 헤더 */}
|
|
<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>
|
|
</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>
|
|
|
|
{/* 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>
|
|
</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"
|
|
>
|
|
<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>
|
|
</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>
|
|
)}
|
|
|
|
{/* 쿼리 결과 미리보기 */}
|
|
{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>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-3 max-h-60 overflow-auto">
|
|
{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>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
<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>
|
|
</div>
|
|
)}
|
|
|
|
{/* 키보드 단축키 안내 */}
|
|
<div className="text-xs text-gray-500 bg-gray-50 p-2 rounded">
|
|
💡 <strong>단축키:</strong> Ctrl+Enter (쿼리 실행), Ctrl+/ (주석 토글)
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Ctrl+Enter로 쿼리 실행
|
|
React.useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.ctrlKey && e.key === 'Enter') {
|
|
e.preventDefault();
|
|
executeQuery();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [executeQuery]);
|
|
}
|
|
|
|
/**
|
|
* 샘플 쿼리 결과 생성 함수
|
|
*/
|
|
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');
|
|
|
|
// console.log('Sample data type detection:', {
|
|
// isComparison,
|
|
// isRegional,
|
|
// isWeekly,
|
|
// isProducts,
|
|
// isMonthly,
|
|
// isSales,
|
|
// isUsers,
|
|
// querySnippet: query.substring(0, 200)
|
|
// });
|
|
|
|
let columns: string[];
|
|
let rows: Record<string, any>[];
|
|
|
|
// 더 구체적인 조건부터 먼저 체크 (순서 중요!)
|
|
if (isComparison) {
|
|
// console.log('✅ Using COMPARISON data');
|
|
// 제품 비교 데이터 (다중 시리즈)
|
|
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 },
|
|
];
|
|
// COMPARISON 데이터를 반환하고 함수 종료
|
|
// console.log('COMPARISON data generated:', {
|
|
// columns,
|
|
// rowCount: rows.length,
|
|
// sampleRow: rows[0],
|
|
// allRows: rows,
|
|
// fieldTypes: {
|
|
// month: typeof rows[0].month,
|
|
// galaxy_sales: typeof rows[0].galaxy_sales,
|
|
// iphone_sales: typeof rows[0].iphone_sales,
|
|
// other_sales: typeof rows[0].other_sales
|
|
// },
|
|
// firstFewRows: rows.slice(0, 3),
|
|
// lastFewRows: rows.slice(-3)
|
|
// });
|
|
return {
|
|
columns,
|
|
rows,
|
|
totalRows: rows.length,
|
|
executionTime: Math.floor(Math.random() * 200) + 100,
|
|
};
|
|
} else if (isRegional) {
|
|
// console.log('✅ Using REGIONAL data');
|
|
// 지역별 분기별 매출
|
|
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 },
|
|
];
|
|
} else if (isWeekly && isUsers) {
|
|
// console.log('✅ Using USERS data');
|
|
// 사용자 가입 추이
|
|
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 },
|
|
];
|
|
} else if (isProducts && !isComparison) {
|
|
// console.log('✅ Using PRODUCTS data');
|
|
// 상품별 판매량
|
|
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 },
|
|
];
|
|
} else if (isMonthly && isSales && !isComparison) {
|
|
// console.log('✅ Using MONTHLY SALES data');
|
|
// 월별 매출 데이터
|
|
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 },
|
|
];
|
|
} else {
|
|
// console.log('⚠️ Using DEFAULT data');
|
|
// 기본 샘플 데이터
|
|
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 },
|
|
];
|
|
}
|
|
|
|
return {
|
|
columns,
|
|
rows,
|
|
totalRows: rows.length,
|
|
executionTime: Math.floor(Math.random() * 200) + 100, // 100-300ms
|
|
};
|
|
}
|