Merge branch 'feature/dashboard-management' into main
대시보드 관리 시스템 통합 - 충돌 해결 완료 - 백엔드/프론트엔드 대시보드 기능 추가
This commit is contained in:
398
frontend/components/admin/dashboard/CanvasElement.tsx
Normal file
398
frontend/components/admin/dashboard/CanvasElement.tsx
Normal 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
|
||||
};
|
||||
}
|
||||
262
frontend/components/admin/dashboard/ChartConfigPanel.tsx
Normal file
262
frontend/components/admin/dashboard/ChartConfigPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
frontend/components/admin/dashboard/DashboardCanvas.tsx
Normal file
109
frontend/components/admin/dashboard/DashboardCanvas.tsx
Normal 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';
|
||||
297
frontend/components/admin/dashboard/DashboardDesigner.tsx
Normal file
297
frontend/components/admin/dashboard/DashboardDesigner.tsx
Normal 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 '내용이 여기에 표시됩니다';
|
||||
}
|
||||
145
frontend/components/admin/dashboard/DashboardSidebar.tsx
Normal file
145
frontend/components/admin/dashboard/DashboardSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
frontend/components/admin/dashboard/DashboardToolbar.tsx
Normal file
42
frontend/components/admin/dashboard/DashboardToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
frontend/components/admin/dashboard/ElementConfigModal.tsx
Normal file
169
frontend/components/admin/dashboard/ElementConfigModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
489
frontend/components/admin/dashboard/QueryEditor.tsx
Normal file
489
frontend/components/admin/dashboard/QueryEditor.tsx
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
102
frontend/components/admin/dashboard/charts/ChartRenderer.tsx
Normal file
102
frontend/components/admin/dashboard/charts/ChartRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
12
frontend/components/admin/dashboard/charts/index.ts
Normal file
12
frontend/components/admin/dashboard/charts/index.ts
Normal 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';
|
||||
13
frontend/components/admin/dashboard/index.ts
Normal file
13
frontend/components/admin/dashboard/index.ts
Normal 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';
|
||||
68
frontend/components/admin/dashboard/types.ts
Normal file
68
frontend/components/admin/dashboard/types.ts
Normal 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; // 오류 메시지
|
||||
}
|
||||
Reference in New Issue
Block a user