Merge branch 'feature/dashboard-management' into main

대시보드 관리 시스템 통합
- 충돌 해결 완료
- 백엔드/프론트엔드 대시보드 기능 추가
This commit is contained in:
2025-10-01 12:10:42 +09:00
33 changed files with 5495 additions and 250 deletions

View File

@@ -0,0 +1,398 @@
'use client';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { DashboardElement, QueryResult } from './types';
import { ChartRenderer } from './charts/ChartRenderer';
interface CanvasElementProps {
element: DashboardElement;
isSelected: boolean;
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
onRemove: (id: string) => void;
onSelect: (id: string | null) => void;
onConfigure?: (element: DashboardElement) => void;
}
/**
* 캔버스에 배치된 개별 요소 컴포넌트
* - 드래그로 이동 가능
* - 크기 조절 핸들
* - 삭제 버튼
*/
export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelect, onConfigure }: CanvasElementProps) {
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
const [resizeStart, setResizeStart] = useState({
x: 0, y: 0, width: 0, height: 0, elementX: 0, elementY: 0, handle: ''
});
const [chartData, setChartData] = useState<QueryResult | null>(null);
const [isLoadingData, setIsLoadingData] = useState(false);
const elementRef = useRef<HTMLDivElement>(null);
// 요소 선택 처리
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
if ((e.target as HTMLElement).closest('.element-close, .resize-handle')) {
return;
}
onSelect(element.id);
setIsDragging(true);
setDragStart({
x: e.clientX,
y: e.clientY,
elementX: element.position.x,
elementY: element.position.y
});
e.preventDefault();
}, [element.id, element.position.x, element.position.y, onSelect]);
// 리사이즈 핸들 마우스다운
const handleResizeMouseDown = useCallback((e: React.MouseEvent, handle: string) => {
e.stopPropagation();
setIsResizing(true);
setResizeStart({
x: e.clientX,
y: e.clientY,
width: element.size.width,
height: element.size.height,
elementX: element.position.x,
elementY: element.position.y,
handle
});
}, [element.size.width, element.size.height, element.position.x, element.position.y]);
// 마우스 이동 처리
const handleMouseMove = useCallback((e: MouseEvent) => {
if (isDragging) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
onUpdate(element.id, {
position: {
x: Math.max(0, dragStart.elementX + deltaX),
y: Math.max(0, dragStart.elementY + deltaY)
}
});
} else if (isResizing) {
const deltaX = e.clientX - resizeStart.x;
const deltaY = e.clientY - resizeStart.y;
let newWidth = resizeStart.width;
let newHeight = resizeStart.height;
let newX = resizeStart.elementX;
let newY = resizeStart.elementY;
switch (resizeStart.handle) {
case 'se': // 오른쪽 아래
newWidth = Math.max(150, resizeStart.width + deltaX);
newHeight = Math.max(150, resizeStart.height + deltaY);
break;
case 'sw': // 왼쪽 아래
newWidth = Math.max(150, resizeStart.width - deltaX);
newHeight = Math.max(150, resizeStart.height + deltaY);
newX = resizeStart.elementX + deltaX;
break;
case 'ne': // 오른쪽 위
newWidth = Math.max(150, resizeStart.width + deltaX);
newHeight = Math.max(150, resizeStart.height - deltaY);
newY = resizeStart.elementY + deltaY;
break;
case 'nw': // 왼쪽 위
newWidth = Math.max(150, resizeStart.width - deltaX);
newHeight = Math.max(150, resizeStart.height - deltaY);
newX = resizeStart.elementX + deltaX;
newY = resizeStart.elementY + deltaY;
break;
}
onUpdate(element.id, {
position: { x: Math.max(0, newX), y: Math.max(0, newY) },
size: { width: newWidth, height: newHeight }
});
}
}, [isDragging, isResizing, dragStart, resizeStart, element.id, onUpdate]);
// 마우스 업 처리
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setIsResizing(false);
}, []);
// 전역 마우스 이벤트 등록
React.useEffect(() => {
if (isDragging || isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
// 데이터 로딩
const loadChartData = useCallback(async () => {
if (!element.dataSource?.query || element.type !== 'chart') {
return;
}
setIsLoadingData(true);
try {
// console.log('🔄 쿼리 실행 시작:', element.dataSource.query);
// 실제 API 호출
const { dashboardApi } = await import('@/lib/api/dashboard');
const result = await dashboardApi.executeQuery(element.dataSource.query);
// console.log('✅ 쿼리 실행 결과:', result);
setChartData({
columns: result.columns || [],
rows: result.rows || [],
totalRows: result.rowCount || 0,
executionTime: 0
});
} catch (error) {
// console.error('❌ 데이터 로딩 오류:', error);
setChartData(null);
} finally {
setIsLoadingData(false);
}
}, [element.dataSource?.query, element.type, element.subtype]);
// 컴포넌트 마운트 시 및 쿼리 변경 시 데이터 로딩
useEffect(() => {
loadChartData();
}, [loadChartData]);
// 자동 새로고침 설정
useEffect(() => {
if (!element.dataSource?.refreshInterval || element.dataSource.refreshInterval === 0) {
return;
}
const interval = setInterval(loadChartData, element.dataSource.refreshInterval);
return () => clearInterval(interval);
}, [element.dataSource?.refreshInterval, loadChartData]);
// 요소 삭제
const handleRemove = useCallback(() => {
onRemove(element.id);
}, [element.id, onRemove]);
// 스타일 클래스 생성
const getContentClass = () => {
if (element.type === 'chart') {
switch (element.subtype) {
case 'bar': return 'bg-gradient-to-br from-indigo-400 to-purple-600';
case 'pie': return 'bg-gradient-to-br from-pink-400 to-red-500';
case 'line': return 'bg-gradient-to-br from-blue-400 to-cyan-400';
default: return 'bg-gray-200';
}
} else if (element.type === 'widget') {
switch (element.subtype) {
case 'exchange': return 'bg-gradient-to-br from-pink-400 to-yellow-400';
case 'weather': return 'bg-gradient-to-br from-cyan-400 to-indigo-800';
default: return 'bg-gray-200';
}
}
return 'bg-gray-200';
};
return (
<div
ref={elementRef}
className={`
absolute bg-white border-2 rounded-lg shadow-lg
min-w-[150px] min-h-[150px] cursor-move
${isSelected ? 'border-green-500 shadow-green-200' : 'border-gray-600'}
`}
style={{
left: element.position.x,
top: element.position.y,
width: element.size.width,
height: element.size.height
}}
onMouseDown={handleMouseDown}
>
{/* 헤더 */}
<div className="bg-gray-50 p-3 border-b border-gray-200 flex justify-between items-center cursor-move">
<span className="font-bold text-sm text-gray-800">{element.title}</span>
<div className="flex gap-1">
{/* 설정 버튼 */}
{onConfigure && (
<button
className="
w-6 h-6 flex items-center justify-center
text-gray-400 hover:bg-blue-500 hover:text-white
rounded transition-colors duration-200
"
onClick={() => onConfigure(element)}
title="설정"
>
</button>
)}
{/* 삭제 버튼 */}
<button
className="
element-close w-6 h-6 flex items-center justify-center
text-gray-400 hover:bg-red-500 hover:text-white
rounded transition-colors duration-200
"
onClick={handleRemove}
title="삭제"
>
×
</button>
</div>
</div>
{/* 내용 */}
<div className="h-[calc(100%-45px)] relative">
{element.type === 'chart' ? (
// 차트 렌더링
<div className="w-full h-full bg-white">
{isLoadingData ? (
<div className="w-full h-full flex items-center justify-center text-gray-500">
<div className="text-center">
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<div className="text-sm"> ...</div>
</div>
</div>
) : (
<ChartRenderer
element={element}
data={chartData}
width={element.size.width}
height={element.size.height - 45}
/>
)}
</div>
) : (
// 위젯 렌더링 (기존 방식)
<div className={`
w-full h-full p-5 flex items-center justify-center
text-sm text-white font-medium text-center
${getContentClass()}
`}>
<div>
<div className="text-4xl mb-2">
{element.type === 'widget' && element.subtype === 'exchange' && '💱'}
{element.type === 'widget' && element.subtype === 'weather' && '☁️'}
</div>
<div className="whitespace-pre-line">{element.content}</div>
</div>
</div>
)}
</div>
{/* 리사이즈 핸들 (선택된 요소에만 표시) */}
{isSelected && (
<>
<ResizeHandle position="nw" onMouseDown={handleResizeMouseDown} />
<ResizeHandle position="ne" onMouseDown={handleResizeMouseDown} />
<ResizeHandle position="sw" onMouseDown={handleResizeMouseDown} />
<ResizeHandle position="se" onMouseDown={handleResizeMouseDown} />
</>
)}
</div>
);
}
interface ResizeHandleProps {
position: 'nw' | 'ne' | 'sw' | 'se';
onMouseDown: (e: React.MouseEvent, handle: string) => void;
}
/**
* 크기 조절 핸들 컴포넌트
*/
function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
const getPositionClass = () => {
switch (position) {
case 'nw': return 'top-[-5px] left-[-5px] cursor-nw-resize';
case 'ne': return 'top-[-5px] right-[-5px] cursor-ne-resize';
case 'sw': return 'bottom-[-5px] left-[-5px] cursor-sw-resize';
case 'se': return 'bottom-[-5px] right-[-5px] cursor-se-resize';
}
};
return (
<div
className={`
resize-handle absolute w-3 h-3 bg-green-500 border border-white
${getPositionClass()}
`}
onMouseDown={(e) => onMouseDown(e, position)}
/>
);
}
/**
* 샘플 데이터 생성 함수 (실제 API 호출 대신 사용)
*/
function generateSampleData(query: string, chartType: string): QueryResult {
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
const isMonthly = query.toLowerCase().includes('month');
const isSales = query.toLowerCase().includes('sales') || query.toLowerCase().includes('매출');
const isUsers = query.toLowerCase().includes('users') || query.toLowerCase().includes('사용자');
const isProducts = query.toLowerCase().includes('product') || query.toLowerCase().includes('상품');
let columns: string[];
let rows: Record<string, any>[];
if (isMonthly && isSales) {
// 월별 매출 데이터
columns = ['month', 'sales', 'order_count'];
rows = [
{ month: '2024-01', sales: 1200000, order_count: 45 },
{ month: '2024-02', sales: 1350000, order_count: 52 },
{ month: '2024-03', sales: 1180000, order_count: 41 },
{ month: '2024-04', sales: 1420000, order_count: 58 },
{ month: '2024-05', sales: 1680000, order_count: 67 },
{ month: '2024-06', sales: 1540000, order_count: 61 },
];
} else if (isUsers) {
// 사용자 가입 추이
columns = ['week', 'new_users'];
rows = [
{ week: '2024-W10', new_users: 23 },
{ week: '2024-W11', new_users: 31 },
{ week: '2024-W12', new_users: 28 },
{ week: '2024-W13', new_users: 35 },
{ week: '2024-W14', new_users: 42 },
{ week: '2024-W15', new_users: 38 },
];
} else if (isProducts) {
// 상품별 판매량
columns = ['product_name', 'total_sold', 'revenue'];
rows = [
{ product_name: '스마트폰', total_sold: 156, revenue: 234000000 },
{ product_name: '노트북', total_sold: 89, revenue: 178000000 },
{ product_name: '태블릿', total_sold: 134, revenue: 67000000 },
{ product_name: '이어폰', total_sold: 267, revenue: 26700000 },
{ product_name: '스마트워치', total_sold: 98, revenue: 49000000 },
];
} else {
// 기본 샘플 데이터
columns = ['category', 'value', 'count'];
rows = [
{ category: 'A', value: 100, count: 10 },
{ category: 'B', value: 150, count: 15 },
{ category: 'C', value: 120, count: 12 },
{ category: 'D', value: 180, count: 18 },
{ category: 'E', value: 90, count: 9 },
];
}
return {
columns,
rows,
totalRows: rows.length,
executionTime: Math.floor(Math.random() * 100) + 50, // 50-150ms
};
}

View File

@@ -0,0 +1,262 @@
'use client';
import React, { useState, useCallback } from 'react';
import { ChartConfig, QueryResult } from './types';
interface ChartConfigPanelProps {
config?: ChartConfig;
queryResult?: QueryResult;
onConfigChange: (config: ChartConfig) => void;
}
/**
* 차트 설정 패널 컴포넌트
* - 데이터 필드 매핑 설정
* - 차트 스타일 설정
* - 실시간 미리보기
*/
export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartConfigPanelProps) {
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
// 설정 업데이트
const updateConfig = useCallback((updates: Partial<ChartConfig>) => {
const newConfig = { ...currentConfig, ...updates };
setCurrentConfig(newConfig);
onConfigChange(newConfig);
}, [currentConfig, onConfigChange]);
// 사용 가능한 컬럼 목록
const availableColumns = queryResult?.columns || [];
const sampleData = queryResult?.rows?.[0] || {};
return (
<div className="space-y-4">
<h4 className="text-lg font-semibold text-gray-800"> </h4>
{/* 쿼리 결과가 없을 때 */}
{!queryResult && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="text-yellow-800 text-sm">
💡 SQL .
</div>
</div>
)}
{/* 데이터 필드 매핑 */}
{queryResult && (
<>
{/* 차트 제목 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700"> </label>
<input
type="text"
value={currentConfig.title || ''}
onChange={(e) => updateConfig({ title: e.target.value })}
placeholder="차트 제목을 입력하세요"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
{/* X축 설정 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
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>
</div>
{/* Y축 설정 (다중 선택 가능) */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
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>
</div>
{/* 집계 함수 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
<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"
>
<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>
</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>
</div>
{/* 차트 색상 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700"> </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'], // 따뜻한
].map((colorSet, setIdx) => (
<button
key={setIdx}
onClick={() => updateConfig({ colors: colorSet })}
className={`
h-8 rounded border-2 flex
${JSON.stringify(currentConfig.colors) === JSON.stringify(colorSet)
? 'border-gray-800' : 'border-gray-300'}
`}
>
{colorSet.map((color, idx) => (
<div
key={idx}
className="flex-1 first:rounded-l last:rounded-r"
style={{ backgroundColor: color }}
/>
))}
</button>
))}
</div>
</div>
{/* 범례 표시 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="showLegend"
checked={currentConfig.showLegend !== false}
onChange={(e) => updateConfig({ showLegend: e.target.checked })}
className="rounded"
/>
<label htmlFor="showLegend" className="text-sm text-gray-700">
</label>
</div>
{/* 설정 미리보기 */}
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-2">📋 </div>
<div className="text-xs text-gray-600 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 || '미설정'
}
</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-blue-600 mt-2">
!
</div>
)}
</div>
</div>
{/* 필수 필드 확인 */}
{(!currentConfig.xAxis || !currentConfig.yAxis) && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="text-red-800 text-sm">
X축과 Y축을 .
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import React, { forwardRef, useState, useCallback } from 'react';
import { DashboardElement, ElementType, ElementSubtype, DragData } from './types';
import { CanvasElement } from './CanvasElement';
interface DashboardCanvasProps {
elements: DashboardElement[];
selectedElement: string | null;
onCreateElement: (type: ElementType, subtype: ElementSubtype, x: number, y: number) => void;
onUpdateElement: (id: string, updates: Partial<DashboardElement>) => void;
onRemoveElement: (id: string) => void;
onSelectElement: (id: string | null) => void;
onConfigureElement?: (element: DashboardElement) => void;
}
/**
* 대시보드 캔버스 컴포넌트
* - 드래그 앤 드롭 영역
* - 그리드 배경
* - 요소 배치 및 관리
*/
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
({ elements, selectedElement, onCreateElement, onUpdateElement, onRemoveElement, onSelectElement, onConfigureElement }, ref) => {
const [isDragOver, setIsDragOver] = useState(false);
// 드래그 오버 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setIsDragOver(true);
}, []);
// 드래그 리브 처리
const handleDragLeave = useCallback((e: React.DragEvent) => {
if (e.currentTarget === e.target) {
setIsDragOver(false);
}
}, []);
// 드롭 처리
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
try {
const dragData: DragData = JSON.parse(e.dataTransfer.getData('application/json'));
if (!ref || typeof ref === 'function') return;
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
// 캔버스 스크롤을 고려한 정확한 위치 계산
const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
const y = e.clientY - rect.top + (ref.current?.scrollTop || 0);
onCreateElement(dragData.type, dragData.subtype, x, y);
} catch (error) {
// console.error('드롭 데이터 파싱 오류:', error);
}
}, [ref, onCreateElement]);
// 캔버스 클릭 시 선택 해제
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onSelectElement(null);
}
}, [onSelectElement]);
return (
<div
ref={ref}
className={`
w-full min-h-full relative
bg-gray-100
bg-grid-pattern
${isDragOver ? 'bg-blue-50' : ''}
`}
style={{
backgroundImage: `
linear-gradient(rgba(200, 200, 200, 0.3) 1px, transparent 1px),
linear-gradient(90deg, rgba(200, 200, 200, 0.3) 1px, transparent 1px)
`,
backgroundSize: '20px 20px'
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleCanvasClick}
>
{/* 배치된 요소들 렌더링 */}
{elements.map((element) => (
<CanvasElement
key={element.id}
element={element}
isSelected={selectedElement === element.id}
onUpdate={onUpdateElement}
onRemove={onRemoveElement}
onSelect={onSelectElement}
onConfigure={onConfigureElement}
/>
))}
</div>
);
}
);
DashboardCanvas.displayName = 'DashboardCanvas';

View File

@@ -0,0 +1,297 @@
'use client';
import React, { useState, useRef, useCallback } from 'react';
import { DashboardCanvas } from './DashboardCanvas';
import { DashboardSidebar } from './DashboardSidebar';
import { DashboardToolbar } from './DashboardToolbar';
import { ElementConfigModal } from './ElementConfigModal';
import { DashboardElement, ElementType, ElementSubtype } from './types';
/**
* 대시보드 설계 도구 메인 컴포넌트
* - 드래그 앤 드롭으로 차트/위젯 배치
* - 요소 이동, 크기 조절, 삭제 기능
* - 레이아웃 저장/불러오기 기능
*/
export default function DashboardDesigner() {
const [elements, setElements] = useState<DashboardElement[]>([]);
const [selectedElement, setSelectedElement] = useState<string | null>(null);
const [elementCounter, setElementCounter] = useState(0);
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
const [dashboardId, setDashboardId] = useState<string | null>(null);
const [dashboardTitle, setDashboardTitle] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
// URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const loadId = params.get('load');
if (loadId) {
loadDashboard(loadId);
}
}, []);
// 대시보드 데이터 로드
const loadDashboard = async (id: string) => {
setIsLoading(true);
try {
// console.log('🔄 대시보드 로딩:', id);
const { dashboardApi } = await import('@/lib/api/dashboard');
const dashboard = await dashboardApi.getDashboard(id);
// console.log('✅ 대시보드 로딩 완료:', dashboard);
// 대시보드 정보 설정
setDashboardId(dashboard.id);
setDashboardTitle(dashboard.title);
// 요소들 설정
if (dashboard.elements && dashboard.elements.length > 0) {
setElements(dashboard.elements);
// elementCounter를 가장 큰 ID 번호로 설정
const maxId = dashboard.elements.reduce((max, el) => {
const match = el.id.match(/element-(\d+)/);
if (match) {
const num = parseInt(match[1]);
return num > max ? num : max;
}
return max;
}, 0);
setElementCounter(maxId);
}
} catch (error) {
// console.error('❌ 대시보드 로딩 오류:', error);
alert('대시보드를 불러오는 중 오류가 발생했습니다.\n\n' + (error instanceof Error ? error.message : '알 수 없는 오류'));
} finally {
setIsLoading(false);
}
};
// 새로운 요소 생성
const createElement = useCallback((
type: ElementType,
subtype: ElementSubtype,
x: number,
y: number
) => {
const newElement: DashboardElement = {
id: `element-${elementCounter + 1}`,
type,
subtype,
position: { x, y },
size: { width: 250, height: 200 },
title: getElementTitle(type, subtype),
content: getElementContent(type, subtype)
};
setElements(prev => [...prev, newElement]);
setElementCounter(prev => prev + 1);
setSelectedElement(newElement.id);
}, [elementCounter]);
// 요소 업데이트
const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => {
setElements(prev => prev.map(el =>
el.id === id ? { ...el, ...updates } : el
));
}, []);
// 요소 삭제
const removeElement = useCallback((id: string) => {
setElements(prev => prev.filter(el => el.id !== id));
if (selectedElement === id) {
setSelectedElement(null);
}
}, [selectedElement]);
// 전체 삭제
const clearCanvas = useCallback(() => {
if (window.confirm('모든 요소를 삭제하시겠습니까?')) {
setElements([]);
setSelectedElement(null);
setElementCounter(0);
}
}, []);
// 요소 설정 모달 열기
const openConfigModal = useCallback((element: DashboardElement) => {
setConfigModalElement(element);
}, []);
// 요소 설정 모달 닫기
const closeConfigModal = useCallback(() => {
setConfigModalElement(null);
}, []);
// 요소 설정 저장
const saveElementConfig = useCallback((updatedElement: DashboardElement) => {
updateElement(updatedElement.id, updatedElement);
}, [updateElement]);
// 레이아웃 저장
const saveLayout = useCallback(async () => {
if (elements.length === 0) {
alert('저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.');
return;
}
try {
// 실제 API 호출
const { dashboardApi } = await import('@/lib/api/dashboard');
const elementsData = elements.map(el => ({
id: el.id,
type: el.type,
subtype: el.subtype,
position: el.position,
size: el.size,
title: el.title,
content: el.content,
dataSource: el.dataSource,
chartConfig: el.chartConfig
}));
let savedDashboard;
if (dashboardId) {
// 기존 대시보드 업데이트
// console.log('🔄 대시보드 업데이트:', dashboardId);
savedDashboard = await dashboardApi.updateDashboard(dashboardId, {
elements: elementsData
});
alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`);
// 뷰어 페이지로 이동
window.location.href = `/dashboard/${savedDashboard.id}`;
} else {
// 새 대시보드 생성
const title = prompt('대시보드 제목을 입력하세요:', '새 대시보드');
if (!title) return;
const description = prompt('대시보드 설명을 입력하세요 (선택사항):', '');
const dashboardData = {
title,
description: description || undefined,
isPublic: false,
elements: elementsData
};
savedDashboard = await dashboardApi.createDashboard(dashboardData);
// console.log('✅ 대시보드 생성 완료:', savedDashboard);
const viewDashboard = confirm(`대시보드 "${title}"이 저장되었습니다!\n\n지금 확인해보시겠습니까?`);
if (viewDashboard) {
window.location.href = `/dashboard/${savedDashboard.id}`;
}
}
} catch (error) {
// console.error('❌ 저장 오류:', error);
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류';
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
}
}, [elements, dashboardId]);
// 로딩 중이면 로딩 화면 표시
if (isLoading) {
return (
<div className="flex h-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<div className="text-lg font-medium text-gray-700"> ...</div>
<div className="text-sm text-gray-500 mt-1"> </div>
</div>
</div>
);
}
return (
<div className="flex h-full bg-gray-50">
{/* 캔버스 영역 */}
<div className="flex-1 relative overflow-auto border-r-2 border-gray-300">
{/* 편집 중인 대시보드 표시 */}
{dashboardTitle && (
<div className="absolute top-2 left-2 z-10 bg-blue-500 text-white px-3 py-1 rounded-lg text-sm font-medium shadow-lg">
📝 : {dashboardTitle}
</div>
)}
<DashboardToolbar
onClearCanvas={clearCanvas}
onSaveLayout={saveLayout}
/>
<DashboardCanvas
ref={canvasRef}
elements={elements}
selectedElement={selectedElement}
onCreateElement={createElement}
onUpdateElement={updateElement}
onRemoveElement={removeElement}
onSelectElement={setSelectedElement}
onConfigureElement={openConfigModal}
/>
</div>
{/* 사이드바 */}
<DashboardSidebar />
{/* 요소 설정 모달 */}
{configModalElement && (
<ElementConfigModal
element={configModalElement}
isOpen={true}
onClose={closeConfigModal}
onSave={saveElementConfig}
/>
)}
</div>
);
}
// 요소 제목 생성 헬퍼 함수
function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
if (type === 'chart') {
switch (subtype) {
case 'bar': return '📊 바 차트';
case 'pie': return '🥧 원형 차트';
case 'line': return '📈 꺾은선 차트';
default: return '📊 차트';
}
} else if (type === 'widget') {
switch (subtype) {
case 'exchange': return '💱 환율 위젯';
case 'weather': return '☁️ 날씨 위젯';
default: return '🔧 위젯';
}
}
return '요소';
}
// 요소 내용 생성 헬퍼 함수
function getElementContent(type: ElementType, subtype: ElementSubtype): string {
if (type === 'chart') {
switch (subtype) {
case 'bar': return '바 차트가 여기에 표시됩니다';
case 'pie': return '원형 차트가 여기에 표시됩니다';
case 'line': return '꺾은선 차트가 여기에 표시됩니다';
default: return '차트가 여기에 표시됩니다';
}
} else if (type === 'widget') {
switch (subtype) {
case 'exchange': return 'USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450';
case 'weather': return '서울\n23°C\n구름 많음';
default: return '위젯 내용이 여기에 표시됩니다';
}
}
return '내용이 여기에 표시됩니다';
}

View File

@@ -0,0 +1,145 @@
'use client';
import React from 'react';
import { DragData, ElementType, ElementSubtype } from './types';
/**
* 대시보드 사이드바 컴포넌트
* - 드래그 가능한 차트/위젯 목록
* - 카테고리별 구분
*/
export function DashboardSidebar() {
// 드래그 시작 처리
const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => {
const dragData: DragData = { type, subtype };
e.dataTransfer.setData('application/json', JSON.stringify(dragData));
e.dataTransfer.effectAllowed = 'copy';
};
return (
<div className="w-80 bg-white border-l border-gray-200 overflow-y-auto p-5">
{/* 차트 섹션 */}
<div className="mb-8">
<h3 className="text-gray-800 mb-4 pb-3 border-b-2 border-green-500 font-semibold text-lg">
📊
</h3>
<div className="space-y-3">
<DraggableItem
icon="📊"
title="바 차트"
type="chart"
subtype="bar"
onDragStart={handleDragStart}
className="border-l-4 border-blue-500"
/>
<DraggableItem
icon="📚"
title="누적 바 차트"
type="chart"
subtype="stacked-bar"
onDragStart={handleDragStart}
className="border-l-4 border-blue-600"
/>
<DraggableItem
icon="📈"
title="꺾은선 차트"
type="chart"
subtype="line"
onDragStart={handleDragStart}
className="border-l-4 border-green-500"
/>
<DraggableItem
icon="📉"
title="영역 차트"
type="chart"
subtype="area"
onDragStart={handleDragStart}
className="border-l-4 border-green-600"
/>
<DraggableItem
icon="🥧"
title="원형 차트"
type="chart"
subtype="pie"
onDragStart={handleDragStart}
className="border-l-4 border-purple-500"
/>
<DraggableItem
icon="🍩"
title="도넛 차트"
type="chart"
subtype="donut"
onDragStart={handleDragStart}
className="border-l-4 border-purple-600"
/>
<DraggableItem
icon="📊📈"
title="콤보 차트"
type="chart"
subtype="combo"
onDragStart={handleDragStart}
className="border-l-4 border-indigo-500"
/>
</div>
</div>
{/* 위젯 섹션 */}
<div className="mb-8">
<h3 className="text-gray-800 mb-4 pb-3 border-b-2 border-green-500 font-semibold text-lg">
🔧
</h3>
<div className="space-y-3">
<DraggableItem
icon="💱"
title="환율 위젯"
type="widget"
subtype="exchange"
onDragStart={handleDragStart}
className="border-l-4 border-orange-500"
/>
<DraggableItem
icon="☁️"
title="날씨 위젯"
type="widget"
subtype="weather"
onDragStart={handleDragStart}
className="border-l-4 border-orange-500"
/>
</div>
</div>
</div>
);
}
interface DraggableItemProps {
icon: string;
title: string;
type: ElementType;
subtype: ElementSubtype;
className?: string;
onDragStart: (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => void;
}
/**
* 드래그 가능한 아이템 컴포넌트
*/
function DraggableItem({ icon, title, type, subtype, className = '', onDragStart }: DraggableItemProps) {
return (
<div
draggable
className={`
p-4 bg-white border-2 border-gray-200 rounded-lg
cursor-move transition-all duration-200
hover:bg-gray-50 hover:border-green-500 hover:translate-x-1
text-center text-sm font-medium
${className}
`}
onDragStart={(e) => onDragStart(e, type, subtype)}
>
<span className="text-lg mr-2">{icon}</span>
{title}
</div>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import React from 'react';
interface DashboardToolbarProps {
onClearCanvas: () => void;
onSaveLayout: () => void;
}
/**
* 대시보드 툴바 컴포넌트
* - 전체 삭제, 레이아웃 저장 등 주요 액션 버튼
*/
export function DashboardToolbar({ onClearCanvas, onSaveLayout }: DashboardToolbarProps) {
return (
<div className="absolute top-5 left-5 bg-white p-3 rounded-lg shadow-lg z-50 flex gap-3">
<button
onClick={onClearCanvas}
className="
px-4 py-2 border border-gray-300 bg-white rounded-md
text-sm font-medium text-gray-700
hover:bg-gray-50 hover:border-gray-400
transition-colors duration-200
"
>
🗑
</button>
<button
onClick={onSaveLayout}
className="
px-4 py-2 border border-gray-300 bg-white rounded-md
text-sm font-medium text-gray-700
hover:bg-gray-50 hover:border-gray-400
transition-colors duration-200
"
>
💾
</button>
</div>
);
}

View File

@@ -0,0 +1,169 @@
'use client';
import React, { useState, useCallback } from 'react';
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from './types';
import { QueryEditor } from './QueryEditor';
import { ChartConfigPanel } from './ChartConfigPanel';
interface ElementConfigModalProps {
element: DashboardElement;
isOpen: boolean;
onClose: () => void;
onSave: (element: DashboardElement) => void;
}
/**
* 요소 설정 모달 컴포넌트
* - 차트/위젯 데이터 소스 설정
* - 쿼리 에디터 통합
* - 차트 설정 패널 통합
*/
export function ElementConfigModal({ element, isOpen, onClose, onSave }: ElementConfigModalProps) {
const [dataSource, setDataSource] = useState<ChartDataSource>(
element.dataSource || { type: 'database', refreshInterval: 30000 }
);
const [chartConfig, setChartConfig] = useState<ChartConfig>(
element.chartConfig || {}
);
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [activeTab, setActiveTab] = useState<'query' | 'chart'>('query');
// 데이터 소스 변경 처리
const handleDataSourceChange = useCallback((newDataSource: ChartDataSource) => {
setDataSource(newDataSource);
}, []);
// 차트 설정 변경 처리
const handleChartConfigChange = useCallback((newConfig: ChartConfig) => {
setChartConfig(newConfig);
}, []);
// 쿼리 테스트 결과 처리
const handleQueryTest = useCallback((result: QueryResult) => {
setQueryResult(result);
// 쿼리 결과가 나오면 자동으로 차트 설정 탭으로 이동
if (result.rows.length > 0) {
setActiveTab('chart');
}
}, []);
// 저장 처리
const handleSave = useCallback(() => {
const updatedElement: DashboardElement = {
...element,
dataSource,
chartConfig,
};
onSave(updatedElement);
onClose();
}, [element, dataSource, chartConfig, onSave, onClose]);
// 모달이 열려있지 않으면 렌더링하지 않음
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl h-[80vh] flex flex-col">
{/* 모달 헤더 */}
<div className="flex justify-between items-center p-6 border-b border-gray-200">
<div>
<h2 className="text-xl font-semibold text-gray-800">
{element.title}
</h2>
<p className="text-sm text-gray-600 mt-1">
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-2xl"
>
×
</button>
</div>
{/* 탭 네비게이션 */}
<div className="flex border-b border-gray-200">
<button
onClick={() => setActiveTab('query')}
className={`
px-6 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === 'query'
? 'border-blue-500 text-blue-600 bg-blue-50'
: 'border-transparent text-gray-500 hover:text-gray-700'}
`}
>
📝 &
</button>
<button
onClick={() => setActiveTab('chart')}
className={`
px-6 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === 'chart'
? 'border-blue-500 text-blue-600 bg-blue-50'
: 'border-transparent text-gray-500 hover:text-gray-700'}
`}
>
📊
{queryResult && (
<span className="ml-2 px-2 py-0.5 bg-green-100 text-green-800 text-xs rounded-full">
{queryResult.rows.length}
</span>
)}
</button>
</div>
{/* 탭 내용 */}
<div className="flex-1 overflow-auto p-6">
{activeTab === 'query' && (
<QueryEditor
dataSource={dataSource}
onDataSourceChange={handleDataSourceChange}
onQueryTest={handleQueryTest}
/>
)}
{activeTab === 'chart' && (
<ChartConfigPanel
config={chartConfig}
queryResult={queryResult}
onConfigChange={handleChartConfigChange}
/>
)}
</div>
{/* 모달 푸터 */}
<div className="flex justify-between items-center p-6 border-t border-gray-200">
<div className="text-sm text-gray-500">
{dataSource.query && (
<>
💾 : {dataSource.query.length > 50
? `${dataSource.query.substring(0, 50)}...`
: dataSource.query}
</>
)}
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
>
</button>
<button
onClick={handleSave}
disabled={!dataSource.query || (!chartConfig.xAxis || !chartConfig.yAxis)}
className="
px-4 py-2 bg-blue-500 text-white rounded-lg
hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed
"
>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,489 @@
'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-blue-500 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-gray-600"> :</span>
<button
onClick={() => insertSampleQuery('comparison')}
className="px-2 py-1 text-xs bg-blue-100 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-gray-600"> :</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-red-50 border border-red-200 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-gray-600">
{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
};
}

View File

@@ -0,0 +1,110 @@
'use client';
import React from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import { ChartConfig } from '../types';
interface AreaChartComponentProps {
data: any[];
config: ChartConfig;
width?: number;
height?: number;
}
/**
* 영역 차트 컴포넌트
* - Recharts AreaChart 사용
* - 추세 파악에 적합
* - 다중 시리즈 지원
*/
export function AreaChartComponent({ data, config, width = 250, height = 200 }: AreaChartComponentProps) {
const {
xAxis = 'x',
yAxis = 'y',
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'],
title,
showLegend = true
} = config;
// Y축 필드들 (단일 또는 다중)
const yFields = Array.isArray(yAxis) ? yAxis : [yAxis];
const yKeys = yFields.filter(field => field && field !== 'y');
return (
<div className="w-full h-full p-2">
{title && (
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
{title}
</div>
)}
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<defs>
{yKeys.map((key, index) => (
<linearGradient key={key} id={`color${index}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={colors[index % colors.length]} stopOpacity={0.8}/>
<stop offset="95%" stopColor={colors[index % colors.length]} stopOpacity={0.1}/>
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey={xAxis}
tick={{ fontSize: 12 }}
stroke="#666"
/>
<YAxis
tick={{ fontSize: 12 }}
stroke="#666"
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '12px'
}}
formatter={(value: any, name: string) => [
typeof value === 'number' ? value.toLocaleString() : value,
name
]}
/>
{showLegend && yKeys.length > 1 && (
<Legend
wrapperStyle={{ fontSize: '12px' }}
/>
)}
{yKeys.map((key, index) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={colors[index % colors.length]}
fill={`url(#color${index})`}
strokeWidth={2}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import React from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
interface BarChartComponentProps {
data: any[];
config: any;
width?: number;
height?: number;
}
/**
* 바 차트 컴포넌트 (Recharts SimpleBarChart 기반)
* - 실제 데이터를 받아서 단순하게 표시
* - 복잡한 변환 로직 없음
*/
export function BarChartComponent({ data, config, width = 600, height = 300 }: BarChartComponentProps) {
// console.log('🎨 BarChartComponent - 전체 데이터:', {
// dataLength: data?.length,
// fullData: data,
// dataType: typeof data,
// isArray: Array.isArray(data),
// config,
// xAxisField: config?.xAxis,
// yAxisFields: config?.yAxis
// });
// 데이터가 없으면 메시지 표시
if (!data || data.length === 0) {
return (
<div className="w-full h-full flex items-center justify-center text-gray-500">
<div className="text-center">
<div className="text-2xl mb-2">📊</div>
<div> </div>
</div>
</div>
);
}
// 데이터의 첫 번째 아이템에서 사용 가능한 키 확인
const firstItem = data[0];
const availableKeys = Object.keys(firstItem);
// console.log('📊 사용 가능한 데이터 키:', availableKeys);
// console.log('📊 첫 번째 데이터 아이템:', firstItem);
// Y축 필드 추출 (배열이면 모두 사용, 아니면 단일 값)
const yFields = Array.isArray(config.yAxis) ? config.yAxis : [config.yAxis];
// 색상 배열
const colors = ['#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1'];
// 한글 레이블 매핑
const labelMapping: Record<string, string> = {
'total_users': '전체 사용자',
'active_users': '활성 사용자',
'name': '부서'
};
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey={config.xAxis}
tick={{ fontSize: 12 }}
/>
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
{config.showLegend !== false && <Legend />}
{/* Y축 필드마다 Bar 생성 */}
{yFields.map((field: string, index: number) => (
<Bar
key={field}
dataKey={field}
fill={colors[index % colors.length]}
name={labelMapping[field] || field}
/>
))}
</BarChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import React from 'react';
import { DashboardElement, QueryResult } from '../types';
import { BarChartComponent } from './BarChartComponent';
import { PieChartComponent } from './PieChartComponent';
import { LineChartComponent } from './LineChartComponent';
import { AreaChartComponent } from './AreaChartComponent';
import { StackedBarChartComponent } from './StackedBarChartComponent';
import { DonutChartComponent } from './DonutChartComponent';
import { ComboChartComponent } from './ComboChartComponent';
interface ChartRendererProps {
element: DashboardElement;
data?: QueryResult;
width?: number;
height?: number;
}
/**
* 차트 렌더러 컴포넌트 (단순 버전)
* - 데이터를 받아서 차트에 그대로 전달
* - 복잡한 변환 로직 제거
*/
export function ChartRenderer({ element, data, width = 250, height = 200 }: ChartRendererProps) {
// console.log('🎬 ChartRenderer:', {
// elementId: element.id,
// hasData: !!data,
// dataRows: data?.rows?.length,
// xAxis: element.chartConfig?.xAxis,
// yAxis: element.chartConfig?.yAxis
// });
// 데이터나 설정이 없으면 메시지 표시
if (!data || !element.chartConfig?.xAxis || !element.chartConfig?.yAxis) {
return (
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm">
<div className="text-center">
<div className="text-2xl mb-2">📊</div>
<div> </div>
<div className="text-xs mt-1"> </div>
</div>
</div>
);
}
// 데이터가 비어있으면
if (!data.rows || data.rows.length === 0) {
return (
<div className="w-full h-full flex items-center justify-center text-red-500 text-sm">
<div className="text-center">
<div className="text-2xl mb-2"></div>
<div> </div>
</div>
</div>
);
}
// 데이터를 그대로 전달 (변환 없음!)
const chartData = data.rows;
// console.log('📊 Chart Data:', {
// dataLength: chartData.length,
// firstRow: chartData[0],
// columns: Object.keys(chartData[0] || {})
// });
// 차트 공통 props
const chartProps = {
data: chartData,
config: element.chartConfig,
width: width - 20,
height: height - 60,
};
// 차트 타입에 따른 렌더링
switch (element.subtype) {
case 'bar':
return <BarChartComponent {...chartProps} />;
case 'pie':
return <PieChartComponent {...chartProps} />;
case 'line':
return <LineChartComponent {...chartProps} />;
case 'area':
return <AreaChartComponent {...chartProps} />;
case 'stacked-bar':
return <StackedBarChartComponent {...chartProps} />;
case 'donut':
return <DonutChartComponent {...chartProps} />;
case 'combo':
return <ComboChartComponent {...chartProps} />;
default:
return (
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm">
<div className="text-center">
<div className="text-2xl mb-2"></div>
<div> </div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,118 @@
'use client';
import React from 'react';
import {
ComposedChart,
Bar,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import { ChartConfig } from '../types';
interface ComboChartComponentProps {
data: any[];
config: ChartConfig;
width?: number;
height?: number;
}
/**
* 콤보 차트 컴포넌트 (바 + 라인)
* - Recharts ComposedChart 사용
* - 서로 다른 스케일의 데이터를 함께 표시
* - 예: 매출(바) + 이익률(라인)
*/
export function ComboChartComponent({ data, config, width = 250, height = 200 }: ComboChartComponentProps) {
const {
xAxis = 'x',
yAxis = 'y',
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'],
title,
showLegend = true
} = config;
// Y축 필드들 (단일 또는 다중)
const yFields = Array.isArray(yAxis) ? yAxis : [yAxis];
const yKeys = yFields.filter(field => field && field !== 'y');
// 첫 번째는 Bar, 나머지는 Line으로 표시
const barKeys = yKeys.slice(0, 1);
const lineKeys = yKeys.slice(1);
return (
<div className="w-full h-full p-2">
{title && (
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
{title}
</div>
)}
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={data}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey={xAxis}
tick={{ fontSize: 12 }}
stroke="#666"
/>
<YAxis
tick={{ fontSize: 12 }}
stroke="#666"
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '12px'
}}
formatter={(value: any, name: string) => [
typeof value === 'number' ? value.toLocaleString() : value,
name
]}
/>
{showLegend && yKeys.length > 1 && (
<Legend
wrapperStyle={{ fontSize: '12px' }}
/>
)}
{/* 바 차트 */}
{barKeys.map((key, index) => (
<Bar
key={key}
dataKey={key}
fill={colors[index % colors.length]}
radius={[2, 2, 0, 0]}
/>
))}
{/* 라인 차트 */}
{lineKeys.map((key, index) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={colors[(barKeys.length + index) % colors.length]}
strokeWidth={2}
dot={{ r: 3 }}
/>
))}
</ComposedChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import React from 'react';
import {
PieChart,
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import { ChartConfig } from '../types';
interface DonutChartComponentProps {
data: any[];
config: ChartConfig;
width?: number;
height?: number;
}
/**
* 도넛 차트 컴포넌트
* - Recharts PieChart (innerRadius 사용) 사용
* - 비율 표시에 적합 (중앙 공간 활용 가능)
*/
export function DonutChartComponent({ data, config, width = 250, height = 200 }: DonutChartComponentProps) {
const {
xAxis = 'x',
yAxis = 'y',
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'],
title,
showLegend = true
} = config;
// 파이 차트용 데이터 변환
const pieData = data.map(item => ({
name: String(item[xAxis] || ''),
value: typeof item[yAxis as string] === 'number' ? item[yAxis as string] : 0
}));
// 총합 계산
const total = pieData.reduce((sum, item) => sum + item.value, 0);
// 커스텀 라벨 (퍼센트 표시)
const renderLabel = (entry: any) => {
const percent = ((entry.value / total) * 100).toFixed(1);
return `${percent}%`;
};
return (
<div className="w-full h-full p-2 flex flex-col">
{title && (
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
{title}
</div>
)}
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
labelLine={false}
label={renderLabel}
outerRadius={80}
innerRadius={50}
fill="#8884d8"
dataKey="value"
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '12px'
}}
formatter={(value: any) => [
typeof value === 'number' ? value.toLocaleString() : value,
'값'
]}
/>
{showLegend && (
<Legend
wrapperStyle={{ fontSize: '12px' }}
layout="vertical"
align="right"
verticalAlign="middle"
/>
)}
</PieChart>
</ResponsiveContainer>
{/* 중앙 총합 표시 */}
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<div className="text-center">
<div className="text-xs text-gray-500">Total</div>
<div className="text-sm font-bold text-gray-800">
{total.toLocaleString()}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import React from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import { ChartConfig } from '../types';
interface LineChartComponentProps {
data: any[];
config: ChartConfig;
width?: number;
height?: number;
}
/**
* 꺾은선 차트 컴포넌트
* - Recharts LineChart 사용
* - 다중 라인 지원
*/
export function LineChartComponent({ data, config, width = 250, height = 200 }: LineChartComponentProps) {
const {
xAxis = 'x',
yAxis = 'y',
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'],
title,
showLegend = true
} = config;
// Y축 필드들 (단일 또는 다중)
const yFields = Array.isArray(yAxis) ? yAxis : [yAxis];
// 사용할 Y축 키들 결정
const yKeys = yFields.filter(field => field && field !== 'y');
return (
<div className="w-full h-full p-2">
{title && (
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
{title}
</div>
)}
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey={xAxis}
tick={{ fontSize: 12 }}
stroke="#666"
/>
<YAxis
tick={{ fontSize: 12 }}
stroke="#666"
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '12px'
}}
formatter={(value: any, name: string) => [
typeof value === 'number' ? value.toLocaleString() : value,
name
]}
/>
{showLegend && yKeys.length > 1 && (
<Legend
wrapperStyle={{ fontSize: '12px' }}
/>
)}
{yKeys.map((key, index) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={colors[index % colors.length]}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import React from 'react';
import {
PieChart,
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import { ChartConfig } from '../types';
interface PieChartComponentProps {
data: any[];
config: ChartConfig;
width?: number;
height?: number;
}
/**
* 원형 차트 컴포넌트
* - Recharts PieChart 사용
* - 자동 색상 배치 및 레이블
*/
export function PieChartComponent({ data, config, width = 250, height = 200 }: PieChartComponentProps) {
const {
xAxis = 'x',
yAxis = 'y',
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'],
title,
showLegend = true
} = config;
// 파이 차트용 데이터 변환
const pieData = data.map((item, index) => ({
name: String(item[xAxis] || `항목 ${index + 1}`),
value: Number(item[yAxis]) || 0,
color: colors[index % colors.length]
})).filter(item => item.value > 0); // 0보다 큰 값만 표시
// 커스텀 레이블 함수
const renderLabel = (entry: any) => {
const percent = ((entry.value / pieData.reduce((sum, item) => sum + item.value, 0)) * 100).toFixed(1);
return `${percent}%`;
};
return (
<div className="w-full h-full p-2">
{title && (
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
{title}
</div>
)}
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
labelLine={false}
label={renderLabel}
outerRadius={Math.min(width, height) * 0.3}
fill="#8884d8"
dataKey="value"
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '12px'
}}
formatter={(value: any, name: string) => [
typeof value === 'number' ? value.toLocaleString() : value,
name
]}
/>
{showLegend && (
<Legend
wrapperStyle={{ fontSize: '12px' }}
iconType="circle"
/>
)}
</PieChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import { ChartConfig } from '../types';
interface StackedBarChartComponentProps {
data: any[];
config: ChartConfig;
width?: number;
height?: number;
}
/**
* 누적 바 차트 컴포넌트
* - Recharts BarChart (stacked) 사용
* - 전체 대비 비율 파악에 적합
* - 다중 시리즈를 누적으로 표시
*/
export function StackedBarChartComponent({ data, config, width = 250, height = 200 }: StackedBarChartComponentProps) {
const {
xAxis = 'x',
yAxis = 'y',
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'],
title,
showLegend = true
} = config;
// Y축 필드들 (단일 또는 다중)
const yFields = Array.isArray(yAxis) ? yAxis : [yAxis];
const yKeys = yFields.filter(field => field && field !== 'y');
return (
<div className="w-full h-full p-2">
{title && (
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
{title}
</div>
)}
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey={xAxis}
tick={{ fontSize: 12 }}
stroke="#666"
/>
<YAxis
tick={{ fontSize: 12 }}
stroke="#666"
/>
<Tooltip
contentStyle={{
backgroundColor: 'white',
border: '1px solid #ccc',
borderRadius: '4px',
fontSize: '12px'
}}
formatter={(value: any, name: string) => [
typeof value === 'number' ? value.toLocaleString() : value,
name
]}
/>
{showLegend && yKeys.length > 1 && (
<Legend
wrapperStyle={{ fontSize: '12px' }}
/>
)}
{yKeys.map((key, index) => (
<Bar
key={key}
dataKey={key}
stackId="a"
fill={colors[index % colors.length]}
radius={index === yKeys.length - 1 ? [2, 2, 0, 0] : [0, 0, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,12 @@
/**
* 차트 컴포넌트 인덱스
*/
export { ChartRenderer } from './ChartRenderer';
export { BarChartComponent } from './BarChartComponent';
export { PieChartComponent } from './PieChartComponent';
export { LineChartComponent } from './LineChartComponent';
export { AreaChartComponent } from './AreaChartComponent';
export { StackedBarChartComponent } from './StackedBarChartComponent';
export { DonutChartComponent } from './DonutChartComponent';
export { ComboChartComponent } from './ComboChartComponent';

View File

@@ -0,0 +1,13 @@
/**
* 대시보드 관리 컴포넌트 인덱스
*/
export { default as DashboardDesigner } from './DashboardDesigner';
export { DashboardCanvas } from './DashboardCanvas';
export { DashboardSidebar } from './DashboardSidebar';
export { DashboardToolbar } from './DashboardToolbar';
export { CanvasElement } from './CanvasElement';
export { QueryEditor } from './QueryEditor';
export { ChartConfigPanel } from './ChartConfigPanel';
export { ElementConfigModal } from './ElementConfigModal';
export * from './types';

View File

@@ -0,0 +1,68 @@
/**
* 대시보드 관리 시스템 타입 정의
*/
export type ElementType = 'chart' | 'widget';
export type ElementSubtype =
| 'bar' | 'pie' | 'line' | 'area' | 'stacked-bar' | 'donut' | 'combo' // 차트 타입
| 'exchange' | 'weather'; // 위젯 타입
export interface Position {
x: number;
y: number;
}
export interface Size {
width: number;
height: number;
}
export interface DashboardElement {
id: string;
type: ElementType;
subtype: ElementSubtype;
position: Position;
size: Size;
title: string;
content: string;
dataSource?: ChartDataSource; // 데이터 소스 설정
chartConfig?: ChartConfig; // 차트 설정
}
export interface DragData {
type: ElementType;
subtype: ElementSubtype;
}
export interface ResizeHandle {
direction: 'nw' | 'ne' | 'sw' | 'se';
cursor: string;
}
export interface ChartDataSource {
type: 'api' | 'database' | 'static';
endpoint?: string; // API 엔드포인트
query?: string; // SQL 쿼리
refreshInterval?: number; // 자동 새로고침 간격 (ms)
filters?: any[]; // 필터 조건
lastExecuted?: string; // 마지막 실행 시간
}
export interface ChartConfig {
xAxis?: string; // X축 데이터 필드
yAxis?: string | string[]; // Y축 데이터 필드 (단일 또는 다중)
groupBy?: string; // 그룹핑 필드
aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min';
colors?: string[]; // 차트 색상
title?: string; // 차트 제목
showLegend?: boolean; // 범례 표시 여부
}
export interface QueryResult {
columns: string[]; // 컬럼명 배열
rows: Record<string, any>[]; // 데이터 행 배열
totalRows: number; // 전체 행 수
executionTime: number; // 실행 시간 (ms)
error?: string; // 오류 메시지
}