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

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

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

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

View File

@ -48,6 +48,7 @@
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"@types/sanitize-html": "^2.9.5", "@types/sanitize-html": "^2.9.5",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0", "@typescript-eslint/parser": "^6.14.0",
"eslint": "^8.55.0", "eslint": "^8.55.0",
@ -3544,6 +3545,13 @@
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.33", "version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",

View File

@ -66,6 +66,7 @@
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"@types/sanitize-html": "^2.9.5", "@types/sanitize-html": "^2.9.5",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0", "@typescript-eslint/parser": "^6.14.0",
"eslint": "^8.55.0", "eslint": "^8.55.0",

View File

@ -43,6 +43,7 @@ import entityReferenceRoutes from "./routes/entityReferenceRoutes";
import externalCallRoutes from "./routes/externalCallRoutes"; import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes"; import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
import dashboardRoutes from "./routes/dashboardRoutes";
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -171,6 +172,7 @@ app.use("/api/entity-reference", entityReferenceRoutes);
app.use("/api/external-calls", externalCallRoutes); app.use("/api/external-calls", externalCallRoutes);
app.use("/api/external-call-configs", externalCallConfigRoutes); app.use("/api/external-call-configs", externalCallConfigRoutes);
app.use("/api/dataflow", dataflowExecutionRoutes); app.use("/api/dataflow", dataflowExecutionRoutes);
app.use("/api/dashboards", dashboardRoutes);
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);

View File

@ -0,0 +1,436 @@
import { Response } from 'express';
import { AuthenticatedRequest } from '../middleware/authMiddleware';
import { DashboardService } from '../services/DashboardService';
import { CreateDashboardRequest, UpdateDashboardRequest, DashboardListQuery } from '../types/dashboard';
import { PostgreSQLService } from '../database/PostgreSQLService';
/**
*
* - REST API
* -
*/
export class DashboardController {
/**
*
* POST /api/dashboards
*/
async createDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: '인증이 필요합니다.'
});
return;
}
const { title, description, elements, isPublic = false, tags, category }: CreateDashboardRequest = req.body;
// 유효성 검증
if (!title || title.trim().length === 0) {
res.status(400).json({
success: false,
message: '대시보드 제목이 필요합니다.'
});
return;
}
if (!elements || !Array.isArray(elements)) {
res.status(400).json({
success: false,
message: '대시보드 요소 데이터가 필요합니다.'
});
return;
}
// 제목 길이 체크
if (title.length > 200) {
res.status(400).json({
success: false,
message: '제목은 200자를 초과할 수 없습니다.'
});
return;
}
// 설명 길이 체크
if (description && description.length > 1000) {
res.status(400).json({
success: false,
message: '설명은 1000자를 초과할 수 없습니다.'
});
return;
}
const dashboardData: CreateDashboardRequest = {
title: title.trim(),
description: description?.trim(),
isPublic,
elements,
tags,
category
};
// console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length });
const savedDashboard = await DashboardService.createDashboard(dashboardData, userId);
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
res.status(201).json({
success: true,
data: savedDashboard,
message: '대시보드가 성공적으로 생성되었습니다.'
});
} catch (error: any) {
// console.error('Dashboard creation error:', {
// message: error?.message,
// stack: error?.stack,
// error
// });
res.status(500).json({
success: false,
message: error?.message || '대시보드 생성 중 오류가 발생했습니다.',
error: process.env.NODE_ENV === 'development' ? error?.message : undefined
});
}
}
/**
*
* GET /api/dashboards
*/
async getDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
const query: DashboardListQuery = {
page: parseInt(req.query.page as string) || 1,
limit: Math.min(parseInt(req.query.limit as string) || 20, 100), // 최대 100개
search: req.query.search as string,
category: req.query.category as string,
isPublic: req.query.isPublic === 'true' ? true : req.query.isPublic === 'false' ? false : undefined,
createdBy: req.query.createdBy as string
};
// 페이지 번호 유효성 검증
if (query.page! < 1) {
res.status(400).json({
success: false,
message: '페이지 번호는 1 이상이어야 합니다.'
});
return;
}
const result = await DashboardService.getDashboards(query, userId);
res.json({
success: true,
data: result.dashboards,
pagination: result.pagination
});
} catch (error) {
// console.error('Dashboard list error:', error);
res.status(500).json({
success: false,
message: '대시보드 목록 조회 중 오류가 발생했습니다.',
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
});
}
}
/**
*
* GET /api/dashboards/:id
*/
async getDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const userId = req.user?.userId;
if (!id) {
res.status(400).json({
success: false,
message: '대시보드 ID가 필요합니다.'
});
return;
}
const dashboard = await DashboardService.getDashboardById(id, userId);
if (!dashboard) {
res.status(404).json({
success: false,
message: '대시보드를 찾을 수 없거나 접근 권한이 없습니다.'
});
return;
}
// 조회수 증가 (본인이 만든 대시보드가 아닌 경우에만)
if (userId && dashboard.createdBy !== userId) {
await DashboardService.incrementViewCount(id);
}
res.json({
success: true,
data: dashboard
});
} catch (error) {
// console.error('Dashboard get error:', error);
res.status(500).json({
success: false,
message: '대시보드 조회 중 오류가 발생했습니다.',
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
});
}
}
/**
*
* PUT /api/dashboards/:id
*/
async updateDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: '인증이 필요합니다.'
});
return;
}
if (!id) {
res.status(400).json({
success: false,
message: '대시보드 ID가 필요합니다.'
});
return;
}
const updateData: UpdateDashboardRequest = req.body;
// 유효성 검증
if (updateData.title !== undefined) {
if (typeof updateData.title !== 'string' || updateData.title.trim().length === 0) {
res.status(400).json({
success: false,
message: '올바른 제목을 입력해주세요.'
});
return;
}
if (updateData.title.length > 200) {
res.status(400).json({
success: false,
message: '제목은 200자를 초과할 수 없습니다.'
});
return;
}
updateData.title = updateData.title.trim();
}
if (updateData.description !== undefined && updateData.description && updateData.description.length > 1000) {
res.status(400).json({
success: false,
message: '설명은 1000자를 초과할 수 없습니다.'
});
return;
}
const updatedDashboard = await DashboardService.updateDashboard(id, updateData, userId);
if (!updatedDashboard) {
res.status(404).json({
success: false,
message: '대시보드를 찾을 수 없거나 수정 권한이 없습니다.'
});
return;
}
res.json({
success: true,
data: updatedDashboard,
message: '대시보드가 성공적으로 수정되었습니다.'
});
} catch (error) {
// console.error('Dashboard update error:', error);
if ((error as Error).message.includes('권한이 없습니다')) {
res.status(403).json({
success: false,
message: (error as Error).message
});
return;
}
res.status(500).json({
success: false,
message: '대시보드 수정 중 오류가 발생했습니다.',
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
});
}
}
/**
*
* DELETE /api/dashboards/:id
*/
async deleteDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params;
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: '인증이 필요합니다.'
});
return;
}
if (!id) {
res.status(400).json({
success: false,
message: '대시보드 ID가 필요합니다.'
});
return;
}
const deleted = await DashboardService.deleteDashboard(id, userId);
if (!deleted) {
res.status(404).json({
success: false,
message: '대시보드를 찾을 수 없거나 삭제 권한이 없습니다.'
});
return;
}
res.json({
success: true,
message: '대시보드가 성공적으로 삭제되었습니다.'
});
} catch (error) {
// console.error('Dashboard delete error:', error);
res.status(500).json({
success: false,
message: '대시보드 삭제 중 오류가 발생했습니다.',
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
});
}
}
/**
*
* GET /api/dashboards/my
*/
async getMyDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
message: '인증이 필요합니다.'
});
return;
}
const query: DashboardListQuery = {
page: parseInt(req.query.page as string) || 1,
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
search: req.query.search as string,
category: req.query.category as string,
createdBy: userId // 본인이 만든 대시보드만
};
const result = await DashboardService.getDashboards(query, userId);
res.json({
success: true,
data: result.dashboards,
pagination: result.pagination
});
} catch (error) {
// console.error('My dashboards error:', error);
res.status(500).json({
success: false,
message: '내 대시보드 목록 조회 중 오류가 발생했습니다.',
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
});
}
}
/**
*
* POST /api/dashboards/execute-query
*/
async executeQuery(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
// 개발용으로 인증 체크 제거
// const userId = req.user?.userId;
// if (!userId) {
// res.status(401).json({
// success: false,
// message: '인증이 필요합니다.'
// });
// return;
// }
const { query } = req.body;
// 유효성 검증
if (!query || typeof query !== 'string' || query.trim().length === 0) {
res.status(400).json({
success: false,
message: '쿼리가 필요합니다.'
});
return;
}
// SQL 인젝션 방지를 위한 기본적인 검증
const trimmedQuery = query.trim().toLowerCase();
if (!trimmedQuery.startsWith('select')) {
res.status(400).json({
success: false,
message: 'SELECT 쿼리만 허용됩니다.'
});
return;
}
// 쿼리 실행
const result = await PostgreSQLService.query(query.trim());
// 결과 변환
const columns = result.fields?.map(field => field.name) || [];
const rows = result.rows || [];
res.status(200).json({
success: true,
data: {
columns,
rows,
rowCount: rows.length
},
message: '쿼리가 성공적으로 실행되었습니다.'
});
} catch (error) {
// console.error('Query execution error:', error);
res.status(500).json({
success: false,
message: '쿼리 실행 중 오류가 발생했습니다.',
error: process.env.NODE_ENV === 'development' ? (error as Error).message : '쿼리 실행 오류'
});
}
}
}

View File

@ -0,0 +1,127 @@
import { Pool, PoolClient, QueryResult } from 'pg';
import config from '../config/environment';
/**
* PostgreSQL Raw Query
* Prisma pg
*/
export class PostgreSQLService {
private static pool: Pool;
/**
*
*/
static initialize() {
if (!this.pool) {
this.pool = new Pool({
connectionString: config.databaseUrl,
max: 20, // 최대 연결 수
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// 연결 풀 이벤트 리스너
this.pool.on('connect', () => {
console.log('🔗 PostgreSQL 연결 성공');
});
this.pool.on('error', (err) => {
console.error('❌ PostgreSQL 연결 오류:', err);
});
}
}
/**
*
*/
static getPool(): Pool {
if (!this.pool) {
this.initialize();
}
return this.pool;
}
/**
*
*/
static async query(text: string, params?: any[]): Promise<QueryResult> {
const pool = this.getPool();
const start = Date.now();
try {
const result = await pool.query(text, params);
const duration = Date.now() - start;
if (config.debug) {
console.log('🔍 Query executed:', { text, duration: `${duration}ms`, rows: result.rowCount });
}
return result;
} catch (error) {
console.error('❌ Query error:', { text, params, error });
throw error;
}
}
/**
*
*/
static async transaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T> {
const pool = this.getPool();
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
*
*/
static async testConnection(): Promise<boolean> {
try {
const result = await this.query('SELECT NOW() as current_time');
console.log('✅ PostgreSQL 연결 테스트 성공:', result.rows[0]);
return true;
} catch (error) {
console.error('❌ PostgreSQL 연결 테스트 실패:', error);
return false;
}
}
/**
*
*/
static async close(): Promise<void> {
if (this.pool) {
await this.pool.end();
console.log('🔒 PostgreSQL 연결 풀 종료');
}
}
}
// 애플리케이션 시작 시 초기화
PostgreSQLService.initialize();
// 프로세스 종료 시 연결 정리
process.on('SIGINT', async () => {
await PostgreSQLService.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
await PostgreSQLService.close();
process.exit(0);
});
process.on('beforeExit', async () => {
await PostgreSQLService.close();
});

View File

@ -0,0 +1,37 @@
import { Router } from 'express';
import { DashboardController } from '../controllers/DashboardController';
import { authenticateToken } from '../middleware/authMiddleware';
const router = Router();
const dashboardController = new DashboardController();
/**
* API
*
* ,
*
*/
// 공개 대시보드 목록 조회 (인증 불필요)
router.get('/public', dashboardController.getDashboards.bind(dashboardController));
// 공개 대시보드 상세 조회 (인증 불필요)
router.get('/public/:id', dashboardController.getDashboard.bind(dashboardController));
// 쿼리 실행 (인증 불필요 - 개발용)
router.post('/execute-query', dashboardController.executeQuery.bind(dashboardController));
// 인증이 필요한 라우트들
router.use(authenticateToken);
// 내 대시보드 목록 조회
router.get('/my', dashboardController.getMyDashboards.bind(dashboardController));
// 대시보드 CRUD
router.post('/', dashboardController.createDashboard.bind(dashboardController));
router.get('/', dashboardController.getDashboards.bind(dashboardController));
router.get('/:id', dashboardController.getDashboard.bind(dashboardController));
router.put('/:id', dashboardController.updateDashboard.bind(dashboardController));
router.delete('/:id', dashboardController.deleteDashboard.bind(dashboardController));
export default router;

View File

@ -0,0 +1,534 @@
import { v4 as uuidv4 } from 'uuid';
import { PostgreSQLService } from '../database/PostgreSQLService';
import {
Dashboard,
DashboardElement,
CreateDashboardRequest,
UpdateDashboardRequest,
DashboardListQuery
} from '../types/dashboard';
/**
* - Raw Query
* PostgreSQL CRUD
*/
export class DashboardService {
/**
*
*/
static async createDashboard(data: CreateDashboardRequest, userId: string): Promise<Dashboard> {
const dashboardId = uuidv4();
const now = new Date();
try {
// 트랜잭션으로 대시보드와 요소들을 함께 생성
const result = await PostgreSQLService.transaction(async (client) => {
// 1. 대시보드 메인 정보 저장
await client.query(`
INSERT INTO dashboards (
id, title, description, is_public, created_by,
created_at, updated_at, tags, category, view_count
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`, [
dashboardId,
data.title,
data.description || null,
data.isPublic || false,
userId,
now,
now,
JSON.stringify(data.tags || []),
data.category || null,
0
]);
// 2. 대시보드 요소들 저장
if (data.elements && data.elements.length > 0) {
for (let i = 0; i < data.elements.length; i++) {
const element = data.elements[i];
const elementId = uuidv4(); // 항상 새로운 UUID 생성
await client.query(`
INSERT INTO dashboard_elements (
id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height,
title, content, data_source_config, chart_config,
display_order, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
`, [
elementId,
dashboardId,
element.type,
element.subtype,
element.position.x,
element.position.y,
element.size.width,
element.size.height,
element.title,
element.content || null,
JSON.stringify(element.dataSource || {}),
JSON.stringify(element.chartConfig || {}),
i,
now,
now
]);
}
}
return dashboardId;
});
// 생성된 대시보드 반환
try {
const dashboard = await this.getDashboardById(dashboardId, userId);
if (!dashboard) {
console.error('대시보드 생성은 성공했으나 조회에 실패:', dashboardId);
// 생성은 성공했으므로 기본 정보만이라도 반환
return {
id: dashboardId,
title: data.title,
description: data.description,
thumbnailUrl: undefined,
isPublic: data.isPublic || false,
createdBy: userId,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
tags: data.tags || [],
category: data.category,
viewCount: 0,
elements: data.elements || []
};
}
return dashboard;
} catch (fetchError) {
console.error('생성된 대시보드 조회 중 오류:', fetchError);
// 생성은 성공했으므로 기본 정보 반환
return {
id: dashboardId,
title: data.title,
description: data.description,
thumbnailUrl: undefined,
isPublic: data.isPublic || false,
createdBy: userId,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
tags: data.tags || [],
category: data.category,
viewCount: 0,
elements: data.elements || []
};
}
} catch (error) {
console.error('Dashboard creation error:', error);
throw error;
}
}
/**
*
*/
static async getDashboards(query: DashboardListQuery, userId?: string) {
const {
page = 1,
limit = 20,
search,
category,
isPublic,
createdBy
} = query;
const offset = (page - 1) * limit;
try {
// 기본 WHERE 조건
let whereConditions = ['d.deleted_at IS NULL'];
let params: any[] = [];
let paramIndex = 1;
// 권한 필터링
if (userId) {
whereConditions.push(`(d.created_by = $${paramIndex} OR d.is_public = true)`);
params.push(userId);
paramIndex++;
} else {
whereConditions.push('d.is_public = true');
}
// 검색 조건
if (search) {
whereConditions.push(`(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})`);
params.push(`%${search}%`, `%${search}%`);
paramIndex += 2;
}
// 카테고리 필터
if (category) {
whereConditions.push(`d.category = $${paramIndex}`);
params.push(category);
paramIndex++;
}
// 공개/비공개 필터
if (typeof isPublic === 'boolean') {
whereConditions.push(`d.is_public = $${paramIndex}`);
params.push(isPublic);
paramIndex++;
}
// 작성자 필터
if (createdBy) {
whereConditions.push(`d.created_by = $${paramIndex}`);
params.push(createdBy);
paramIndex++;
}
const whereClause = whereConditions.join(' AND ');
// 대시보드 목록 조회 (users 테이블 조인 제거)
const dashboardQuery = `
SELECT
d.id,
d.title,
d.description,
d.thumbnail_url,
d.is_public,
d.created_by,
d.created_at,
d.updated_at,
d.tags,
d.category,
d.view_count,
COUNT(de.id) as elements_count
FROM dashboards d
LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id
WHERE ${whereClause}
GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public,
d.created_by, d.created_at, d.updated_at, d.tags, d.category,
d.view_count
ORDER BY d.updated_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const dashboardResult = await PostgreSQLService.query(
dashboardQuery,
[...params, limit, offset]
);
// 전체 개수 조회
const countQuery = `
SELECT COUNT(DISTINCT d.id) as total
FROM dashboards d
WHERE ${whereClause}
`;
const countResult = await PostgreSQLService.query(countQuery, params);
const total = parseInt(countResult.rows[0]?.total || '0');
return {
dashboards: dashboardResult.rows.map((row: any) => ({
id: row.id,
title: row.title,
description: row.description,
thumbnailUrl: row.thumbnail_url,
isPublic: row.is_public,
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at,
tags: JSON.parse(row.tags || '[]'),
category: row.category,
viewCount: parseInt(row.view_count || '0'),
elementsCount: parseInt(row.elements_count || '0')
})),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
} catch (error) {
console.error('Dashboard list error:', error);
throw error;
}
}
/**
*
*/
static async getDashboardById(dashboardId: string, userId?: string): Promise<Dashboard | null> {
try {
// 1. 대시보드 기본 정보 조회 (권한 체크 포함)
let dashboardQuery: string;
let dashboardParams: any[];
if (userId) {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND (d.created_by = $2 OR d.is_public = true)
`;
dashboardParams = [dashboardId, userId];
} else {
dashboardQuery = `
SELECT d.*
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
AND d.is_public = true
`;
dashboardParams = [dashboardId];
}
const dashboardResult = await PostgreSQLService.query(dashboardQuery, dashboardParams);
if (dashboardResult.rows.length === 0) {
return null;
}
const dashboard = dashboardResult.rows[0];
// 2. 대시보드 요소들 조회
const elementsQuery = `
SELECT * FROM dashboard_elements
WHERE dashboard_id = $1
ORDER BY display_order ASC
`;
const elementsResult = await PostgreSQLService.query(elementsQuery, [dashboardId]);
// 3. 요소 데이터 변환
const elements: DashboardElement[] = elementsResult.rows.map((row: any) => ({
id: row.id,
type: row.element_type,
subtype: row.element_subtype,
position: {
x: row.position_x,
y: row.position_y
},
size: {
width: row.width,
height: row.height
},
title: row.title,
content: row.content,
dataSource: JSON.parse(row.data_source_config || '{}'),
chartConfig: JSON.parse(row.chart_config || '{}')
}));
return {
id: dashboard.id,
title: dashboard.title,
description: dashboard.description,
thumbnailUrl: dashboard.thumbnail_url,
isPublic: dashboard.is_public,
createdBy: dashboard.created_by,
createdAt: dashboard.created_at,
updatedAt: dashboard.updated_at,
tags: JSON.parse(dashboard.tags || '[]'),
category: dashboard.category,
viewCount: parseInt(dashboard.view_count || '0'),
elements
};
} catch (error) {
console.error('Dashboard get error:', error);
throw error;
}
}
/**
*
*/
static async updateDashboard(
dashboardId: string,
data: UpdateDashboardRequest,
userId: string
): Promise<Dashboard | null> {
try {
const result = await PostgreSQLService.transaction(async (client) => {
// 권한 체크
const authCheckResult = await client.query(`
SELECT id FROM dashboards
WHERE id = $1 AND created_by = $2 AND deleted_at IS NULL
`, [dashboardId, userId]);
if (authCheckResult.rows.length === 0) {
throw new Error('대시보드를 찾을 수 없거나 수정 권한이 없습니다.');
}
const now = new Date();
// 1. 대시보드 메인 정보 업데이트
const updateFields: string[] = [];
const updateParams: any[] = [];
let paramIndex = 1;
if (data.title !== undefined) {
updateFields.push(`title = $${paramIndex}`);
updateParams.push(data.title);
paramIndex++;
}
if (data.description !== undefined) {
updateFields.push(`description = $${paramIndex}`);
updateParams.push(data.description);
paramIndex++;
}
if (data.isPublic !== undefined) {
updateFields.push(`is_public = $${paramIndex}`);
updateParams.push(data.isPublic);
paramIndex++;
}
if (data.tags !== undefined) {
updateFields.push(`tags = $${paramIndex}`);
updateParams.push(JSON.stringify(data.tags));
paramIndex++;
}
if (data.category !== undefined) {
updateFields.push(`category = $${paramIndex}`);
updateParams.push(data.category);
paramIndex++;
}
updateFields.push(`updated_at = $${paramIndex}`);
updateParams.push(now);
paramIndex++;
updateParams.push(dashboardId);
if (updateFields.length > 1) { // updated_at 외에 다른 필드가 있는 경우
const updateQuery = `
UPDATE dashboards
SET ${updateFields.join(', ')}
WHERE id = $${paramIndex}
`;
await client.query(updateQuery, updateParams);
}
// 2. 요소 업데이트 (있는 경우)
if (data.elements) {
// 기존 요소들 삭제
await client.query(`
DELETE FROM dashboard_elements WHERE dashboard_id = $1
`, [dashboardId]);
// 새 요소들 추가
for (let i = 0; i < data.elements.length; i++) {
const element = data.elements[i];
const elementId = uuidv4();
await client.query(`
INSERT INTO dashboard_elements (
id, dashboard_id, element_type, element_subtype,
position_x, position_y, width, height,
title, content, data_source_config, chart_config,
display_order, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
`, [
elementId,
dashboardId,
element.type,
element.subtype,
element.position.x,
element.position.y,
element.size.width,
element.size.height,
element.title,
element.content || null,
JSON.stringify(element.dataSource || {}),
JSON.stringify(element.chartConfig || {}),
i,
now,
now
]);
}
}
return dashboardId;
});
// 업데이트된 대시보드 반환
return await this.getDashboardById(dashboardId, userId);
} catch (error) {
console.error('Dashboard update error:', error);
throw error;
}
}
/**
* ( )
*/
static async deleteDashboard(dashboardId: string, userId: string): Promise<boolean> {
try {
const now = new Date();
const result = await PostgreSQLService.query(`
UPDATE dashboards
SET deleted_at = $1, updated_at = $2
WHERE id = $3 AND created_by = $4 AND deleted_at IS NULL
`, [now, now, dashboardId, userId]);
return (result.rowCount || 0) > 0;
} catch (error) {
console.error('Dashboard delete error:', error);
throw error;
}
}
/**
*
*/
static async incrementViewCount(dashboardId: string): Promise<void> {
try {
await PostgreSQLService.query(`
UPDATE dashboards
SET view_count = view_count + 1
WHERE id = $1 AND deleted_at IS NULL
`, [dashboardId]);
} catch (error) {
console.error('View count increment error:', error);
// 조회수 증가 실패는 치명적이지 않으므로 에러를 던지지 않음
}
}
/**
*
*/
static async checkUserPermission(
dashboardId: string,
userId: string,
requiredPermission: 'view' | 'edit' | 'admin' = 'view'
): Promise<boolean> {
try {
const result = await PostgreSQLService.query(`
SELECT
CASE
WHEN d.created_by = $2 THEN 'admin'
WHEN d.is_public = true THEN 'view'
ELSE 'none'
END as permission
FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL
`, [dashboardId, userId]);
if (result.rows.length === 0) {
return false;
}
const userPermission = result.rows[0].permission;
// 권한 레벨 체크
const permissionLevels = { 'view': 1, 'edit': 2, 'admin': 3 };
const userLevel = permissionLevels[userPermission as keyof typeof permissionLevels] || 0;
const requiredLevel = permissionLevels[requiredPermission];
return userLevel >= requiredLevel;
} catch (error) {
console.error('Permission check error:', error);
return false;
}
}
}

View File

@ -0,0 +1,90 @@
/**
*
*/
export interface DashboardElement {
id: string;
type: 'chart' | 'widget';
subtype: 'bar' | 'pie' | 'line' | 'exchange' | 'weather';
position: {
x: number;
y: number;
};
size: {
width: number;
height: number;
};
title: string;
content?: string;
dataSource?: {
type: 'api' | 'database' | 'static';
endpoint?: string;
query?: string;
refreshInterval?: number;
filters?: any[];
lastExecuted?: string;
};
chartConfig?: {
xAxis?: string;
yAxis?: string;
groupBy?: string;
aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min';
colors?: string[];
title?: string;
showLegend?: boolean;
};
}
export interface Dashboard {
id: string;
title: string;
description?: string;
thumbnailUrl?: string;
isPublic: boolean;
createdBy: string;
createdAt: string;
updatedAt: string;
deletedAt?: string;
tags?: string[];
category?: string;
viewCount: number;
elements: DashboardElement[];
}
export interface CreateDashboardRequest {
title: string;
description?: string;
isPublic?: boolean;
elements: DashboardElement[];
tags?: string[];
category?: string;
}
export interface UpdateDashboardRequest {
title?: string;
description?: string;
isPublic?: boolean;
elements?: DashboardElement[];
tags?: string[];
category?: string;
}
export interface DashboardListQuery {
page?: number;
limit?: number;
search?: string;
category?: string;
isPublic?: boolean;
createdBy?: string;
}
export interface DashboardShare {
id: string;
dashboardId: string;
sharedWithUser?: string;
sharedWithRole?: string;
permissionLevel: 'view' | 'edit' | 'admin';
createdBy: string;
createdAt: string;
expiresAt?: string;
}

View File

@ -0,0 +1,286 @@
'use client';
import React, { useState, useEffect } from 'react';
import { DashboardViewer } from '@/components/dashboard/DashboardViewer';
import { DashboardElement } from '@/components/admin/dashboard/types';
interface DashboardViewPageProps {
params: {
dashboardId: string;
};
}
/**
*
* -
* -
* -
*/
export default function DashboardViewPage({ params }: DashboardViewPageProps) {
const [dashboard, setDashboard] = useState<{
id: string;
title: string;
description?: string;
elements: DashboardElement[];
createdAt: string;
updatedAt: string;
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 대시보드 데이터 로딩
useEffect(() => {
loadDashboard();
}, [params.dashboardId]);
const loadDashboard = async () => {
setIsLoading(true);
setError(null);
try {
// 실제 API 호출 시도
const { dashboardApi } = await import('@/lib/api/dashboard');
try {
const dashboardData = await dashboardApi.getDashboard(params.dashboardId);
setDashboard(dashboardData);
} catch (apiError) {
console.warn('API 호출 실패, 로컬 스토리지 확인:', apiError);
// API 실패 시 로컬 스토리지에서 찾기
const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]');
const savedDashboard = savedDashboards.find((d: any) => d.id === params.dashboardId);
if (savedDashboard) {
setDashboard(savedDashboard);
} else {
// 로컬에도 없으면 샘플 데이터 사용
const sampleDashboard = generateSampleDashboard(params.dashboardId);
setDashboard(sampleDashboard);
}
}
} catch (err) {
setError('대시보드를 불러오는 중 오류가 발생했습니다.');
console.error('Dashboard loading error:', err);
} finally {
setIsLoading(false);
}
};
// 로딩 상태
if (isLoading) {
return (
<div className="h-screen flex 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>
);
}
// 에러 상태
if (error || !dashboard) {
return (
<div className="h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-6xl mb-4">😞</div>
<div className="text-xl font-medium text-gray-700 mb-2">
{error || '대시보드를 찾을 수 없습니다'}
</div>
<div className="text-sm text-gray-500 mb-4">
ID: {params.dashboardId}
</div>
<button
onClick={loadDashboard}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
</button>
</div>
</div>
);
}
return (
<div className="h-screen bg-gray-50">
{/* 대시보드 헤더 */}
<div className="bg-white border-b border-gray-200 px-6 py-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-800">{dashboard.title}</h1>
{dashboard.description && (
<p className="text-sm text-gray-600 mt-1">{dashboard.description}</p>
)}
</div>
<div className="flex items-center gap-3">
{/* 새로고침 버튼 */}
<button
onClick={loadDashboard}
className="px-3 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg hover:bg-gray-50"
title="새로고침"
>
🔄
</button>
{/* 전체화면 버튼 */}
<button
onClick={() => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen();
}
}}
className="px-3 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg hover:bg-gray-50"
title="전체화면"
>
</button>
{/* 편집 버튼 */}
<button
onClick={() => {
window.open(`/admin/dashboard?load=${params.dashboardId}`, '_blank');
}}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
</button>
</div>
</div>
{/* 메타 정보 */}
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span>: {new Date(dashboard.createdAt).toLocaleString()}</span>
<span>: {new Date(dashboard.updatedAt).toLocaleString()}</span>
<span>: {dashboard.elements.length}</span>
</div>
</div>
{/* 대시보드 뷰어 */}
<div className="h-[calc(100vh-120px)]">
<DashboardViewer
elements={dashboard.elements}
dashboardId={dashboard.id}
/>
</div>
</div>
);
}
/**
*
*/
function generateSampleDashboard(dashboardId: string) {
const dashboards: Record<string, any> = {
'sales-overview': {
id: 'sales-overview',
title: '📊 매출 현황 대시보드',
description: '월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.',
elements: [
{
id: 'chart-1',
type: 'chart',
subtype: 'bar',
position: { x: 20, y: 20 },
size: { width: 400, height: 300 },
title: '📊 월별 매출 추이',
content: '월별 매출 데이터',
dataSource: {
type: 'database',
query: 'SELECT month, sales FROM monthly_sales',
refreshInterval: 30000
},
chartConfig: {
xAxis: 'month',
yAxis: 'sales',
title: '월별 매출 추이',
colors: ['#3B82F6', '#EF4444', '#10B981']
}
},
{
id: 'chart-2',
type: 'chart',
subtype: 'pie',
position: { x: 450, y: 20 },
size: { width: 350, height: 300 },
title: '🥧 상품별 판매 비율',
content: '상품별 판매 데이터',
dataSource: {
type: 'database',
query: 'SELECT product_name, total_sold FROM product_sales',
refreshInterval: 60000
},
chartConfig: {
xAxis: 'product_name',
yAxis: 'total_sold',
title: '상품별 판매 비율',
colors: ['#8B5CF6', '#EC4899', '#06B6D4', '#84CC16']
}
},
{
id: 'chart-3',
type: 'chart',
subtype: 'line',
position: { x: 20, y: 350 },
size: { width: 780, height: 250 },
title: '📈 사용자 가입 추이',
content: '사용자 가입 데이터',
dataSource: {
type: 'database',
query: 'SELECT week, new_users FROM user_growth',
refreshInterval: 300000
},
chartConfig: {
xAxis: 'week',
yAxis: 'new_users',
title: '주간 신규 사용자 가입 추이',
colors: ['#10B981']
}
}
],
createdAt: '2024-09-30T10:00:00Z',
updatedAt: '2024-09-30T14:30:00Z'
},
'user-analytics': {
id: 'user-analytics',
title: '👥 사용자 분석 대시보드',
description: '사용자 행동 패턴 및 가입 추이 분석',
elements: [
{
id: 'chart-4',
type: 'chart',
subtype: 'line',
position: { x: 20, y: 20 },
size: { width: 500, height: 300 },
title: '📈 일일 활성 사용자',
content: '사용자 활동 데이터',
dataSource: {
type: 'database',
query: 'SELECT date, active_users FROM daily_active_users',
refreshInterval: 60000
},
chartConfig: {
xAxis: 'date',
yAxis: 'active_users',
title: '일일 활성 사용자 추이'
}
}
],
createdAt: '2024-09-29T15:00:00Z',
updatedAt: '2024-09-30T09:15:00Z'
}
};
return dashboards[dashboardId] || {
id: dashboardId,
title: `대시보드 ${dashboardId}`,
description: '샘플 대시보드입니다.',
elements: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
}

View File

@ -1,269 +1,286 @@
"use client"; 'use client';
import { useState } from "react"; import React, { useState, useEffect } from 'react';
import { Button } from "@/components/ui/button"; import Link from 'next/link';
import { Card, CardContent } from "@/components/ui/card";
import {
Home,
FileText,
Users,
Settings,
Package,
BarChart3,
LogOut,
Menu,
X,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
interface UserInfo { interface Dashboard {
userId: string;
userName: string;
deptName: string;
email: string;
}
interface MenuItem {
id: string; id: string;
title: string; title: string;
icon: any; description?: string;
children?: MenuItem[]; thumbnail?: string;
elementsCount: number;
createdAt: string;
updatedAt: string;
isPublic: boolean;
} }
const menuItems: MenuItem[] = [ /**
{ *
id: "dashboard", * -
title: "대시보드", * -
icon: Home, * -
}, */
{ export default function DashboardListPage() {
id: "project", const [dashboards, setDashboards] = useState<Dashboard[]>([]);
title: "프로젝트 관리", const [isLoading, setIsLoading] = useState(true);
icon: FileText, const [searchTerm, setSearchTerm] = useState('');
children: [
{ id: "project-list", title: "프로젝트 목록", icon: FileText },
{ id: "project-concept", title: "프로젝트 컨셉", icon: FileText },
{ id: "project-planning", title: "프로젝트 기획", icon: FileText },
],
},
{
id: "part",
title: "부품 관리",
icon: Package,
children: [
{ id: "part-list", title: "부품 목록", icon: Package },
{ id: "part-bom", title: "BOM 관리", icon: Package },
{ id: "part-inventory", title: "재고 관리", icon: Package },
],
},
{
id: "user",
title: "사용자 관리",
icon: Users,
children: [
{ id: "user-list", title: "사용자 목록", icon: Users },
{ id: "user-auth", title: "권한 관리", icon: Users },
],
},
{
id: "report",
title: "보고서",
icon: BarChart3,
children: [
{ id: "report-project", title: "프로젝트 보고서", icon: BarChart3 },
{ id: "report-cost", title: "비용 보고서", icon: BarChart3 },
],
},
{
id: "settings",
title: "시스템 설정",
icon: Settings,
children: [
{ id: "settings-system", title: "시스템 설정", icon: Settings },
{ id: "settings-common", title: "공통 코드", icon: Settings },
],
},
];
export default function DashboardPage() { // 대시보드 목록 로딩
const { user, logout } = useAuth(); useEffect(() => {
const [sidebarOpen, setSidebarOpen] = useState(true); loadDashboards();
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set(["dashboard"])); }, []);
const [selectedMenu, setSelectedMenu] = useState("dashboard");
const [currentContent, setCurrentContent] = useState<string>("dashboard");
const handleLogout = async () => { const loadDashboards = async () => {
await logout(); setIsLoading(true);
};
const toggleMenu = (menuId: string) => { try {
const newExpanded = new Set(expandedMenus); // 실제 API 호출 시도
if (newExpanded.has(menuId)) { const { dashboardApi } = await import('@/lib/api/dashboard');
newExpanded.delete(menuId);
} else {
newExpanded.add(menuId);
}
setExpandedMenus(newExpanded);
};
const handleMenuClick = (menuId: string) => { try {
setSelectedMenu(menuId); const result = await dashboardApi.getDashboards({ page: 1, limit: 50 });
setCurrentContent(menuId);
};
const renderContent = () => { // API에서 가져온 대시보드들을 Dashboard 형식으로 변환
switch (currentContent) { const apiDashboards: Dashboard[] = result.dashboards.map((dashboard: any) => ({
case "dashboard": id: dashboard.id,
return ( title: dashboard.title,
<div className="space-y-6"> description: dashboard.description,
<h1 className="text-3xl font-bold text-slate-900"></h1> elementsCount: dashboard.elementsCount || dashboard.elements?.length || 0,
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"> createdAt: dashboard.createdAt,
<Card> updatedAt: dashboard.updatedAt,
<CardContent className="p-6"> isPublic: dashboard.isPublic,
<div className="flex items-center"> creatorName: dashboard.creatorName
<FileText className="h-8 w-8 text-blue-600" /> }));
<div className="ml-4">
<p className="text-sm font-medium text-slate-600"> </p> setDashboards(apiDashboards);
<p className="text-2xl font-bold text-slate-900">24</p>
</div> } catch (apiError) {
</div> console.warn('API 호출 실패, 로컬 스토리지 및 샘플 데이터 사용:', apiError);
</CardContent>
</Card> // API 실패 시 로컬 스토리지 + 샘플 데이터 사용
<Card> const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]');
<CardContent className="p-6">
<div className="flex items-center"> // 샘플 대시보드들
<Package className="h-8 w-8 text-green-600" /> const sampleDashboards: Dashboard[] = [
<div className="ml-4"> {
<p className="text-sm font-medium text-slate-600"> </p> id: 'sales-overview',
<p className="text-2xl font-bold text-slate-900">1,247</p> title: '📊 매출 현황 대시보드',
</div> description: '월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.',
</div> elementsCount: 3,
</CardContent> createdAt: '2024-09-30T10:00:00Z',
</Card> updatedAt: '2024-09-30T14:30:00Z',
<Card> isPublic: true
<CardContent className="p-6"> },
<div className="flex items-center"> {
<Users className="h-8 w-8 text-purple-600" /> id: 'user-analytics',
<div className="ml-4"> title: '👥 사용자 분석 대시보드',
<p className="text-sm font-medium text-slate-600"> </p> description: '사용자 행동 패턴 및 가입 추이 분석',
<p className="text-2xl font-bold text-slate-900">89</p> elementsCount: 1,
</div> createdAt: '2024-09-29T15:00:00Z',
</div> updatedAt: '2024-09-30T09:15:00Z',
</CardContent> isPublic: false
</Card> },
<Card> {
<CardContent className="p-6"> id: 'inventory-status',
<div className="flex items-center"> title: '📦 재고 현황 대시보드',
<BarChart3 className="h-8 w-8 text-orange-600" /> description: '실시간 재고 현황 및 입출고 내역',
<div className="ml-4"> elementsCount: 4,
<p className="text-sm font-medium text-slate-600"> </p> createdAt: '2024-09-28T11:30:00Z',
<p className="text-2xl font-bold text-slate-900">12</p> updatedAt: '2024-09-29T16:45:00Z',
</div> isPublic: true
</div> }
</CardContent> ];
</Card>
</div> // 저장된 대시보드를 Dashboard 형식으로 변환
<Card> const userDashboards: Dashboard[] = savedDashboards.map((dashboard: any) => ({
<CardContent className="p-6"> id: dashboard.id,
<h2 className="mb-4 text-xl font-semibold"> </h2> title: dashboard.title,
<div className="space-y-4"> description: dashboard.description,
<div className="flex items-center space-x-4"> elementsCount: dashboard.elements?.length || 0,
<div className="h-2 w-2 rounded-full bg-blue-500"></div> createdAt: dashboard.createdAt,
<span className="text-sm text-slate-600"> '제품 A' </span> updatedAt: dashboard.updatedAt,
<span className="text-xs text-slate-400">2 </span> isPublic: false // 사용자가 만든 대시보드는 기본적으로 비공개
</div> }));
<div className="flex items-center space-x-4">
<div className="h-2 w-2 rounded-full bg-green-500"></div> // 사용자 대시보드를 맨 앞에 배치
<span className="text-sm text-slate-600"> 'PCB-001' </span> setDashboards([...userDashboards, ...sampleDashboards]);
<span className="text-xs text-slate-400">4 </span> }
</div> } catch (error) {
<div className="flex items-center space-x-4"> console.error('Dashboard loading error:', error);
<div className="h-2 w-2 rounded-full bg-orange-500"></div> } finally {
<span className="text-sm text-slate-600"> '김개발' </span> setIsLoading(false);
<span className="text-xs text-slate-400">1 </span>
</div>
</div>
</CardContent>
</Card>
</div>
);
default:
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold text-slate-900">
{menuItems.find((item) => item.id === currentContent)?.title ||
menuItems.flatMap((item) => item.children || []).find((child) => child.id === currentContent)?.title ||
"페이지를 찾을 수 없습니다"}
</h1>
<Card>
<CardContent className="p-6">
<p className="text-slate-600">{currentContent} .</p>
<p className="mt-2 text-sm text-slate-400"> .</p>
</CardContent>
</Card>
</div>
);
} }
}; };
const renderMenuItem = (item: MenuItem, level: number = 0) => { // 검색 필터링
const isExpanded = expandedMenus.has(item.id); const filteredDashboards = dashboards.filter(dashboard =>
const isSelected = selectedMenu === item.id; dashboard.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
const hasChildren = item.children && item.children.length > 0; dashboard.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div key={item.id}>
<div
className={`flex cursor-pointer items-center rounded-md px-4 py-2 text-sm transition-colors ${
isSelected ? "bg-blue-600 text-white" : "text-slate-700 hover:bg-slate-100"
} ${level > 0 ? "ml-6" : ""}`}
onClick={() => {
if (hasChildren) {
toggleMenu(item.id);
} else {
handleMenuClick(item.id);
}
}}
>
<item.icon className="mr-3 h-4 w-4" />
<span className="flex-1">{item.title}</span>
{hasChildren && (isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />)}
</div>
{hasChildren && isExpanded && (
<div className="mt-1">{item.children?.map((child) => renderMenuItem(child, level + 1))}</div>
)}
</div>
);
};
return ( return (
<div className="min-h-screen bg-slate-50"> <div className="min-h-screen bg-gray-50">
{/* 헤더 */} {/* 헤더 */}
<header className="border-b border-slate-200 bg-white shadow-sm"> <div className="bg-white border-b border-gray-200">
<div className="px-6 py-4"> <div className="max-w-7xl mx-auto px-6 py-6">
<div className="flex items-center justify-between"> <div className="flex justify-between items-center">
<div className="flex items-center space-x-4"> <div>
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(!sidebarOpen)}> <h1 className="text-3xl font-bold text-gray-900">📊 </h1>
<Menu className="h-5 w-5" /> <p className="text-gray-600 mt-1"> </p>
</Button> </div>
<h1 className="text-xl font-semibold text-slate-900">PLM </h1>
<Link
href="/admin/dashboard"
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium"
>
</Link>
</div>
{/* 검색 바 */}
<div className="mt-6">
<div className="relative max-w-md">
<input
type="text"
placeholder="대시보드 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<div className="absolute left-3 top-2.5 text-gray-400">
🔍
</div>
</div> </div>
</div> </div>
</div> </div>
</header> </div>
<div className="flex"> {/* 메인 콘텐츠 */}
{/* 사이드바 */} <div className="max-w-7xl mx-auto px-6 py-8">
<aside {isLoading ? (
className={`${sidebarOpen ? "w-64" : "w-0"} overflow-hidden border-r border-slate-200 bg-white transition-all duration-300`} // 로딩 상태
> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<nav className="space-y-2 p-4">{menuItems.map((item) => renderMenuItem(item))}</nav> {[1, 2, 3, 4, 5, 6].map((i) => (
</aside> <div key={i} className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="animate-pulse">
{/* 메인 컨텐츠 */} <div className="h-4 bg-gray-200 rounded w-3/4 mb-3"></div>
<main className="flex-1 p-6">{renderContent()}</main> <div className="h-3 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-2/3 mb-4"></div>
<div className="h-32 bg-gray-200 rounded mb-4"></div>
<div className="flex justify-between">
<div className="h-3 bg-gray-200 rounded w-1/4"></div>
<div className="h-3 bg-gray-200 rounded w-1/4"></div>
</div>
</div>
</div>
))}
</div>
) : filteredDashboards.length === 0 ? (
// 빈 상태
<div className="text-center py-12">
<div className="text-6xl mb-4">📊</div>
<h3 className="text-xl font-medium text-gray-700 mb-2">
{searchTerm ? '검색 결과가 없습니다' : '아직 대시보드가 없습니다'}
</h3>
<p className="text-gray-500 mb-6">
{searchTerm
? '다른 검색어로 시도해보세요'
: '첫 번째 대시보드를 만들어보세요'}
</p>
{!searchTerm && (
<Link
href="/admin/dashboard"
className="inline-flex items-center px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium"
>
</Link>
)}
</div>
) : (
// 대시보드 그리드
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredDashboards.map((dashboard) => (
<DashboardCard key={dashboard.id} dashboard={dashboard} />
))}
</div>
)}
</div>
</div>
);
}
interface DashboardCardProps {
dashboard: Dashboard;
}
/**
*
*/
function DashboardCard({ dashboard }: DashboardCardProps) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow">
{/* 썸네일 영역 */}
<div className="h-48 bg-gradient-to-br from-blue-50 to-indigo-100 rounded-t-lg flex items-center justify-center">
<div className="text-center">
<div className="text-4xl mb-2">📊</div>
<div className="text-sm text-gray-600">{dashboard.elementsCount} </div>
</div>
</div>
{/* 카드 내용 */}
<div className="p-6">
<div className="flex justify-between items-start mb-3">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1">
{dashboard.title}
</h3>
{dashboard.isPublic ? (
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded-full">
</span>
) : (
<span className="text-xs bg-gray-100 text-gray-800 px-2 py-1 rounded-full">
</span>
)}
</div>
{dashboard.description && (
<p className="text-gray-600 text-sm mb-4 line-clamp-2">
{dashboard.description}
</p>
)}
{/* 메타 정보 */}
<div className="text-xs text-gray-500 mb-4">
<div>: {new Date(dashboard.createdAt).toLocaleDateString()}</div>
<div>: {new Date(dashboard.updatedAt).toLocaleDateString()}</div>
</div>
{/* 액션 버튼들 */}
<div className="flex gap-2">
<Link
href={`/dashboard/${dashboard.id}`}
className="flex-1 px-4 py-2 bg-blue-500 text-white text-center rounded-lg hover:bg-blue-600 text-sm font-medium"
>
</Link>
<Link
href={`/admin/dashboard?load=${dashboard.id}`}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
>
</Link>
<button
onClick={() => {
// 복사 기능 구현
console.log('Dashboard copy:', dashboard.id);
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
title="복사"
>
📋
</button>
</div>
</div> </div>
</div> </div>
); );

View File

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

View File

@ -77,40 +77,85 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
</select> </select>
</div> </div>
{/* Y축 설정 */} {/* Y축 설정 (다중 선택 가능) */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
Y축 () Y축 () -
<span className="text-red-500 ml-1">*</span> <span className="text-red-500 ml-1">*</span>
</label> </label>
<select <div className="space-y-2 max-h-60 overflow-y-auto border border-gray-300 rounded-lg p-2 bg-white">
value={currentConfig.yAxis || ''} {availableColumns.map((col) => {
onChange={(e) => updateConfig({ yAxis: e.target.value })} const isSelected = Array.isArray(currentConfig.yAxis)
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" ? currentConfig.yAxis.includes(col)
> : currentConfig.yAxis === col;
<option value=""></option>
{availableColumns.map((col) => ( return (
<option key={col} value={col}> <label
{col} {sampleData[col] && `(예: ${sampleData[col]})`} key={col}
</option> className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer"
))} >
</select> <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>
{/* 집계 함수 */} {/* 집계 함수 */}
<div className="space-y-2"> <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 <select
value={currentConfig.aggregation || 'sum'} value={currentConfig.aggregation || 'sum'}
onChange={(e) => updateConfig({ aggregation: e.target.value as any })} onChange={(e) => updateConfig({ aggregation: e.target.value as any })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
> >
<option value="sum"> (SUM)</option> <option value="sum"> (SUM) - </option>
<option value="avg"> (AVG)</option> <option value="avg"> (AVG) - </option>
<option value="count"> (COUNT)</option> <option value="count"> (COUNT) - </option>
<option value="max"> (MAX)</option> <option value="max"> (MAX) - </option>
<option value="min"> (MIN)</option> <option value="min"> (MIN) - </option>
</select> </select>
<div className="text-xs text-gray-500">
💡 .
SQL .
</div>
</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-sm font-medium text-gray-700 mb-2">📋 </div>
<div className="text-xs text-gray-600 space-y-1"> <div className="text-xs text-gray-600 space-y-1">
<div><strong>X축:</strong> {currentConfig.xAxis || '미설정'}</div> <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> <div><strong>:</strong> {currentConfig.aggregation || 'sum'}</div>
{currentConfig.groupBy && ( {currentConfig.groupBy && (
<div><strong>:</strong> {currentConfig.groupBy}</div> <div><strong>:</strong> {currentConfig.groupBy}</div>
)} )}
<div><strong> :</strong> {queryResult.rows.length}</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>
</div> </div>

View File

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

View File

@ -18,8 +18,60 @@ export default function DashboardDesigner() {
const [selectedElement, setSelectedElement] = useState<string | null>(null); const [selectedElement, setSelectedElement] = useState<string | null>(null);
const [elementCounter, setElementCounter] = useState(0); const [elementCounter, setElementCounter] = useState(0);
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null); 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); 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(( const createElement = useCallback((
type: ElementType, type: ElementType,
@ -82,28 +134,98 @@ export default function DashboardDesigner() {
}, [updateElement]); }, [updateElement]);
// 레이아웃 저장 // 레이아웃 저장
const saveLayout = useCallback(() => { const saveLayout = useCallback(async () => {
const layoutData = { if (elements.length === 0) {
elements: elements.map(el => ({ alert('저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.');
return;
}
try {
// 실제 API 호출
const { dashboardApi } = await import('@/lib/api/dashboard');
const elementsData = elements.map(el => ({
id: el.id,
type: el.type, type: el.type,
subtype: el.subtype, subtype: el.subtype,
position: el.position, position: el.position,
size: el.size, size: el.size,
title: el.title, title: el.title,
content: el.content,
dataSource: el.dataSource, dataSource: el.dataSource,
chartConfig: el.chartConfig chartConfig: el.chartConfig
})), }));
timestamp: new Date().toISOString()
};
console.log('저장된 레이아웃:', JSON.stringify(layoutData, null, 2)); let savedDashboard;
alert('레이아웃이 콘솔에 저장되었습니다. (F12를 눌러 확인하세요)');
}, [elements]); 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 ( return (
<div className="flex h-full bg-gray-50"> <div className="flex h-full bg-gray-50">
{/* 캔버스 영역 */} {/* 캔버스 영역 */}
<div className="flex-1 relative overflow-auto border-r-2 border-gray-300"> <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 <DashboardToolbar
onClearCanvas={clearCanvas} onClearCanvas={clearCanvas}
onSaveLayout={saveLayout} onSaveLayout={saveLayout}

View File

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

View File

@ -32,10 +32,35 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
setError(null); setError(null);
try { try {
// 실제 API 호출 대신 샘플 데이터 생성 // 실제 API 호출
await new Promise(resolve => setTimeout(resolve, 500 + Math.random() * 1000)); // 실제 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); setQueryResult(result);
onQueryTest?.(result); onQueryTest?.(result);
@ -50,7 +75,7 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : '쿼리 실행 중 오류가 발생했습니다.'; const errorMessage = err instanceof Error ? err.message : '쿼리 실행 중 오류가 발생했습니다.';
setError(errorMessage); setError(errorMessage);
console.error('Query execution error:', err); // console.error('Query execution error:', err);
} finally { } finally {
setIsExecuting(false); setIsExecuting(false);
} }
@ -59,6 +84,18 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
// 샘플 쿼리 삽입 // 샘플 쿼리 삽입
const insertSampleQuery = useCallback((sampleType: string) => { const insertSampleQuery = useCallback((sampleType: string) => {
const samples = { 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: `-- 월별 매출 데이터 sales: `-- 월별 매출 데이터
SELECT SELECT
DATE_TRUNC('month', order_date) as month, 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' WHERE oi.created_at >= CURRENT_DATE - INTERVAL '1 month'
GROUP BY product_name GROUP BY product_name
ORDER BY total_sold DESC 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] || ''); setQuery(samples[sampleType as keyof typeof samples] || '');
@ -124,6 +173,18 @@ LIMIT 10;`
{/* 샘플 쿼리 버튼들 */} {/* 샘플 쿼리 버튼들 */}
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<span className="text-sm text-gray-600"> :</span> <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 <button
onClick={() => insertSampleQuery('sales')} onClick={() => insertSampleQuery('sales')}
className="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded" 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 { function generateSampleQueryResult(query: string): QueryResult {
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성 // 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
const isMonthly = query.toLowerCase().includes('month'); const queryLower = query.toLowerCase();
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('상품'); // console.log('generateSampleQueryResult called with query:', query.substring(0, 100));
const isWeekly = query.toLowerCase().includes('week');
// 가장 구체적인 조건부터 먼저 체크 (순서 중요!)
const isComparison = queryLower.includes('galaxy') || queryLower.includes('갤럭시') || queryLower.includes('아이폰') || queryLower.includes('iphone');
const isRegional = queryLower.includes('region') || queryLower.includes('지역');
const isMonthly = queryLower.includes('month');
const isSales = queryLower.includes('sales') || queryLower.includes('매출');
const isUsers = queryLower.includes('users') || queryLower.includes('사용자');
const isProducts = queryLower.includes('product') || queryLower.includes('상품');
const isWeekly = queryLower.includes('week');
// console.log('Sample data type detection:', {
// isComparison,
// isRegional,
// isWeekly,
// isProducts,
// isMonthly,
// isSales,
// isUsers,
// querySnippet: query.substring(0, 200)
// });
let columns: string[]; let columns: string[];
let rows: Record<string, any>[]; let rows: Record<string, any>[];
if (isMonthly && isSales) { // 더 구체적인 조건부터 먼저 체크 (순서 중요!)
// 월별 매출 데이터 if (isComparison) {
columns = ['month', 'sales', 'order_count']; // console.log('✅ Using COMPARISON data');
// 제품 비교 데이터 (다중 시리즈)
columns = ['month', 'galaxy_sales', 'iphone_sales', 'other_sales'];
rows = [ rows = [
{ month: '2024-01', sales: 1200000, order_count: 45 }, { month: '2024-01', galaxy_sales: 450000, iphone_sales: 620000, other_sales: 130000 },
{ month: '2024-02', sales: 1350000, order_count: 52 }, { month: '2024-02', galaxy_sales: 520000, iphone_sales: 680000, other_sales: 150000 },
{ month: '2024-03', sales: 1180000, order_count: 41 }, { month: '2024-03', galaxy_sales: 480000, iphone_sales: 590000, other_sales: 110000 },
{ month: '2024-04', sales: 1420000, order_count: 58 }, { month: '2024-04', galaxy_sales: 610000, iphone_sales: 650000, other_sales: 160000 },
{ month: '2024-05', sales: 1680000, order_count: 67 }, { month: '2024-05', galaxy_sales: 720000, iphone_sales: 780000, other_sales: 180000 },
{ month: '2024-06', sales: 1540000, order_count: 61 }, { month: '2024-06', galaxy_sales: 680000, iphone_sales: 690000, other_sales: 170000 },
{ month: '2024-07', sales: 1720000, order_count: 71 }, { month: '2024-07', galaxy_sales: 750000, iphone_sales: 800000, other_sales: 170000 },
{ month: '2024-08', sales: 1580000, order_count: 63 }, { month: '2024-08', galaxy_sales: 690000, iphone_sales: 720000, other_sales: 170000 },
{ month: '2024-09', sales: 1650000, order_count: 68 }, { month: '2024-09', galaxy_sales: 730000, iphone_sales: 750000, other_sales: 170000 },
{ month: '2024-10', sales: 1780000, order_count: 75 }, { month: '2024-10', galaxy_sales: 800000, iphone_sales: 810000, other_sales: 170000 },
{ month: '2024-11', sales: 1920000, order_count: 82 }, { month: '2024-11', galaxy_sales: 870000, iphone_sales: 880000, other_sales: 170000 },
{ month: '2024-12', sales: 2100000, order_count: 89 }, { 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) { } else if (isWeekly && isUsers) {
// console.log('✅ Using USERS data');
// 사용자 가입 추이 // 사용자 가입 추이
columns = ['week', 'new_users']; columns = ['week', 'new_users'];
rows = [ rows = [
@ -313,7 +430,8 @@ function generateSampleQueryResult(query: string): QueryResult {
{ week: '2024-W20', new_users: 61 }, { week: '2024-W20', new_users: 61 },
{ week: '2024-W21', new_users: 58 }, { week: '2024-W21', new_users: 58 },
]; ];
} else if (isProducts) { } else if (isProducts && !isComparison) {
// console.log('✅ Using PRODUCTS data');
// 상품별 판매량 // 상품별 판매량
columns = ['product_name', 'total_sold', 'revenue']; columns = ['product_name', 'total_sold', 'revenue'];
rows = [ rows = [
@ -328,7 +446,26 @@ function generateSampleQueryResult(query: string): QueryResult {
{ product_name: '프린터', total_sold: 34, revenue: 17000000 }, { product_name: '프린터', total_sold: 34, revenue: 17000000 },
{ product_name: '웹캠', total_sold: 89, revenue: 8900000 }, { 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 { } else {
// console.log('⚠️ Using DEFAULT data');
// 기본 샘플 데이터 // 기본 샘플 데이터
columns = ['category', 'value', 'count']; columns = ['category', 'value', 'count'];
rows = [ 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'; 'use client';
import React from 'react'; import React from 'react';
import { import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import { ChartConfig } from '../types';
interface BarChartComponentProps { interface BarChartComponentProps {
data: any[]; data: any[];
config: ChartConfig; config: any;
width?: number; width?: number;
height?: number; height?: number;
} }
/** /**
* * (Recharts SimpleBarChart )
* - Recharts BarChart * -
* - , , * -
*/ */
export function BarChartComponent({ data, config, width = 250, height = 200 }: BarChartComponentProps) { export function BarChartComponent({ data, config, width = 600, height = 300 }: BarChartComponentProps) {
const { // console.log('🎨 BarChartComponent - 전체 데이터:', {
xAxis = 'x', // dataLength: data?.length,
yAxis = 'y', // fullData: data,
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'], // dataType: typeof data,
title, // isArray: Array.isArray(data),
showLegend = true // config,
} = config; // xAxisField: config?.xAxis,
// yAxisFields: config?.yAxis
// });
// Y축에 해당하는 모든 키 찾기 (그룹핑된 데이터의 경우) // 데이터가 없으면 메시지 표시
const yKeys = data.length > 0 if (!data || data.length === 0) {
? Object.keys(data[0]).filter(key => key !== xAxis && typeof data[0][key] === 'number') return (
: [yAxis]; <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 ( return (
<div className="w-full h-full p-2"> <ResponsiveContainer width="100%" height="100%">
{title && ( <BarChart
<div className="text-center text-sm font-semibold text-gray-700 mb-2"> data={data}
{title} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
</div> >
)} <CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey={config.xAxis}
tick={{ fontSize: 12 }}
/>
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
{config.showLegend !== false && <Legend />}
<ResponsiveContainer width="100%" height="100%"> {/* Y축 필드마다 Bar 생성 */}
<BarChart {yFields.map((field: string, index: number) => (
data={data} <Bar
margin={{ key={field}
top: 5, dataKey={field}
right: 30, fill={colors[index % colors.length]}
left: 20, name={labelMapping[field] || field}
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey={xAxis}
tick={{ fontSize: 12 }}
stroke="#666"
/> />
<YAxis ))}
tick={{ fontSize: 12 }} </BarChart>
stroke="#666" </ResponsiveContainer>
/>
<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>
); );
} }

View File

@ -5,6 +5,10 @@ import { DashboardElement, QueryResult } from '../types';
import { BarChartComponent } from './BarChartComponent'; import { BarChartComponent } from './BarChartComponent';
import { PieChartComponent } from './PieChartComponent'; import { PieChartComponent } from './PieChartComponent';
import { LineChartComponent } from './LineChartComponent'; import { LineChartComponent } from './LineChartComponent';
import { AreaChartComponent } from './AreaChartComponent';
import { StackedBarChartComponent } from './StackedBarChartComponent';
import { DonutChartComponent } from './DonutChartComponent';
import { ComboChartComponent } from './ComboChartComponent';
interface ChartRendererProps { interface ChartRendererProps {
element: DashboardElement; element: DashboardElement;
@ -14,12 +18,20 @@ interface ChartRendererProps {
} }
/** /**
* * ( )
* - * -
* - * -
*/ */
export function ChartRenderer({ element, data, width = 250, height = 200 }: 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) { if (!data || !element.chartConfig?.xAxis || !element.chartConfig?.yAxis) {
return ( return (
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm"> <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 (!data.rows || data.rows.length === 0) {
// 에러가 있는 경우
if (chartData.length === 0) {
return ( return (
<div className="w-full h-full flex items-center justify-center text-red-500 text-sm"> <div className="w-full h-full flex items-center justify-center text-red-500 text-sm">
<div className="text-center"> <div className="text-center">
<div className="text-2xl mb-2"></div> <div className="text-2xl mb-2"></div>
<div> </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 // 차트 공통 props
const chartProps = { const chartProps = {
data: chartData, data: chartData,
config: element.chartConfig, config: element.chartConfig,
width: width - 20, // 패딩 고려 width: width - 20,
height: height - 60, // 헤더 높이 고려 height: height - 60,
}; };
// 차트 타입에 따른 렌더링 // 차트 타입에 따른 렌더링
@ -63,6 +81,14 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
return <PieChartComponent {...chartProps} />; return <PieChartComponent {...chartProps} />;
case 'line': case 'line':
return <LineChartComponent {...chartProps} />; 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: default:
return ( return (
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm"> <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 showLegend = true
} = config; } = config;
// Y축에 해당하는 모든 키 찾기 (그룹핑된 데이터의 경우) // Y축 필드들 (단일 또는 다중)
const yKeys = data.length > 0 const yFields = Array.isArray(yAxis) ? yAxis : [yAxis];
? Object.keys(data[0]).filter(key => key !== xAxis && typeof data[0][key] === 'number')
: [yAxis]; // 사용할 Y축 키들 결정
const yKeys = yFields.filter(field => field && field !== 'y');
return ( return (
<div className="w-full h-full p-2"> <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 { BarChartComponent } from './BarChartComponent';
export { PieChartComponent } from './PieChartComponent'; export { PieChartComponent } from './PieChartComponent';
export { LineChartComponent } from './LineChartComponent'; 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 ElementType = 'chart' | 'widget';
export type ElementSubtype = export type ElementSubtype =
| 'bar' | 'pie' | 'line' // 차트 타입 | 'bar' | 'pie' | 'line' | 'area' | 'stacked-bar' | 'donut' | 'combo' // 차트 타입
| 'exchange' | 'weather'; // 위젯 타입 | 'exchange' | 'weather'; // 위젯 타입
export interface Position { export interface Position {
@ -50,13 +50,13 @@ export interface ChartDataSource {
} }
export interface ChartConfig { export interface ChartConfig {
xAxis?: string; // X축 데이터 필드 xAxis?: string; // X축 데이터 필드
yAxis?: string; // Y축 데이터 필드 yAxis?: string | string[]; // Y축 데이터 필드 (단일 또는 다중)
groupBy?: string; // 그룹핑 필드 groupBy?: string; // 그룹핑 필드
aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min'; aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min';
colors?: string[]; // 차트 색상 colors?: string[]; // 차트 색상
title?: string; // 차트 제목 title?: string; // 차트 제목
showLegend?: boolean; // 범례 표시 여부 showLegend?: boolean; // 범례 표시 여부
} }
export interface QueryResult { export interface QueryResult {

View File

@ -0,0 +1,277 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { DashboardElement, QueryResult } from '@/components/admin/dashboard/types';
import { ChartRenderer } from '@/components/admin/dashboard/charts/ChartRenderer';
interface DashboardViewerProps {
elements: DashboardElement[];
dashboardId: string;
refreshInterval?: number; // 전체 대시보드 새로고침 간격 (ms)
}
/**
*
* -
* -
* -
*/
export function DashboardViewer({ elements, dashboardId, refreshInterval }: DashboardViewerProps) {
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
// 개별 요소 데이터 로딩
const loadElementData = useCallback(async (element: DashboardElement) => {
if (!element.dataSource?.query || element.type !== 'chart') {
return;
}
setLoadingElements(prev => new Set([...prev, element.id]));
try {
// console.log(`🔄 요소 ${element.id} 데이터 로딩 시작:`, element.dataSource.query);
// 실제 API 호출
const { dashboardApi } = await import('@/lib/api/dashboard');
const result = await dashboardApi.executeQuery(element.dataSource.query);
// console.log(`✅ 요소 ${element.id} 데이터 로딩 완료:`, result);
const data: QueryResult = {
columns: result.columns || [],
rows: result.rows || [],
totalRows: result.rowCount || 0,
executionTime: 0
};
setElementData(prev => ({
...prev,
[element.id]: data
}));
} catch (error) {
// console.error(`❌ Element ${element.id} data loading error:`, error);
} finally {
setLoadingElements(prev => {
const newSet = new Set(prev);
newSet.delete(element.id);
return newSet;
});
}
}, []);
// 모든 요소 데이터 로딩
const loadAllData = useCallback(async () => {
setLastRefresh(new Date());
const chartElements = elements.filter(el => el.type === 'chart' && el.dataSource?.query);
// 병렬로 모든 차트 데이터 로딩
await Promise.all(chartElements.map(element => loadElementData(element)));
}, [elements, loadElementData]);
// 초기 데이터 로딩
useEffect(() => {
loadAllData();
}, [loadAllData]);
// 전체 새로고침 간격 설정
useEffect(() => {
if (!refreshInterval || refreshInterval === 0) {
return;
}
const interval = setInterval(loadAllData, refreshInterval);
return () => clearInterval(interval);
}, [refreshInterval, loadAllData]);
// 요소가 없는 경우
if (elements.length === 0) {
return (
<div className="h-full flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-6xl mb-4">📊</div>
<div className="text-xl font-medium text-gray-700 mb-2">
</div>
<div className="text-sm text-gray-500">
</div>
</div>
</div>
);
}
return (
<div className="relative w-full h-full bg-gray-100 overflow-auto">
{/* 새로고침 상태 표시 */}
<div className="absolute top-4 right-4 z-10 bg-white rounded-lg shadow-sm px-3 py-2 text-xs text-gray-600">
: {lastRefresh.toLocaleTimeString()}
{Array.from(loadingElements).length > 0 && (
<span className="ml-2 text-blue-600">
({Array.from(loadingElements).length} ...)
</span>
)}
</div>
{/* 대시보드 요소들 */}
<div className="relative" style={{ minHeight: '100%' }}>
{elements.map((element) => (
<ViewerElement
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
/>
))}
</div>
</div>
);
}
interface ViewerElementProps {
element: DashboardElement;
data?: QueryResult;
isLoading: boolean;
onRefresh: () => void;
}
/**
*
*/
function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className="absolute bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden"
style={{
left: element.position.x,
top: element.position.y,
width: element.size.width,
height: element.size.height
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 헤더 */}
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex justify-between items-center">
<h3 className="font-semibold text-gray-800 text-sm">{element.title}</h3>
{/* 새로고침 버튼 (호버 시에만 표시) */}
{isHovered && (
<button
onClick={onRefresh}
disabled={isLoading}
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
title="새로고침"
>
{isLoading ? (
<div className="w-4 h-4 border border-gray-400 border-t-transparent rounded-full animate-spin" />
) : (
'🔄'
)}
</button>
)}
</div>
{/* 내용 */}
<div className="h-[calc(100%-57px)]">
{element.type === 'chart' ? (
<ChartRenderer
element={element}
data={data}
width={element.size.width}
height={element.size.height - 57}
/>
) : (
// 위젯 렌더링
<div className="w-full h-full p-4 flex items-center justify-center bg-gradient-to-br from-blue-400 to-purple-600 text-white">
<div className="text-center">
<div className="text-3xl mb-2">
{element.subtype === 'exchange' && '💱'}
{element.subtype === 'weather' && '☁️'}
</div>
<div className="text-sm whitespace-pre-line">{element.content}</div>
</div>
</div>
)}
</div>
{/* 로딩 오버레이 */}
{isLoading && (
<div className="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center">
<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 text-gray-600"> ...</div>
</div>
</div>
)}
</div>
);
}
/**
* ()
*/
function generateSampleQueryResult(query: string, chartType: string): QueryResult {
// 시간에 따라 약간씩 다른 데이터 생성 (실시간 업데이트 시뮬레이션)
const timeVariation = Math.sin(Date.now() / 10000) * 0.1 + 1;
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');
let columns: string[];
let rows: Record<string, any>[];
if (isMonthly && isSales) {
columns = ['month', 'sales', 'order_count'];
rows = [
{ month: '2024-01', sales: Math.round(1200000 * timeVariation), order_count: Math.round(45 * timeVariation) },
{ month: '2024-02', sales: Math.round(1350000 * timeVariation), order_count: Math.round(52 * timeVariation) },
{ month: '2024-03', sales: Math.round(1180000 * timeVariation), order_count: Math.round(41 * timeVariation) },
{ month: '2024-04', sales: Math.round(1420000 * timeVariation), order_count: Math.round(58 * timeVariation) },
{ month: '2024-05', sales: Math.round(1680000 * timeVariation), order_count: Math.round(67 * timeVariation) },
{ month: '2024-06', sales: Math.round(1540000 * timeVariation), order_count: Math.round(61 * timeVariation) },
];
} else if (isWeekly && isUsers) {
columns = ['week', 'new_users'];
rows = [
{ week: '2024-W10', new_users: Math.round(23 * timeVariation) },
{ week: '2024-W11', new_users: Math.round(31 * timeVariation) },
{ week: '2024-W12', new_users: Math.round(28 * timeVariation) },
{ week: '2024-W13', new_users: Math.round(35 * timeVariation) },
{ week: '2024-W14', new_users: Math.round(42 * timeVariation) },
{ week: '2024-W15', new_users: Math.round(38 * timeVariation) },
];
} else if (isProducts) {
columns = ['product_name', 'total_sold', 'revenue'];
rows = [
{ product_name: '스마트폰', total_sold: Math.round(156 * timeVariation), revenue: Math.round(234000000 * timeVariation) },
{ product_name: '노트북', total_sold: Math.round(89 * timeVariation), revenue: Math.round(178000000 * timeVariation) },
{ product_name: '태블릿', total_sold: Math.round(134 * timeVariation), revenue: Math.round(67000000 * timeVariation) },
{ product_name: '이어폰', total_sold: Math.round(267 * timeVariation), revenue: Math.round(26700000 * timeVariation) },
{ product_name: '스마트워치', total_sold: Math.round(98 * timeVariation), revenue: Math.round(49000000 * timeVariation) },
];
} else {
columns = ['category', 'value', 'count'];
rows = [
{ category: 'A', value: Math.round(100 * timeVariation), count: Math.round(10 * timeVariation) },
{ category: 'B', value: Math.round(150 * timeVariation), count: Math.round(15 * timeVariation) },
{ category: 'C', value: Math.round(120 * timeVariation), count: Math.round(12 * timeVariation) },
{ category: 'D', value: Math.round(180 * timeVariation), count: Math.round(18 * timeVariation) },
{ category: 'E', value: Math.round(90 * timeVariation), count: Math.round(9 * timeVariation) },
];
}
return {
columns,
rows,
totalRows: rows.length,
executionTime: Math.floor(Math.random() * 100) + 50,
};
}

View File

@ -0,0 +1,281 @@
/**
* API
*/
import { DashboardElement } from '@/components/admin/dashboard/types';
// API 기본 설정
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
// 토큰 가져오기 (실제 인증 시스템에 맞게 수정)
function getAuthToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
}
// API 요청 헬퍼
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<{ success: boolean; data?: T; message?: string; pagination?: any }> {
const token = getAuthToken();
const config: RequestInit = {
headers: {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
...options.headers,
},
...options,
};
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
// 응답이 JSON이 아닐 수도 있으므로 안전하게 처리
let result;
try {
result = await response.json();
} catch (jsonError) {
console.error('JSON Parse Error:', jsonError);
throw new Error(`서버 응답을 파싱할 수 없습니다. Status: ${response.status}`);
}
if (!response.ok) {
console.error('API Error Response:', {
status: response.status,
statusText: response.statusText,
result
});
throw new Error(result.message || `HTTP ${response.status}: ${response.statusText}`);
}
return result;
} catch (error: any) {
console.error('API Request Error:', {
endpoint,
error: error?.message || error,
errorObj: error,
config
});
throw error;
}
}
// 대시보드 타입 정의
export interface Dashboard {
id: string;
title: string;
description?: string;
thumbnailUrl?: string;
isPublic: boolean;
createdBy: string;
createdAt: string;
updatedAt: string;
tags?: string[];
category?: string;
viewCount: number;
elementsCount?: number;
creatorName?: string;
elements?: DashboardElement[];
}
export interface CreateDashboardRequest {
title: string;
description?: string;
isPublic?: boolean;
elements: DashboardElement[];
tags?: string[];
category?: string;
}
export interface DashboardListQuery {
page?: number;
limit?: number;
search?: string;
category?: string;
isPublic?: boolean;
}
// 대시보드 API 함수들
export const dashboardApi = {
/**
*
*/
async createDashboard(data: CreateDashboardRequest): Promise<Dashboard> {
const result = await apiRequest<Dashboard>('/dashboards', {
method: 'POST',
body: JSON.stringify(data),
});
if (!result.success || !result.data) {
throw new Error(result.message || '대시보드 생성에 실패했습니다.');
}
return result.data;
},
/**
*
*/
async getDashboards(query: DashboardListQuery = {}) {
const params = new URLSearchParams();
if (query.page) params.append('page', query.page.toString());
if (query.limit) params.append('limit', query.limit.toString());
if (query.search) params.append('search', query.search);
if (query.category) params.append('category', query.category);
if (typeof query.isPublic === 'boolean') params.append('isPublic', query.isPublic.toString());
const queryString = params.toString();
const endpoint = `/dashboards${queryString ? `?${queryString}` : ''}`;
const result = await apiRequest<Dashboard[]>(endpoint);
if (!result.success) {
throw new Error(result.message || '대시보드 목록 조회에 실패했습니다.');
}
return {
dashboards: result.data || [],
pagination: result.pagination
};
},
/**
*
*/
async getMyDashboards(query: DashboardListQuery = {}) {
const params = new URLSearchParams();
if (query.page) params.append('page', query.page.toString());
if (query.limit) params.append('limit', query.limit.toString());
if (query.search) params.append('search', query.search);
if (query.category) params.append('category', query.category);
const queryString = params.toString();
const endpoint = `/dashboards/my${queryString ? `?${queryString}` : ''}`;
const result = await apiRequest<Dashboard[]>(endpoint);
if (!result.success) {
throw new Error(result.message || '내 대시보드 목록 조회에 실패했습니다.');
}
return {
dashboards: result.data || [],
pagination: result.pagination
};
},
/**
*
*/
async getDashboard(id: string): Promise<Dashboard> {
const result = await apiRequest<Dashboard>(`/dashboards/${id}`);
if (!result.success || !result.data) {
throw new Error(result.message || '대시보드 조회에 실패했습니다.');
}
return result.data;
},
/**
* ( )
*/
async getPublicDashboard(id: string): Promise<Dashboard> {
const result = await apiRequest<Dashboard>(`/dashboards/public/${id}`);
if (!result.success || !result.data) {
throw new Error(result.message || '대시보드 조회에 실패했습니다.');
}
return result.data;
},
/**
*
*/
async updateDashboard(id: string, data: Partial<CreateDashboardRequest>): Promise<Dashboard> {
const result = await apiRequest<Dashboard>(`/dashboards/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
if (!result.success || !result.data) {
throw new Error(result.message || '대시보드 수정에 실패했습니다.');
}
return result.data;
},
/**
*
*/
async deleteDashboard(id: string): Promise<void> {
const result = await apiRequest(`/dashboards/${id}`, {
method: 'DELETE',
});
if (!result.success) {
throw new Error(result.message || '대시보드 삭제에 실패했습니다.');
}
},
/**
* ( )
*/
async getPublicDashboards(query: DashboardListQuery = {}) {
const params = new URLSearchParams();
if (query.page) params.append('page', query.page.toString());
if (query.limit) params.append('limit', query.limit.toString());
if (query.search) params.append('search', query.search);
if (query.category) params.append('category', query.category);
const queryString = params.toString();
const endpoint = `/dashboards/public${queryString ? `?${queryString}` : ''}`;
const result = await apiRequest<Dashboard[]>(endpoint);
if (!result.success) {
throw new Error(result.message || '공개 대시보드 목록 조회에 실패했습니다.');
}
return {
dashboards: result.data || [],
pagination: result.pagination
};
},
/**
* ( )
*/
async executeQuery(query: string): Promise<{ columns: string[]; rows: any[]; rowCount: number }> {
const result = await apiRequest<{ columns: string[]; rows: any[]; rowCount: number }>('/dashboards/execute-query', {
method: 'POST',
body: JSON.stringify({ query }),
});
if (!result.success || !result.data) {
throw new Error(result.message || '쿼리 실행에 실패했습니다.');
}
return result.data;
}
};
// 에러 처리 유틸리티
export function handleApiError(error: any): string {
if (error.message) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return '알 수 없는 오류가 발생했습니다.';
}