feat: 대시보드 관리 시스템 구현

## 백엔드
- DashboardController: 대시보드 CRUD 및 쿼리 실행 API
- DashboardService: 비즈니스 로직 처리
- PostgreSQL 연동 및 데이터 관리

## 프론트엔드
- DashboardDesigner: 캔버스 기반 대시보드 디자이너
- QueryEditor: SQL 쿼리 편집 및 미리보기
- ChartRenderer: 다양한 차트 타입 지원 (Bar, Line, Area, Donut, Stacked, Combo)
- DashboardViewer: 실시간 데이터 반영 뷰어

## 개선사항
- 콘솔 로그 프로덕션 준비 (주석 처리)
- 차트 컴포넌트 확장 (6가지 타입)
- 실시간 쿼리 실행 및 데이터 바인딩
This commit is contained in:
2025-10-01 12:06:24 +09:00
parent cf747b5fb3
commit 5f63c24c42
27 changed files with 3330 additions and 539 deletions

View File

@@ -141,11 +141,22 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
setIsLoadingData(true);
try {
// 실제 API 호출 대신 샘플 데이터 생성
const sampleData = generateSampleData(element.dataSource.query, element.subtype);
setChartData(sampleData);
// 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);
// console.error('❌ 데이터 로딩 오류:', error);
setChartData(null);
} finally {
setIsLoadingData(false);

View File

@@ -77,40 +77,85 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
</select>
</div>
{/* Y축 설정 */}
{/* Y축 설정 (다중 선택 가능) */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Y축 ()
Y축 () -
<span className="text-red-500 ml-1">*</span>
</label>
<select
value={currentConfig.yAxis || ''}
onChange={(e) => updateConfig({ yAxis: 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 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"> </label>
<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>
<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>
{/* 그룹핑 필드 (선택사항) */}
@@ -182,12 +227,23 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
<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> {currentConfig.yAxis || '미설정'}</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>

View File

@@ -57,7 +57,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
onCreateElement(dragData.type, dragData.subtype, x, y);
} catch (error) {
console.error('드롭 데이터 파싱 오류:', error);
// console.error('드롭 데이터 파싱 오류:', error);
}
}, [ref, onCreateElement]);

View File

@@ -18,8 +18,60 @@ export default function DashboardDesigner() {
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,
@@ -82,28 +134,98 @@ export default function DashboardDesigner() {
}, [updateElement]);
// 레이아웃 저장
const saveLayout = useCallback(() => {
const layoutData = {
elements: elements.map(el => ({
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
})),
timestamp: new Date().toISOString()
};
console.log('저장된 레이아웃:', JSON.stringify(layoutData, null, 2));
alert('레이아웃이 콘솔에 저장되었습니다. (F12를 눌러 확인하세요)');
}, [elements]);
}));
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}

View File

@@ -34,12 +34,12 @@ export function DashboardSidebar() {
className="border-l-4 border-blue-500"
/>
<DraggableItem
icon="🥧"
title="원형 차트"
icon="📚"
title="누적 바 차트"
type="chart"
subtype="pie"
subtype="stacked-bar"
onDragStart={handleDragStart}
className="border-l-4 border-blue-500"
className="border-l-4 border-blue-600"
/>
<DraggableItem
icon="📈"
@@ -47,7 +47,39 @@ export function DashboardSidebar() {
type="chart"
subtype="line"
onDragStart={handleDragStart}
className="border-l-4 border-blue-500"
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>

View File

@@ -32,10 +32,35 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
setError(null);
try {
// 실제 API 호출 대신 샘플 데이터 생성
await new Promise(resolve => setTimeout(resolve, 500 + Math.random() * 1000)); // 실제 API 호출 시뮬레이션
// 실제 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으로 설정
};
const result: QueryResult = generateSampleQueryResult(query.trim());
setQueryResult(result);
onQueryTest?.(result);
@@ -50,7 +75,7 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '쿼리 실행 중 오류가 발생했습니다.';
setError(errorMessage);
console.error('Query execution error:', err);
// console.error('Query execution error:', err);
} finally {
setIsExecuting(false);
}
@@ -59,6 +84,18 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
// 샘플 쿼리 삽입
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,
@@ -88,7 +125,19 @@ 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;`
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] || '');
@@ -124,6 +173,18 @@ LIMIT 10;`
{/* 샘플 쿼리 버튼들 */}
<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"
@@ -270,33 +331,89 @@ LIMIT 10;`
*/
function generateSampleQueryResult(query: 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('상품');
const isWeekly = query.toLowerCase().includes('week');
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 (isMonthly && isSales) {
// 월별 매출 데이터
columns = ['month', 'sales', 'order_count'];
// 더 구체적인 조건부터 먼저 체크 (순서 중요!)
if (isComparison) {
// console.log('✅ Using COMPARISON data');
// 제품 비교 데이터 (다중 시리즈)
columns = ['month', 'galaxy_sales', 'iphone_sales', 'other_sales'];
rows = [
{ month: '2024-01', sales: 1200000, order_count: 45 },
{ month: '2024-02', sales: 1350000, order_count: 52 },
{ month: '2024-03', sales: 1180000, order_count: 41 },
{ month: '2024-04', sales: 1420000, order_count: 58 },
{ month: '2024-05', sales: 1680000, order_count: 67 },
{ month: '2024-06', sales: 1540000, order_count: 61 },
{ month: '2024-07', sales: 1720000, order_count: 71 },
{ month: '2024-08', sales: 1580000, order_count: 63 },
{ month: '2024-09', sales: 1650000, order_count: 68 },
{ month: '2024-10', sales: 1780000, order_count: 75 },
{ month: '2024-11', sales: 1920000, order_count: 82 },
{ month: '2024-12', sales: 2100000, order_count: 89 },
{ month: '2024-01', 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 = [
@@ -313,7 +430,8 @@ function generateSampleQueryResult(query: string): QueryResult {
{ week: '2024-W20', new_users: 61 },
{ week: '2024-W21', new_users: 58 },
];
} else if (isProducts) {
} else if (isProducts && !isComparison) {
// console.log('✅ Using PRODUCTS data');
// 상품별 판매량
columns = ['product_name', 'total_sold', 'revenue'];
rows = [
@@ -328,7 +446,26 @@ function generateSampleQueryResult(query: string): QueryResult {
{ 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 = [

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

@@ -1,100 +1,87 @@
'use client';
import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import { ChartConfig } from '../types';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
interface BarChartComponentProps {
data: any[];
config: ChartConfig;
config: any;
width?: number;
height?: number;
}
/**
* 바 차트 컴포넌트
* - Recharts BarChart 사용
* - 설정 가능한 색상, 축, 범례
* 바 차트 컴포넌트 (Recharts SimpleBarChart 기반)
* - 실제 데이터를 받아서 단순하게 표시
* - 복잡한 변환 로직 없음
*/
export function BarChartComponent({ data, config, width = 250, height = 200 }: BarChartComponentProps) {
const {
xAxis = 'x',
yAxis = 'y',
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'],
title,
showLegend = true
} = config;
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
// });
// Y축에 해당하는 모든 키 찾기 (그룹핑된 데이터의 경우)
const yKeys = data.length > 0
? Object.keys(data[0]).filter(key => key !== xAxis && typeof data[0][key] === 'number')
: [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 (
<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"
<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}
/>
<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}
fill={colors[index % colors.length]}
radius={[2, 2, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
))}
</BarChart>
</ResponsiveContainer>
);
}

View File

@@ -5,6 +5,10 @@ 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;
@@ -14,12 +18,20 @@ interface ChartRendererProps {
}
/**
* 차트 렌더러 컴포넌트
* - 차트 타입에 따라 적절한 차트 컴포넌트를 렌더링
* - 데이터 변환 및 에러 처리
* 차트 렌더러 컴포넌트 (단순 버전)
* - 데이터를 받아서 차트에 그대로 전달
* - 복잡한 변환 로직 제거
*/
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">
@@ -32,27 +44,33 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
);
}
// 데이터 변환
const chartData = transformData(data, element.chartConfig);
// 에러가 있는 경우
if (chartData.length === 0) {
// 데이터가 비어있으면
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>
</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, // 헤더 높이 고려
width: width - 20,
height: height - 60,
};
// 차트 타입에 따른 렌더링
@@ -63,6 +81,14 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
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">
@@ -74,122 +100,3 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
);
}
}
/**
* 쿼리 결과를 차트 데이터로 변환
*/
function transformData(queryResult: QueryResult, config: any) {
try {
const { xAxis, yAxis, groupBy, aggregation = 'sum' } = config;
if (!queryResult.rows || queryResult.rows.length === 0) {
return [];
}
// 그룹핑이 있는 경우
if (groupBy && groupBy !== xAxis) {
const grouped = queryResult.rows.reduce((acc, row) => {
const xValue = String(row[xAxis] || '');
const groupValue = String(row[groupBy] || '');
const yValue = Number(row[yAxis]) || 0;
if (!acc[xValue]) {
acc[xValue] = { [xAxis]: xValue };
}
if (!acc[xValue][groupValue]) {
acc[xValue][groupValue] = 0;
}
// 집계 함수 적용
switch (aggregation) {
case 'sum':
acc[xValue][groupValue] += yValue;
break;
case 'avg':
// 평균 계산을 위해 임시로 배열 저장
if (!acc[xValue][`${groupValue}_values`]) {
acc[xValue][`${groupValue}_values`] = [];
}
acc[xValue][`${groupValue}_values`].push(yValue);
break;
case 'count':
acc[xValue][groupValue] += 1;
break;
case 'max':
acc[xValue][groupValue] = Math.max(acc[xValue][groupValue], yValue);
break;
case 'min':
acc[xValue][groupValue] = Math.min(acc[xValue][groupValue], yValue);
break;
}
return acc;
}, {} as any);
// 평균 계산 후처리
if (aggregation === 'avg') {
Object.keys(grouped).forEach(xValue => {
Object.keys(grouped[xValue]).forEach(key => {
if (key.endsWith('_values')) {
const baseKey = key.replace('_values', '');
const values = grouped[xValue][key];
grouped[xValue][baseKey] = values.reduce((sum: number, val: number) => sum + val, 0) / values.length;
delete grouped[xValue][key];
}
});
});
}
return Object.values(grouped);
}
// 단순 변환 (그룹핑 없음)
const dataMap = new Map();
queryResult.rows.forEach(row => {
const xValue = String(row[xAxis] || '');
const yValue = Number(row[yAxis]) || 0;
if (!dataMap.has(xValue)) {
dataMap.set(xValue, { [xAxis]: xValue, [yAxis]: 0, count: 0 });
}
const existing = dataMap.get(xValue);
switch (aggregation) {
case 'sum':
existing[yAxis] += yValue;
break;
case 'avg':
existing[yAxis] += yValue;
existing.count += 1;
break;
case 'count':
existing[yAxis] += 1;
break;
case 'max':
existing[yAxis] = Math.max(existing[yAxis], yValue);
break;
case 'min':
existing[yAxis] = existing[yAxis] === 0 ? yValue : Math.min(existing[yAxis], yValue);
break;
}
});
// 평균 계산 후처리
if (aggregation === 'avg') {
dataMap.forEach(item => {
if (item.count > 0) {
item[yAxis] = item[yAxis] / item.count;
}
delete item.count;
});
}
return Array.from(dataMap.values()).slice(0, 50); // 최대 50개 데이터포인트
} catch (error) {
console.error('데이터 변환 오류:', error);
return [];
}
}

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

@@ -34,10 +34,11 @@ export function LineChartComponent({ data, config, width = 250, height = 200 }:
showLegend = true
} = config;
// Y축에 해당하는 모든 키 찾기 (그룹핑된 데이터의 경우)
const yKeys = data.length > 0
? Object.keys(data[0]).filter(key => key !== xAxis && typeof data[0][key] === 'number')
: [yAxis];
// 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">

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

@@ -6,3 +6,7 @@ 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

@@ -5,7 +5,7 @@
export type ElementType = 'chart' | 'widget';
export type ElementSubtype =
| 'bar' | 'pie' | 'line' // 차트 타입
| 'bar' | 'pie' | 'line' | 'area' | 'stacked-bar' | 'donut' | 'combo' // 차트 타입
| 'exchange' | 'weather'; // 위젯 타입
export interface Position {
@@ -50,13 +50,13 @@ export interface ChartDataSource {
}
export interface ChartConfig {
xAxis?: string; // X축 데이터 필드
yAxis?: string; // Y축 데이터 필드
groupBy?: string; // 그룹핑 필드
xAxis?: string; // X축 데이터 필드
yAxis?: string | string[]; // Y축 데이터 필드 (단일 또는 다중)
groupBy?: string; // 그룹핑 필드
aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min';
colors?: string[]; // 차트 색상
title?: string; // 차트 제목
showLegend?: boolean; // 범례 표시 여부
colors?: string[]; // 차트 색상
title?: string; // 차트 제목
showLegend?: boolean; // 범례 표시 여부
}
export interface QueryResult {