feat: 대시보드 관리 시스템 구현
## 백엔드 - DashboardController: 대시보드 CRUD 및 쿼리 실행 API - DashboardService: 비즈니스 로직 처리 - PostgreSQL 연동 및 데이터 관리 ## 프론트엔드 - DashboardDesigner: 캔버스 기반 대시보드 디자이너 - QueryEditor: SQL 쿼리 편집 및 미리보기 - ChartRenderer: 다양한 차트 타입 지원 (Bar, Line, Area, Donut, Stacked, Combo) - DashboardViewer: 실시간 데이터 반영 뷰어 ## 개선사항 - 콘솔 로그 프로덕션 준비 (주석 처리) - 차트 컴포넌트 확장 (6가지 타입) - 실시간 쿼리 실행 및 데이터 바인딩
This commit is contained in:
parent
cf747b5fb3
commit
5f63c24c42
|
|
@ -48,6 +48,7 @@
|
|||
"@types/pg": "^8.15.5",
|
||||
"@types/sanitize-html": "^2.9.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"eslint": "^8.55.0",
|
||||
|
|
@ -3544,6 +3545,13 @@
|
|||
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==",
|
||||
"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": {
|
||||
"version": "17.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@
|
|||
"@types/pg": "^8.15.5",
|
||||
"@types/sanitize-html": "^2.9.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"eslint": "^8.55.0",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
|||
import externalCallRoutes from "./routes/externalCallRoutes";
|
||||
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
||||
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
||||
import dashboardRoutes from "./routes/dashboardRoutes";
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// 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-call-configs", externalCallConfigRoutes);
|
||||
app.use("/api/dataflow", dataflowExecutionRoutes);
|
||||
app.use("/api/dashboards", dashboardRoutes);
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
|
|
@ -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 : '쿼리 실행 오류'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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()
|
||||
};
|
||||
}
|
||||
|
|
@ -1,269 +1,286 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface UserInfo {
|
||||
userId: string;
|
||||
userName: string;
|
||||
deptName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
interface Dashboard {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: any;
|
||||
children?: MenuItem[];
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
elementsCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
/**
|
||||
* 대시보드 목록 페이지
|
||||
* - 저장된 대시보드들의 목록 표시
|
||||
* - 새 대시보드 생성 링크
|
||||
* - 대시보드 미리보기 및 관리
|
||||
*/
|
||||
export default function DashboardListPage() {
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 대시보드 목록 로딩
|
||||
useEffect(() => {
|
||||
loadDashboards();
|
||||
}, []);
|
||||
|
||||
const loadDashboards = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 실제 API 호출 시도
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
|
||||
try {
|
||||
const result = await dashboardApi.getDashboards({ page: 1, limit: 50 });
|
||||
|
||||
// API에서 가져온 대시보드들을 Dashboard 형식으로 변환
|
||||
const apiDashboards: Dashboard[] = result.dashboards.map((dashboard: any) => ({
|
||||
id: dashboard.id,
|
||||
title: dashboard.title,
|
||||
description: dashboard.description,
|
||||
elementsCount: dashboard.elementsCount || dashboard.elements?.length || 0,
|
||||
createdAt: dashboard.createdAt,
|
||||
updatedAt: dashboard.updatedAt,
|
||||
isPublic: dashboard.isPublic,
|
||||
creatorName: dashboard.creatorName
|
||||
}));
|
||||
|
||||
setDashboards(apiDashboards);
|
||||
|
||||
} catch (apiError) {
|
||||
console.warn('API 호출 실패, 로컬 스토리지 및 샘플 데이터 사용:', apiError);
|
||||
|
||||
// API 실패 시 로컬 스토리지 + 샘플 데이터 사용
|
||||
const savedDashboards = JSON.parse(localStorage.getItem('savedDashboards') || '[]');
|
||||
|
||||
// 샘플 대시보드들
|
||||
const sampleDashboards: Dashboard[] = [
|
||||
{
|
||||
id: "dashboard",
|
||||
title: "대시보드",
|
||||
icon: Home,
|
||||
id: 'sales-overview',
|
||||
title: '📊 매출 현황 대시보드',
|
||||
description: '월별 매출 추이 및 상품별 판매 현황을 한눈에 확인할 수 있습니다.',
|
||||
elementsCount: 3,
|
||||
createdAt: '2024-09-30T10:00:00Z',
|
||||
updatedAt: '2024-09-30T14:30:00Z',
|
||||
isPublic: true
|
||||
},
|
||||
{
|
||||
id: "project",
|
||||
title: "프로젝트 관리",
|
||||
icon: FileText,
|
||||
children: [
|
||||
{ id: "project-list", title: "프로젝트 목록", icon: FileText },
|
||||
{ id: "project-concept", title: "프로젝트 컨셉", icon: FileText },
|
||||
{ id: "project-planning", title: "프로젝트 기획", icon: FileText },
|
||||
],
|
||||
id: 'user-analytics',
|
||||
title: '👥 사용자 분석 대시보드',
|
||||
description: '사용자 행동 패턴 및 가입 추이 분석',
|
||||
elementsCount: 1,
|
||||
createdAt: '2024-09-29T15:00:00Z',
|
||||
updatedAt: '2024-09-30T09:15:00Z',
|
||||
isPublic: false
|
||||
},
|
||||
{
|
||||
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 },
|
||||
],
|
||||
},
|
||||
id: 'inventory-status',
|
||||
title: '📦 재고 현황 대시보드',
|
||||
description: '실시간 재고 현황 및 입출고 내역',
|
||||
elementsCount: 4,
|
||||
createdAt: '2024-09-28T11:30:00Z',
|
||||
updatedAt: '2024-09-29T16:45:00Z',
|
||||
isPublic: true
|
||||
}
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set(["dashboard"]));
|
||||
const [selectedMenu, setSelectedMenu] = useState("dashboard");
|
||||
const [currentContent, setCurrentContent] = useState<string>("dashboard");
|
||||
// 저장된 대시보드를 Dashboard 형식으로 변환
|
||||
const userDashboards: Dashboard[] = savedDashboards.map((dashboard: any) => ({
|
||||
id: dashboard.id,
|
||||
title: dashboard.title,
|
||||
description: dashboard.description,
|
||||
elementsCount: dashboard.elements?.length || 0,
|
||||
createdAt: dashboard.createdAt,
|
||||
updatedAt: dashboard.updatedAt,
|
||||
isPublic: false // 사용자가 만든 대시보드는 기본적으로 비공개
|
||||
}));
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
const toggleMenu = (menuId: string) => {
|
||||
const newExpanded = new Set(expandedMenus);
|
||||
if (newExpanded.has(menuId)) {
|
||||
newExpanded.delete(menuId);
|
||||
} else {
|
||||
newExpanded.add(menuId);
|
||||
// 사용자 대시보드를 맨 앞에 배치
|
||||
setDashboards([...userDashboards, ...sampleDashboards]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Dashboard loading error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
setExpandedMenus(newExpanded);
|
||||
};
|
||||
|
||||
const handleMenuClick = (menuId: string) => {
|
||||
setSelectedMenu(menuId);
|
||||
setCurrentContent(menuId);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (currentContent) {
|
||||
case "dashboard":
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold text-slate-900">대시보드</h1>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<FileText className="h-8 w-8 text-blue-600" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-slate-600">전체 프로젝트</p>
|
||||
<p className="text-2xl font-bold text-slate-900">24</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-8 w-8 text-green-600" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-slate-600">등록된 부품</p>
|
||||
<p className="text-2xl font-bold text-slate-900">1,247</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<Users className="h-8 w-8 text-purple-600" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-slate-600">활성 사용자</p>
|
||||
<p className="text-2xl font-bold text-slate-900">89</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center">
|
||||
<BarChart3 className="h-8 w-8 text-orange-600" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-slate-600">진행중인 작업</p>
|
||||
<p className="text-2xl font-bold text-slate-900">12</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold">최근 활동</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500"></div>
|
||||
<span className="text-sm text-slate-600">새로운 프로젝트 '제품 A' 생성됨</span>
|
||||
<span className="text-xs text-slate-400">2시간 전</span>
|
||||
</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>
|
||||
<span className="text-xs text-slate-400">4시간 전</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-2 w-2 rounded-full bg-orange-500"></div>
|
||||
<span className="text-sm text-slate-600">사용자 '김개발' 권한 변경</span>
|
||||
<span className="text-xs text-slate-400">1일 전</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
// 검색 필터링
|
||||
const filteredDashboards = dashboards.filter(dashboard =>
|
||||
dashboard.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
dashboard.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
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 className="min-h-screen bg-gray-50">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">📊 대시보드</h1>
|
||||
<p className="text-gray-600 mt-1">데이터를 시각화하고 인사이트를 얻어보세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderMenuItem = (item: MenuItem, level: number = 0) => {
|
||||
const isExpanded = expandedMenus.has(item.id);
|
||||
const isSelected = selectedMenu === item.id;
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
|
||||
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);
|
||||
}
|
||||
}}
|
||||
<Link
|
||||
href="/admin/dashboard"
|
||||
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium"
|
||||
>
|
||||
<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" />)}
|
||||
➕ 새 대시보드 만들기
|
||||
</Link>
|
||||
</div>
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="mt-1">{item.children?.map((child) => renderMenuItem(child, level + 1))}</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 className="max-w-7xl mx-auto px-6 py-8">
|
||||
{isLoading ? (
|
||||
// 로딩 상태
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* 헤더 */}
|
||||
<header className="border-b border-slate-200 bg-white shadow-sm">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(!sidebarOpen)}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-slate-900">PLM 시스템</h1>
|
||||
) : (
|
||||
// 대시보드 그리드
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<div className="flex">
|
||||
{/* 사이드바 */}
|
||||
<aside
|
||||
className={`${sidebarOpen ? "w-64" : "w-0"} overflow-hidden border-r border-slate-200 bg-white transition-all duration-300`}
|
||||
>
|
||||
<nav className="space-y-2 p-4">{menuItems.map((item) => renderMenuItem(item))}</nav>
|
||||
</aside>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<main className="flex-1 p-6">{renderContent()}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -141,11 +141,22 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
// 실제 API 호출 대신 샘플 데이터 생성
|
||||
const sampleData = generateSampleData(element.dataSource.query, element.subtype);
|
||||
setChartData(sampleData);
|
||||
// console.log('🔄 쿼리 실행 시작:', element.dataSource.query);
|
||||
|
||||
// 실제 API 호출
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
|
||||
// console.log('✅ 쿼리 실행 결과:', result);
|
||||
|
||||
setChartData({
|
||||
columns: result.columns || [],
|
||||
rows: result.rows || [],
|
||||
totalRows: result.rowCount || 0,
|
||||
executionTime: 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('데이터 로딩 오류:', error);
|
||||
// console.error('❌ 데이터 로딩 오류:', error);
|
||||
setChartData(null);
|
||||
} finally {
|
||||
setIsLoadingData(false);
|
||||
|
|
|
|||
|
|
@ -77,40 +77,85 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{/* Y축 설정 */}
|
||||
{/* Y축 설정 (다중 선택 가능) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Y축 (값)
|
||||
Y축 (값) - 여러 개 선택 가능
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.yAxis || ''}
|
||||
onChange={(e) => updateConfig({ yAxis: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto border border-gray-300 rounded-lg p-2 bg-white">
|
||||
{availableColumns.map((col) => {
|
||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis.includes(col)
|
||||
: currentConfig.yAxis === col;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={col}
|
||||
className="flex items-center gap-2 p-2 hover:bg-gray-50 rounded cursor-pointer"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
<option key={col} value={col}>
|
||||
{col} {sampleData[col] && `(예: ${sampleData[col]})`}
|
||||
</option>
|
||||
))}
|
||||
</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 className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">집계 함수</label>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
집계 함수
|
||||
<span className="text-gray-500 text-xs ml-2">(데이터 처리 방식)</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.aggregation || 'sum'}
|
||||
onChange={(e) => updateConfig({ aggregation: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="sum">합계 (SUM)</option>
|
||||
<option value="avg">평균 (AVG)</option>
|
||||
<option value="count">개수 (COUNT)</option>
|
||||
<option value="max">최대값 (MAX)</option>
|
||||
<option value="min">최소값 (MIN)</option>
|
||||
<option value="sum">합계 (SUM) - 모든 값을 더함</option>
|
||||
<option value="avg">평균 (AVG) - 평균값 계산</option>
|
||||
<option value="count">개수 (COUNT) - 데이터 개수</option>
|
||||
<option value="max">최대값 (MAX) - 가장 큰 값</option>
|
||||
<option value="min">최소값 (MIN) - 가장 작은 값</option>
|
||||
</select>
|
||||
<div className="text-xs text-gray-500">
|
||||
💡 집계 함수는 현재 쿼리 결과에 적용되지 않습니다.
|
||||
SQL 쿼리에서 직접 집계하는 것을 권장합니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그룹핑 필드 (선택사항) */}
|
||||
|
|
@ -182,12 +227,23 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
|
|||
<div className="text-sm font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<div><strong>X축:</strong> {currentConfig.xAxis || '미설정'}</div>
|
||||
<div><strong>Y축:</strong> {currentConfig.yAxis || '미설정'}</div>
|
||||
<div>
|
||||
<strong>Y축:</strong>{' '}
|
||||
{Array.isArray(currentConfig.yAxis)
|
||||
? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(', ')})`
|
||||
: currentConfig.yAxis || '미설정'
|
||||
}
|
||||
</div>
|
||||
<div><strong>집계:</strong> {currentConfig.aggregation || 'sum'}</div>
|
||||
{currentConfig.groupBy && (
|
||||
<div><strong>그룹핑:</strong> {currentConfig.groupBy}</div>
|
||||
)}
|
||||
<div><strong>데이터 행 수:</strong> {queryResult.rows.length}개</div>
|
||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && (
|
||||
<div className="text-blue-600 mt-2">
|
||||
✨ 다중 시리즈 차트가 생성됩니다!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
|
||||
onCreateElement(dragData.type, dragData.subtype, x, y);
|
||||
} catch (error) {
|
||||
console.error('드롭 데이터 파싱 오류:', error);
|
||||
// console.error('드롭 데이터 파싱 오류:', error);
|
||||
}
|
||||
}, [ref, onCreateElement]);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,60 @@ export default function DashboardDesigner() {
|
|||
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
||||
const [elementCounter, setElementCounter] = useState(0);
|
||||
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
||||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
||||
const [dashboardTitle, setDashboardTitle] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const loadId = params.get('load');
|
||||
|
||||
if (loadId) {
|
||||
loadDashboard(loadId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 대시보드 데이터 로드
|
||||
const loadDashboard = async (id: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// console.log('🔄 대시보드 로딩:', id);
|
||||
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
const dashboard = await dashboardApi.getDashboard(id);
|
||||
|
||||
// console.log('✅ 대시보드 로딩 완료:', dashboard);
|
||||
|
||||
// 대시보드 정보 설정
|
||||
setDashboardId(dashboard.id);
|
||||
setDashboardTitle(dashboard.title);
|
||||
|
||||
// 요소들 설정
|
||||
if (dashboard.elements && dashboard.elements.length > 0) {
|
||||
setElements(dashboard.elements);
|
||||
|
||||
// elementCounter를 가장 큰 ID 번호로 설정
|
||||
const maxId = dashboard.elements.reduce((max, el) => {
|
||||
const match = el.id.match(/element-(\d+)/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1]);
|
||||
return num > max ? num : max;
|
||||
}
|
||||
return max;
|
||||
}, 0);
|
||||
setElementCounter(maxId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// console.error('❌ 대시보드 로딩 오류:', error);
|
||||
alert('대시보드를 불러오는 중 오류가 발생했습니다.\n\n' + (error instanceof Error ? error.message : '알 수 없는 오류'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 새로운 요소 생성
|
||||
const createElement = useCallback((
|
||||
type: ElementType,
|
||||
|
|
@ -82,28 +134,98 @@ export default function DashboardDesigner() {
|
|||
}, [updateElement]);
|
||||
|
||||
// 레이아웃 저장
|
||||
const saveLayout = useCallback(() => {
|
||||
const layoutData = {
|
||||
elements: elements.map(el => ({
|
||||
const saveLayout = useCallback(async () => {
|
||||
if (elements.length === 0) {
|
||||
alert('저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
|
||||
const elementsData = elements.map(el => ({
|
||||
id: el.id,
|
||||
type: el.type,
|
||||
subtype: el.subtype,
|
||||
position: el.position,
|
||||
size: el.size,
|
||||
title: el.title,
|
||||
content: el.content,
|
||||
dataSource: el.dataSource,
|
||||
chartConfig: el.chartConfig
|
||||
})),
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
|
||||
let savedDashboard;
|
||||
|
||||
if (dashboardId) {
|
||||
// 기존 대시보드 업데이트
|
||||
// console.log('🔄 대시보드 업데이트:', dashboardId);
|
||||
savedDashboard = await dashboardApi.updateDashboard(dashboardId, {
|
||||
elements: elementsData
|
||||
});
|
||||
|
||||
alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`);
|
||||
|
||||
// 뷰어 페이지로 이동
|
||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
||||
|
||||
} else {
|
||||
// 새 대시보드 생성
|
||||
const title = prompt('대시보드 제목을 입력하세요:', '새 대시보드');
|
||||
if (!title) return;
|
||||
|
||||
const description = prompt('대시보드 설명을 입력하세요 (선택사항):', '');
|
||||
|
||||
const dashboardData = {
|
||||
title,
|
||||
description: description || undefined,
|
||||
isPublic: false,
|
||||
elements: elementsData
|
||||
};
|
||||
|
||||
console.log('저장된 레이아웃:', JSON.stringify(layoutData, null, 2));
|
||||
alert('레이아웃이 콘솔에 저장되었습니다. (F12를 눌러 확인하세요)');
|
||||
}, [elements]);
|
||||
savedDashboard = await dashboardApi.createDashboard(dashboardData);
|
||||
|
||||
// console.log('✅ 대시보드 생성 완료:', savedDashboard);
|
||||
|
||||
const viewDashboard = confirm(`대시보드 "${title}"이 저장되었습니다!\n\n지금 확인해보시겠습니까?`);
|
||||
if (viewDashboard) {
|
||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// console.error('❌ 저장 오류:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류';
|
||||
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
|
||||
}
|
||||
}, [elements, dashboardId]);
|
||||
|
||||
// 로딩 중이면 로딩 화면 표시
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<div className="text-lg font-medium text-gray-700">대시보드 로딩 중...</div>
|
||||
<div className="text-sm text-gray-500 mt-1">잠시만 기다려주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-gray-50">
|
||||
{/* 캔버스 영역 */}
|
||||
<div className="flex-1 relative overflow-auto border-r-2 border-gray-300">
|
||||
{/* 편집 중인 대시보드 표시 */}
|
||||
{dashboardTitle && (
|
||||
<div className="absolute top-2 left-2 z-10 bg-blue-500 text-white px-3 py-1 rounded-lg text-sm font-medium shadow-lg">
|
||||
📝 편집 중: {dashboardTitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DashboardToolbar
|
||||
onClearCanvas={clearCanvas}
|
||||
onSaveLayout={saveLayout}
|
||||
|
|
|
|||
|
|
@ -34,12 +34,12 @@ export function DashboardSidebar() {
|
|||
className="border-l-4 border-blue-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🥧"
|
||||
title="원형 차트"
|
||||
icon="📚"
|
||||
title="누적 바 차트"
|
||||
type="chart"
|
||||
subtype="pie"
|
||||
subtype="stacked-bar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
className="border-l-4 border-blue-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📈"
|
||||
|
|
@ -47,7 +47,39 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="line"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
className="border-l-4 border-green-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📉"
|
||||
title="영역 차트"
|
||||
type="chart"
|
||||
subtype="area"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-green-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🥧"
|
||||
title="원형 차트"
|
||||
type="chart"
|
||||
subtype="pie"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-purple-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🍩"
|
||||
title="도넛 차트"
|
||||
type="chart"
|
||||
subtype="donut"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-purple-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📊📈"
|
||||
title="콤보 차트"
|
||||
type="chart"
|
||||
subtype="combo"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -32,10 +32,35 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
// 실제 API 호출 대신 샘플 데이터 생성
|
||||
await new Promise(resolve => setTimeout(resolve, 500 + Math.random() * 1000)); // 실제 API 호출 시뮬레이션
|
||||
// 실제 API 호출
|
||||
const response = await fetch('http://localhost:8080/api/dashboards/execute-query', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token') || 'test-token'}` // JWT 토큰 사용
|
||||
},
|
||||
body: JSON.stringify({ query: query.trim() })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || '쿼리 실행에 실패했습니다.');
|
||||
}
|
||||
|
||||
const apiResult = await response.json();
|
||||
|
||||
if (!apiResult.success) {
|
||||
throw new Error(apiResult.message || '쿼리 실행에 실패했습니다.');
|
||||
}
|
||||
|
||||
// API 결과를 QueryResult 형식으로 변환
|
||||
const result: QueryResult = {
|
||||
columns: apiResult.data.columns,
|
||||
rows: apiResult.data.rows,
|
||||
totalRows: apiResult.data.rowCount,
|
||||
executionTime: 0 // API에서 실행 시간을 제공하지 않으므로 0으로 설정
|
||||
};
|
||||
|
||||
const result: QueryResult = generateSampleQueryResult(query.trim());
|
||||
setQueryResult(result);
|
||||
onQueryTest?.(result);
|
||||
|
||||
|
|
@ -50,7 +75,7 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
|
|||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : '쿼리 실행 중 오류가 발생했습니다.';
|
||||
setError(errorMessage);
|
||||
console.error('Query execution error:', err);
|
||||
// console.error('Query execution error:', err);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
|
|
@ -59,6 +84,18 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
|
|||
// 샘플 쿼리 삽입
|
||||
const insertSampleQuery = useCallback((sampleType: string) => {
|
||||
const samples = {
|
||||
comparison: `-- 제품별 월별 매출 비교 (다중 시리즈)
|
||||
-- 갤럭시(Galaxy) vs 아이폰(iPhone) 매출 비교
|
||||
SELECT
|
||||
DATE_TRUNC('month', order_date) as month,
|
||||
SUM(CASE WHEN product_category = '갤럭시' THEN amount ELSE 0 END) as galaxy_sales,
|
||||
SUM(CASE WHEN product_category = '아이폰' THEN amount ELSE 0 END) as iphone_sales,
|
||||
SUM(CASE WHEN product_category = '기타' THEN amount ELSE 0 END) as other_sales
|
||||
FROM orders
|
||||
WHERE order_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||
GROUP BY DATE_TRUNC('month', order_date)
|
||||
ORDER BY month;`,
|
||||
|
||||
sales: `-- 월별 매출 데이터
|
||||
SELECT
|
||||
DATE_TRUNC('month', order_date) as month,
|
||||
|
|
@ -88,7 +125,19 @@ JOIN products p ON oi.product_id = p.id
|
|||
WHERE oi.created_at >= CURRENT_DATE - INTERVAL '1 month'
|
||||
GROUP BY product_name
|
||||
ORDER BY total_sold DESC
|
||||
LIMIT 10;`
|
||||
LIMIT 10;`,
|
||||
|
||||
regional: `-- 지역별 매출 비교
|
||||
SELECT
|
||||
region as 지역,
|
||||
SUM(CASE WHEN quarter = 'Q1' THEN sales ELSE 0 END) as Q1,
|
||||
SUM(CASE WHEN quarter = 'Q2' THEN sales ELSE 0 END) as Q2,
|
||||
SUM(CASE WHEN quarter = 'Q3' THEN sales ELSE 0 END) as Q3,
|
||||
SUM(CASE WHEN quarter = 'Q4' THEN sales ELSE 0 END) as Q4
|
||||
FROM regional_sales
|
||||
WHERE year = EXTRACT(YEAR FROM CURRENT_DATE)
|
||||
GROUP BY region
|
||||
ORDER BY Q4 DESC;`
|
||||
};
|
||||
|
||||
setQuery(samples[sampleType as keyof typeof samples] || '');
|
||||
|
|
@ -124,6 +173,18 @@ LIMIT 10;`
|
|||
{/* 샘플 쿼리 버튼들 */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-600">샘플 쿼리:</span>
|
||||
<button
|
||||
onClick={() => insertSampleQuery('comparison')}
|
||||
className="px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 rounded font-medium"
|
||||
>
|
||||
🔥 제품 비교
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery('regional')}
|
||||
className="px-2 py-1 text-xs bg-green-100 hover:bg-green-200 rounded font-medium"
|
||||
>
|
||||
🌍 지역별 비교
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery('sales')}
|
||||
className="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded"
|
||||
|
|
@ -270,33 +331,89 @@ LIMIT 10;`
|
|||
*/
|
||||
function generateSampleQueryResult(query: string): QueryResult {
|
||||
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
|
||||
const isMonthly = query.toLowerCase().includes('month');
|
||||
const isSales = query.toLowerCase().includes('sales') || query.toLowerCase().includes('매출');
|
||||
const isUsers = query.toLowerCase().includes('users') || query.toLowerCase().includes('사용자');
|
||||
const isProducts = query.toLowerCase().includes('product') || query.toLowerCase().includes('상품');
|
||||
const isWeekly = query.toLowerCase().includes('week');
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
// 디버깅용 로그
|
||||
// console.log('generateSampleQueryResult called with query:', query.substring(0, 100));
|
||||
|
||||
// 가장 구체적인 조건부터 먼저 체크 (순서 중요!)
|
||||
const isComparison = queryLower.includes('galaxy') || queryLower.includes('갤럭시') || queryLower.includes('아이폰') || queryLower.includes('iphone');
|
||||
const isRegional = queryLower.includes('region') || queryLower.includes('지역');
|
||||
const isMonthly = queryLower.includes('month');
|
||||
const isSales = queryLower.includes('sales') || queryLower.includes('매출');
|
||||
const isUsers = queryLower.includes('users') || queryLower.includes('사용자');
|
||||
const isProducts = queryLower.includes('product') || queryLower.includes('상품');
|
||||
const isWeekly = queryLower.includes('week');
|
||||
|
||||
// console.log('Sample data type detection:', {
|
||||
// isComparison,
|
||||
// isRegional,
|
||||
// isWeekly,
|
||||
// isProducts,
|
||||
// isMonthly,
|
||||
// isSales,
|
||||
// isUsers,
|
||||
// querySnippet: query.substring(0, 200)
|
||||
// });
|
||||
|
||||
let columns: string[];
|
||||
let rows: Record<string, any>[];
|
||||
|
||||
if (isMonthly && isSales) {
|
||||
// 월별 매출 데이터
|
||||
columns = ['month', 'sales', 'order_count'];
|
||||
// 더 구체적인 조건부터 먼저 체크 (순서 중요!)
|
||||
if (isComparison) {
|
||||
// console.log('✅ Using COMPARISON data');
|
||||
// 제품 비교 데이터 (다중 시리즈)
|
||||
columns = ['month', 'galaxy_sales', 'iphone_sales', 'other_sales'];
|
||||
rows = [
|
||||
{ month: '2024-01', sales: 1200000, order_count: 45 },
|
||||
{ month: '2024-02', sales: 1350000, order_count: 52 },
|
||||
{ month: '2024-03', sales: 1180000, order_count: 41 },
|
||||
{ month: '2024-04', sales: 1420000, order_count: 58 },
|
||||
{ month: '2024-05', sales: 1680000, order_count: 67 },
|
||||
{ month: '2024-06', sales: 1540000, order_count: 61 },
|
||||
{ month: '2024-07', sales: 1720000, order_count: 71 },
|
||||
{ month: '2024-08', sales: 1580000, order_count: 63 },
|
||||
{ month: '2024-09', sales: 1650000, order_count: 68 },
|
||||
{ month: '2024-10', sales: 1780000, order_count: 75 },
|
||||
{ month: '2024-11', sales: 1920000, order_count: 82 },
|
||||
{ month: '2024-12', sales: 2100000, order_count: 89 },
|
||||
{ month: '2024-01', galaxy_sales: 450000, iphone_sales: 620000, other_sales: 130000 },
|
||||
{ month: '2024-02', galaxy_sales: 520000, iphone_sales: 680000, other_sales: 150000 },
|
||||
{ month: '2024-03', galaxy_sales: 480000, iphone_sales: 590000, other_sales: 110000 },
|
||||
{ month: '2024-04', galaxy_sales: 610000, iphone_sales: 650000, other_sales: 160000 },
|
||||
{ month: '2024-05', galaxy_sales: 720000, iphone_sales: 780000, other_sales: 180000 },
|
||||
{ month: '2024-06', galaxy_sales: 680000, iphone_sales: 690000, other_sales: 170000 },
|
||||
{ month: '2024-07', galaxy_sales: 750000, iphone_sales: 800000, other_sales: 170000 },
|
||||
{ month: '2024-08', galaxy_sales: 690000, iphone_sales: 720000, other_sales: 170000 },
|
||||
{ month: '2024-09', galaxy_sales: 730000, iphone_sales: 750000, other_sales: 170000 },
|
||||
{ month: '2024-10', galaxy_sales: 800000, iphone_sales: 810000, other_sales: 170000 },
|
||||
{ month: '2024-11', galaxy_sales: 870000, iphone_sales: 880000, other_sales: 170000 },
|
||||
{ month: '2024-12', galaxy_sales: 950000, iphone_sales: 990000, other_sales: 160000 },
|
||||
];
|
||||
// COMPARISON 데이터를 반환하고 함수 종료
|
||||
// console.log('COMPARISON data generated:', {
|
||||
// columns,
|
||||
// rowCount: rows.length,
|
||||
// sampleRow: rows[0],
|
||||
// allRows: rows,
|
||||
fieldTypes: {
|
||||
month: typeof rows[0].month,
|
||||
galaxy_sales: typeof rows[0].galaxy_sales,
|
||||
iphone_sales: typeof rows[0].iphone_sales,
|
||||
other_sales: typeof rows[0].other_sales
|
||||
},
|
||||
firstFewRows: rows.slice(0, 3),
|
||||
lastFewRows: rows.slice(-3)
|
||||
// });
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: Math.floor(Math.random() * 200) + 100,
|
||||
};
|
||||
} else if (isRegional) {
|
||||
// console.log('✅ Using REGIONAL data');
|
||||
// 지역별 분기별 매출
|
||||
columns = ['지역', 'Q1', 'Q2', 'Q3', 'Q4'];
|
||||
rows = [
|
||||
{ 지역: '서울', Q1: 1200000, Q2: 1350000, Q3: 1420000, Q4: 1580000 },
|
||||
{ 지역: '경기', Q1: 980000, Q2: 1120000, Q3: 1180000, Q4: 1290000 },
|
||||
{ 지역: '부산', Q1: 650000, Q2: 720000, Q3: 780000, Q4: 850000 },
|
||||
{ 지역: '대구', Q1: 450000, Q2: 490000, Q3: 520000, Q4: 580000 },
|
||||
{ 지역: '인천', Q1: 520000, Q2: 580000, Q3: 620000, Q4: 690000 },
|
||||
{ 지역: '광주', Q1: 380000, Q2: 420000, Q3: 450000, Q4: 490000 },
|
||||
{ 지역: '대전', Q1: 410000, Q2: 460000, Q3: 490000, Q4: 530000 },
|
||||
];
|
||||
} else if (isWeekly && isUsers) {
|
||||
// console.log('✅ Using USERS data');
|
||||
// 사용자 가입 추이
|
||||
columns = ['week', 'new_users'];
|
||||
rows = [
|
||||
|
|
@ -313,7 +430,8 @@ function generateSampleQueryResult(query: string): QueryResult {
|
|||
{ week: '2024-W20', new_users: 61 },
|
||||
{ week: '2024-W21', new_users: 58 },
|
||||
];
|
||||
} else if (isProducts) {
|
||||
} else if (isProducts && !isComparison) {
|
||||
// console.log('✅ Using PRODUCTS data');
|
||||
// 상품별 판매량
|
||||
columns = ['product_name', 'total_sold', 'revenue'];
|
||||
rows = [
|
||||
|
|
@ -328,7 +446,26 @@ function generateSampleQueryResult(query: string): QueryResult {
|
|||
{ product_name: '프린터', total_sold: 34, revenue: 17000000 },
|
||||
{ product_name: '웹캠', total_sold: 89, revenue: 8900000 },
|
||||
];
|
||||
} else if (isMonthly && isSales && !isComparison) {
|
||||
// console.log('✅ Using MONTHLY SALES data');
|
||||
// 월별 매출 데이터
|
||||
columns = ['month', 'sales', 'order_count'];
|
||||
rows = [
|
||||
{ month: '2024-01', sales: 1200000, order_count: 45 },
|
||||
{ month: '2024-02', sales: 1350000, order_count: 52 },
|
||||
{ month: '2024-03', sales: 1180000, order_count: 41 },
|
||||
{ month: '2024-04', sales: 1420000, order_count: 58 },
|
||||
{ month: '2024-05', sales: 1680000, order_count: 67 },
|
||||
{ month: '2024-06', sales: 1540000, order_count: 61 },
|
||||
{ month: '2024-07', sales: 1720000, order_count: 71 },
|
||||
{ month: '2024-08', sales: 1580000, order_count: 63 },
|
||||
{ month: '2024-09', sales: 1650000, order_count: 68 },
|
||||
{ month: '2024-10', sales: 1780000, order_count: 75 },
|
||||
{ month: '2024-11', sales: 1920000, order_count: 82 },
|
||||
{ month: '2024-12', sales: 2100000, order_count: 89 },
|
||||
];
|
||||
} else {
|
||||
// console.log('⚠️ Using DEFAULT data');
|
||||
// 기본 샘플 데이터
|
||||
columns = ['category', 'value', 'count'];
|
||||
rows = [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,100 +1,87 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { ChartConfig } from '../types';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
|
||||
interface BarChartComponentProps {
|
||||
data: any[];
|
||||
config: ChartConfig;
|
||||
config: any;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 바 차트 컴포넌트
|
||||
* - Recharts BarChart 사용
|
||||
* - 설정 가능한 색상, 축, 범례
|
||||
* 바 차트 컴포넌트 (Recharts SimpleBarChart 기반)
|
||||
* - 실제 데이터를 받아서 단순하게 표시
|
||||
* - 복잡한 변환 로직 없음
|
||||
*/
|
||||
export function BarChartComponent({ data, config, width = 250, height = 200 }: BarChartComponentProps) {
|
||||
const {
|
||||
xAxis = 'x',
|
||||
yAxis = 'y',
|
||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'],
|
||||
title,
|
||||
showLegend = true
|
||||
} = config;
|
||||
export function BarChartComponent({ data, config, width = 600, height = 300 }: BarChartComponentProps) {
|
||||
// console.log('🎨 BarChartComponent - 전체 데이터:', {
|
||||
// dataLength: data?.length,
|
||||
// fullData: data,
|
||||
// dataType: typeof data,
|
||||
// isArray: Array.isArray(data),
|
||||
// config,
|
||||
// xAxisField: config?.xAxis,
|
||||
// yAxisFields: config?.yAxis
|
||||
// });
|
||||
|
||||
// Y축에 해당하는 모든 키 찾기 (그룹핑된 데이터의 경우)
|
||||
const yKeys = data.length > 0
|
||||
? Object.keys(data[0]).filter(key => key !== xAxis && typeof data[0][key] === 'number')
|
||||
: [yAxis];
|
||||
// 데이터가 없으면 메시지 표시
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">📊</div>
|
||||
<div>데이터가 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터의 첫 번째 아이템에서 사용 가능한 키 확인
|
||||
const firstItem = data[0];
|
||||
const availableKeys = Object.keys(firstItem);
|
||||
// console.log('📊 사용 가능한 데이터 키:', availableKeys);
|
||||
// console.log('📊 첫 번째 데이터 아이템:', firstItem);
|
||||
|
||||
// Y축 필드 추출 (배열이면 모두 사용, 아니면 단일 값)
|
||||
const yFields = Array.isArray(config.yAxis) ? config.yAxis : [config.yAxis];
|
||||
|
||||
// 색상 배열
|
||||
const colors = ['#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1'];
|
||||
|
||||
// 한글 레이블 매핑
|
||||
const labelMapping: Record<string, string> = {
|
||||
'total_users': '전체 사용자',
|
||||
'active_users': '활성 사용자',
|
||||
'name': '부서'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey={xAxis}
|
||||
dataKey={config.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' }}
|
||||
/>
|
||||
)}
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip />
|
||||
{config.showLegend !== false && <Legend />}
|
||||
|
||||
{yKeys.map((key, index) => (
|
||||
{/* Y축 필드마다 Bar 생성 */}
|
||||
{yFields.map((field: string, index: number) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
key={field}
|
||||
dataKey={field}
|
||||
fill={colors[index % colors.length]}
|
||||
radius={[2, 2, 0, 0]}
|
||||
name={labelMapping[field] || field}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import { DashboardElement, QueryResult } from '../types';
|
|||
import { BarChartComponent } from './BarChartComponent';
|
||||
import { PieChartComponent } from './PieChartComponent';
|
||||
import { LineChartComponent } from './LineChartComponent';
|
||||
import { AreaChartComponent } from './AreaChartComponent';
|
||||
import { StackedBarChartComponent } from './StackedBarChartComponent';
|
||||
import { DonutChartComponent } from './DonutChartComponent';
|
||||
import { ComboChartComponent } from './ComboChartComponent';
|
||||
|
||||
interface ChartRendererProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -14,12 +18,20 @@ interface ChartRendererProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* 차트 렌더러 컴포넌트
|
||||
* - 차트 타입에 따라 적절한 차트 컴포넌트를 렌더링
|
||||
* - 데이터 변환 및 에러 처리
|
||||
* 차트 렌더러 컴포넌트 (단순 버전)
|
||||
* - 데이터를 받아서 차트에 그대로 전달
|
||||
* - 복잡한 변환 로직 제거
|
||||
*/
|
||||
export function ChartRenderer({ element, data, width = 250, height = 200 }: ChartRendererProps) {
|
||||
// 데이터가 없거나 설정이 불완전한 경우
|
||||
// console.log('🎬 ChartRenderer:', {
|
||||
// elementId: element.id,
|
||||
// hasData: !!data,
|
||||
// dataRows: data?.rows?.length,
|
||||
// xAxis: element.chartConfig?.xAxis,
|
||||
// yAxis: element.chartConfig?.yAxis
|
||||
// });
|
||||
|
||||
// 데이터나 설정이 없으면 메시지 표시
|
||||
if (!data || !element.chartConfig?.xAxis || !element.chartConfig?.yAxis) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm">
|
||||
|
|
@ -32,27 +44,33 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
|||
);
|
||||
}
|
||||
|
||||
// 데이터 변환
|
||||
const chartData = transformData(data, element.chartConfig);
|
||||
|
||||
// 에러가 있는 경우
|
||||
if (chartData.length === 0) {
|
||||
// 데이터가 비어있으면
|
||||
if (!data.rows || data.rows.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-red-500 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">⚠️</div>
|
||||
<div>데이터를 불러올 수 없습니다</div>
|
||||
<div>데이터가 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터를 그대로 전달 (변환 없음!)
|
||||
const chartData = data.rows;
|
||||
|
||||
// console.log('📊 Chart Data:', {
|
||||
// dataLength: chartData.length,
|
||||
// firstRow: chartData[0],
|
||||
// columns: Object.keys(chartData[0] || {})
|
||||
// });
|
||||
|
||||
// 차트 공통 props
|
||||
const chartProps = {
|
||||
data: chartData,
|
||||
config: element.chartConfig,
|
||||
width: width - 20, // 패딩 고려
|
||||
height: height - 60, // 헤더 높이 고려
|
||||
width: width - 20,
|
||||
height: height - 60,
|
||||
};
|
||||
|
||||
// 차트 타입에 따른 렌더링
|
||||
|
|
@ -63,6 +81,14 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
|||
return <PieChartComponent {...chartProps} />;
|
||||
case 'line':
|
||||
return <LineChartComponent {...chartProps} />;
|
||||
case 'area':
|
||||
return <AreaChartComponent {...chartProps} />;
|
||||
case 'stacked-bar':
|
||||
return <StackedBarChartComponent {...chartProps} />;
|
||||
case 'donut':
|
||||
return <DonutChartComponent {...chartProps} />;
|
||||
case 'combo':
|
||||
return <ComboChartComponent {...chartProps} />;
|
||||
default:
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500 text-sm">
|
||||
|
|
@ -74,122 +100,3 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 결과를 차트 데이터로 변환
|
||||
*/
|
||||
function transformData(queryResult: QueryResult, config: any) {
|
||||
try {
|
||||
const { xAxis, yAxis, groupBy, aggregation = 'sum' } = config;
|
||||
|
||||
if (!queryResult.rows || queryResult.rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 그룹핑이 있는 경우
|
||||
if (groupBy && groupBy !== xAxis) {
|
||||
const grouped = queryResult.rows.reduce((acc, row) => {
|
||||
const xValue = String(row[xAxis] || '');
|
||||
const groupValue = String(row[groupBy] || '');
|
||||
const yValue = Number(row[yAxis]) || 0;
|
||||
|
||||
if (!acc[xValue]) {
|
||||
acc[xValue] = { [xAxis]: xValue };
|
||||
}
|
||||
|
||||
if (!acc[xValue][groupValue]) {
|
||||
acc[xValue][groupValue] = 0;
|
||||
}
|
||||
|
||||
// 집계 함수 적용
|
||||
switch (aggregation) {
|
||||
case 'sum':
|
||||
acc[xValue][groupValue] += yValue;
|
||||
break;
|
||||
case 'avg':
|
||||
// 평균 계산을 위해 임시로 배열 저장
|
||||
if (!acc[xValue][`${groupValue}_values`]) {
|
||||
acc[xValue][`${groupValue}_values`] = [];
|
||||
}
|
||||
acc[xValue][`${groupValue}_values`].push(yValue);
|
||||
break;
|
||||
case 'count':
|
||||
acc[xValue][groupValue] += 1;
|
||||
break;
|
||||
case 'max':
|
||||
acc[xValue][groupValue] = Math.max(acc[xValue][groupValue], yValue);
|
||||
break;
|
||||
case 'min':
|
||||
acc[xValue][groupValue] = Math.min(acc[xValue][groupValue], yValue);
|
||||
break;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as any);
|
||||
|
||||
// 평균 계산 후처리
|
||||
if (aggregation === 'avg') {
|
||||
Object.keys(grouped).forEach(xValue => {
|
||||
Object.keys(grouped[xValue]).forEach(key => {
|
||||
if (key.endsWith('_values')) {
|
||||
const baseKey = key.replace('_values', '');
|
||||
const values = grouped[xValue][key];
|
||||
grouped[xValue][baseKey] = values.reduce((sum: number, val: number) => sum + val, 0) / values.length;
|
||||
delete grouped[xValue][key];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return Object.values(grouped);
|
||||
}
|
||||
|
||||
// 단순 변환 (그룹핑 없음)
|
||||
const dataMap = new Map();
|
||||
|
||||
queryResult.rows.forEach(row => {
|
||||
const xValue = String(row[xAxis] || '');
|
||||
const yValue = Number(row[yAxis]) || 0;
|
||||
|
||||
if (!dataMap.has(xValue)) {
|
||||
dataMap.set(xValue, { [xAxis]: xValue, [yAxis]: 0, count: 0 });
|
||||
}
|
||||
|
||||
const existing = dataMap.get(xValue);
|
||||
|
||||
switch (aggregation) {
|
||||
case 'sum':
|
||||
existing[yAxis] += yValue;
|
||||
break;
|
||||
case 'avg':
|
||||
existing[yAxis] += yValue;
|
||||
existing.count += 1;
|
||||
break;
|
||||
case 'count':
|
||||
existing[yAxis] += 1;
|
||||
break;
|
||||
case 'max':
|
||||
existing[yAxis] = Math.max(existing[yAxis], yValue);
|
||||
break;
|
||||
case 'min':
|
||||
existing[yAxis] = existing[yAxis] === 0 ? yValue : Math.min(existing[yAxis], yValue);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 평균 계산 후처리
|
||||
if (aggregation === 'avg') {
|
||||
dataMap.forEach(item => {
|
||||
if (item.count > 0) {
|
||||
item[yAxis] = item[yAxis] / item.count;
|
||||
}
|
||||
delete item.count;
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(dataMap.values()).slice(0, 50); // 최대 50개 데이터포인트
|
||||
} catch (error) {
|
||||
console.error('데이터 변환 오류:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ComposedChart,
|
||||
Bar,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { ChartConfig } from '../types';
|
||||
|
||||
interface ComboChartComponentProps {
|
||||
data: any[];
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 콤보 차트 컴포넌트 (바 + 라인)
|
||||
* - Recharts ComposedChart 사용
|
||||
* - 서로 다른 스케일의 데이터를 함께 표시
|
||||
* - 예: 매출(바) + 이익률(라인)
|
||||
*/
|
||||
export function ComboChartComponent({ data, config, width = 250, height = 200 }: ComboChartComponentProps) {
|
||||
const {
|
||||
xAxis = 'x',
|
||||
yAxis = 'y',
|
||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'],
|
||||
title,
|
||||
showLegend = true
|
||||
} = config;
|
||||
|
||||
// Y축 필드들 (단일 또는 다중)
|
||||
const yFields = Array.isArray(yAxis) ? yAxis : [yAxis];
|
||||
const yKeys = yFields.filter(field => field && field !== 'y');
|
||||
|
||||
// 첫 번째는 Bar, 나머지는 Line으로 표시
|
||||
const barKeys = yKeys.slice(0, 1);
|
||||
const lineKeys = yKeys.slice(1);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey={xAxis}
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#666"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
stroke="#666"
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
formatter={(value: any, name: string) => [
|
||||
typeof value === 'number' ? value.toLocaleString() : value,
|
||||
name
|
||||
]}
|
||||
/>
|
||||
{showLegend && yKeys.length > 1 && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 바 차트 */}
|
||||
{barKeys.map((key, index) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={colors[index % colors.length]}
|
||||
radius={[2, 2, 0, 0]}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 라인 차트 */}
|
||||
{lineKeys.map((key, index) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={colors[(barKeys.length + index) % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
))}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { ChartConfig } from '../types';
|
||||
|
||||
interface DonutChartComponentProps {
|
||||
data: any[];
|
||||
config: ChartConfig;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 도넛 차트 컴포넌트
|
||||
* - Recharts PieChart (innerRadius 사용) 사용
|
||||
* - 비율 표시에 적합 (중앙 공간 활용 가능)
|
||||
*/
|
||||
export function DonutChartComponent({ data, config, width = 250, height = 200 }: DonutChartComponentProps) {
|
||||
const {
|
||||
xAxis = 'x',
|
||||
yAxis = 'y',
|
||||
colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'],
|
||||
title,
|
||||
showLegend = true
|
||||
} = config;
|
||||
|
||||
// 파이 차트용 데이터 변환
|
||||
const pieData = data.map(item => ({
|
||||
name: String(item[xAxis] || ''),
|
||||
value: typeof item[yAxis as string] === 'number' ? item[yAxis as string] : 0
|
||||
}));
|
||||
|
||||
// 총합 계산
|
||||
const total = pieData.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
// 커스텀 라벨 (퍼센트 표시)
|
||||
const renderLabel = (entry: any) => {
|
||||
const percent = ((entry.value / total) * 100).toFixed(1);
|
||||
return `${percent}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2 flex flex-col">
|
||||
{title && (
|
||||
<div className="text-center text-sm font-semibold text-gray-700 mb-2">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderLabel}
|
||||
outerRadius={80}
|
||||
innerRadius={50}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
formatter={(value: any) => [
|
||||
typeof value === 'number' ? value.toLocaleString() : value,
|
||||
'값'
|
||||
]}
|
||||
/>
|
||||
{showLegend && (
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px' }}
|
||||
layout="vertical"
|
||||
align="right"
|
||||
verticalAlign="middle"
|
||||
/>
|
||||
)}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* 중앙 총합 표시 */}
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500">Total</div>
|
||||
<div className="text-sm font-bold text-gray-800">
|
||||
{total.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -34,10 +34,11 @@ export function LineChartComponent({ data, config, width = 250, height = 200 }:
|
|||
showLegend = true
|
||||
} = config;
|
||||
|
||||
// Y축에 해당하는 모든 키 찾기 (그룹핑된 데이터의 경우)
|
||||
const yKeys = data.length > 0
|
||||
? Object.keys(data[0]).filter(key => key !== xAxis && typeof data[0][key] === 'number')
|
||||
: [yAxis];
|
||||
// Y축 필드들 (단일 또는 다중)
|
||||
const yFields = Array.isArray(yAxis) ? yAxis : [yAxis];
|
||||
|
||||
// 사용할 Y축 키들 결정
|
||||
const yKeys = yFields.filter(field => field && field !== 'y');
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-2">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,3 +6,7 @@ export { ChartRenderer } from './ChartRenderer';
|
|||
export { BarChartComponent } from './BarChartComponent';
|
||||
export { PieChartComponent } from './PieChartComponent';
|
||||
export { LineChartComponent } from './LineChartComponent';
|
||||
export { AreaChartComponent } from './AreaChartComponent';
|
||||
export { StackedBarChartComponent } from './StackedBarChartComponent';
|
||||
export { DonutChartComponent } from './DonutChartComponent';
|
||||
export { ComboChartComponent } from './ComboChartComponent';
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
export type ElementType = 'chart' | 'widget';
|
||||
|
||||
export type ElementSubtype =
|
||||
| 'bar' | 'pie' | 'line' // 차트 타입
|
||||
| 'bar' | 'pie' | 'line' | 'area' | 'stacked-bar' | 'donut' | 'combo' // 차트 타입
|
||||
| 'exchange' | 'weather'; // 위젯 타입
|
||||
|
||||
export interface Position {
|
||||
|
|
@ -51,7 +51,7 @@ export interface ChartDataSource {
|
|||
|
||||
export interface ChartConfig {
|
||||
xAxis?: string; // X축 데이터 필드
|
||||
yAxis?: string; // Y축 데이터 필드
|
||||
yAxis?: string | string[]; // Y축 데이터 필드 (단일 또는 다중)
|
||||
groupBy?: string; // 그룹핑 필드
|
||||
aggregation?: 'sum' | 'avg' | 'count' | 'max' | 'min';
|
||||
colors?: string[]; // 차트 색상
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 '알 수 없는 오류가 발생했습니다.';
|
||||
}
|
||||
Loading…
Reference in New Issue